From 3d906b61bc03ce25245589fcb6b9309ee06c5ae9 Mon Sep 17 00:00:00 2001 From: NeoPilot <221231603+neopilotai@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:08:29 +0000 Subject: [PATCH 01/13] =?UTF-8?q?=F0=9F=93=A6=20db:=20update=20database=20?= =?UTF-8?q?schema=20and=20migrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/db/CHANGELOG.md | 74 ++ packages/db/package.json | 2 +- .../scripts/create-auth-bootstrap-invite.ts | 84 ++ packages/db/src/backup-lib.test.ts | 14 +- packages/db/src/backup-lib.ts | 301 ++++-- packages/db/src/client.test.ts | 98 +- packages/db/src/index.ts | 1 + packages/db/src/migration-runtime.ts | 6 +- .../migrations/0057_cheerful_betty_ross.sql | 4 - .../migrations/0057_tidy_join_requests.sql | 57 ++ .../migrations/0058_classy_edwin_jarvis.sql | 6 - .../src/migrations/0058_wealthy_starbolt.sql | 6 + .../0059_plugin_database_namespaces.sql | 41 + .../db/src/migrations/0059_sour_micromax.sql | 2 - .../src/migrations/0060_orange_annihilus.sql | 50 + .../db/src/migrations/0060_wild_psylocke.sql | 22 - .../src/migrations/0061_lively_thor_girl.sql | 3 + .../0062_routine_run_dispatch_fingerprint.sql | 9 + .../0063_issue_thread_interactions.sql | 65 ++ ...4_issue_thread_interaction_idempotency.sql | 4 + .../db/src/migrations/meta/0057_snapshot.json | 73 +- .../db/src/migrations/meta/0058_snapshot.json | 118 ++- .../db/src/migrations/meta/0060_snapshot.json | 889 ++++++++++++----- ...{0059_snapshot.json => 0061_snapshot.json} | 933 +++++++++++++++--- packages/db/src/migrations/meta/_journal.json | 46 +- packages/db/src/runtime-config.ts | 52 +- packages/db/src/schema/agent_runtime_state.ts | 3 +- .../db/src/schema/agent_wakeup_requests.ts | 1 - packages/db/src/schema/budget_policies.ts | 7 +- packages/db/src/schema/heartbeat_runs.ts | 14 +- packages/db/src/schema/index.ts | 4 +- packages/db/src/schema/issue_artifacts.ts | 35 - .../db/src/schema/issue_reference_mentions.ts | 48 + .../src/schema/issue_thread_interactions.ts | 54 + packages/db/src/schema/issues.ts | 3 +- packages/db/src/schema/join_requests.ts | 7 + packages/db/src/schema/plugin_database.ts | 75 ++ packages/db/src/schema/routines.ts | 2 + packages/db/src/test-embedded-postgres.ts | 14 +- 39 files changed, 2594 insertions(+), 633 deletions(-) create mode 100644 packages/db/scripts/create-auth-bootstrap-invite.ts delete mode 100644 packages/db/src/migrations/0057_cheerful_betty_ross.sql create mode 100644 packages/db/src/migrations/0057_tidy_join_requests.sql delete mode 100644 packages/db/src/migrations/0058_classy_edwin_jarvis.sql create mode 100644 packages/db/src/migrations/0058_wealthy_starbolt.sql create mode 100644 packages/db/src/migrations/0059_plugin_database_namespaces.sql delete mode 100644 packages/db/src/migrations/0059_sour_micromax.sql create mode 100644 packages/db/src/migrations/0060_orange_annihilus.sql delete mode 100644 packages/db/src/migrations/0060_wild_psylocke.sql create mode 100644 packages/db/src/migrations/0061_lively_thor_girl.sql create mode 100644 packages/db/src/migrations/0062_routine_run_dispatch_fingerprint.sql create mode 100644 packages/db/src/migrations/0063_issue_thread_interactions.sql create mode 100644 packages/db/src/migrations/0064_issue_thread_interaction_idempotency.sql rename packages/db/src/migrations/meta/{0059_snapshot.json => 0061_snapshot.json} (95%) delete mode 100644 packages/db/src/schema/issue_artifacts.ts create mode 100644 packages/db/src/schema/issue_reference_mentions.ts create mode 100644 packages/db/src/schema/issue_thread_interactions.ts create mode 100644 packages/db/src/schema/plugin_database.ts diff --git a/packages/db/CHANGELOG.md b/packages/db/CHANGELOG.md index 5d1db52..1fccf86 100644 --- a/packages/db/CHANGELOG.md +++ b/packages/db/CHANGELOG.md @@ -1,3 +1,77 @@ # @taskcore/db +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @taskcore/shared@0.3.1 + +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies [6077ae6] +- Updated dependencies + - @taskcore/shared@0.3.0 + +## 0.2.7 + +### Patch Changes + +- Version bump (patch) +- Updated dependencies + - @taskcore/shared@0.2.7 + +## 0.2.6 + +### Patch Changes + +- Version bump (patch) +- Updated dependencies + - @taskcore/shared@0.2.6 + +## 0.2.5 + +### Patch Changes + +- Version bump (patch) +- Updated dependencies + - @taskcore/shared@0.2.5 + +## 0.2.4 + +### Patch Changes + +- Version bump (patch) +- Updated dependencies + - @taskcore/shared@0.2.4 + +## 0.2.3 + +### Patch Changes + +- Version bump (patch) +- Updated dependencies + - @taskcore/shared@0.2.3 + +## 0.2.2 + +### Patch Changes + +- Version bump (patch) +- Updated dependencies + - @taskcore/shared@0.2.2 + ## 0.2.1 + +### Patch Changes + +- Version bump (patch) +- Updated dependencies + - @taskcore/shared@0.2.1 diff --git a/packages/db/package.json b/packages/db/package.json index f112197..670fe53 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -56,4 +56,4 @@ "typescript": "^5.7.3", "vitest": "^3.0.5" } -} \ No newline at end of file +} diff --git a/packages/db/scripts/create-auth-bootstrap-invite.ts b/packages/db/scripts/create-auth-bootstrap-invite.ts new file mode 100644 index 0000000..f6b8006 --- /dev/null +++ b/packages/db/scripts/create-auth-bootstrap-invite.ts @@ -0,0 +1,84 @@ +import { createHash, randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { and, eq, gt, isNull } from "drizzle-orm"; +import { createDb } from "../src/client.js"; +import { invites } from "../src/schema/index.js"; + +function hashToken(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +function createInviteToken() { + return `pcp_bootstrap_${randomBytes(24).toString("hex")}`; +} + +function readArg(flag: string) { + const index = process.argv.indexOf(flag); + if (index === -1) return null; + return process.argv[index + 1] ?? null; +} + +async function main() { + const configPath = readArg("--config"); + const baseUrl = readArg("--base-url"); + + if (!configPath || !baseUrl) { + throw new Error("Usage: tsx create-auth-bootstrap-invite.ts --config --base-url "); + } + + const config = JSON.parse(readFileSync(path.resolve(configPath), "utf8")) as { + database?: { + mode?: string; + embeddedPostgresPort?: number; + connectionString?: string; + }; + }; + const dbUrl = + config.database?.mode === "postgres" + ? config.database.connectionString + : `postgres://taskcore:taskcore@127.0.0.1:${config.database?.embeddedPostgresPort ?? 54329}/taskcore`; + if (!dbUrl) { + throw new Error(`Could not resolve database connection from ${configPath}`); + } + + const db = createDb(dbUrl); + const closableDb = db as typeof db & { + $client?: { + end?: (options?: { timeout?: number }) => Promise; + }; + }; + + try { + const now = new Date(); + await db + .update(invites) + .set({ revokedAt: now, updatedAt: now }) + .where( + and( + eq(invites.inviteType, "bootstrap_ceo"), + isNull(invites.revokedAt), + isNull(invites.acceptedAt), + gt(invites.expiresAt, now) + ) + ); + + const token = createInviteToken(); + await db.insert(invites).values({ + inviteType: "bootstrap_ceo", + tokenHash: hashToken(token), + allowedJoinTypes: "human", + expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000), + invitedByUserId: "system", + }); + + process.stdout.write(`${baseUrl.replace(/\/+$/, "")}/invite/${token}\n`); + } finally { + await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined); + } +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts index 3b94c83..2b98bd7 100644 --- a/packages/db/src/backup-lib.test.ts +++ b/packages/db/src/backup-lib.test.ts @@ -83,8 +83,8 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { "taskcore_restore_target", ); const backupDir = createTempDir("taskcore-db-backup-output-"); - const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => { } }); - const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => { } }); + const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} }); + const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} }); try { await sourceSql.unsafe(` @@ -127,6 +127,7 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { backupDir, retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: "taskcore-test", + backupEngine: "javascript", }); expect(result.backupFile).toMatch(/taskcore-test-.*\.sql\.gz$/); @@ -148,14 +149,17 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { title: string; payload: string; state: string; - metadata: { index: number; even: boolean }; + metadata: { index: number; even: boolean } | string; }[]>(` SELECT "title", "payload", "state"::text AS "state", "metadata" FROM "public"."backup_test_records" WHERE "title" IN ('row-0', 'row-159') ORDER BY "title" `); - expect(sampleRows).toEqual([ + expect(sampleRows.map((row) => ({ + ...row, + metadata: typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata, + }))).toEqual([ { title: "row-0", payload, @@ -181,7 +185,7 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { "restores statements incrementally when backup comments precede the first breakpoint", async () => { const restoreConnectionString = await createTempDatabase(); - const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => { } }); + const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} }); const backupDir = createTempDir("taskcore-db-restore-manual-"); const backupFile = path.join(backupDir, "manual.sql"); diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index d0cffd4..8e58513 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -1,6 +1,8 @@ import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; import { basename, resolve } from "node:path"; import { createInterface } from "node:readline"; +import { spawn } from "node:child_process"; +import { open as openFile } from "node:fs/promises"; import { pipeline } from "node:stream/promises"; import { createGunzip, createGzip } from "node:zlib"; import postgres from "postgres"; @@ -20,6 +22,7 @@ export type RunDatabaseBackupOptions = { includeMigrationJournal?: boolean; excludeTables?: string[]; nullifyColumns?: Record; + backupEngine?: "auto" | "pg_dump" | "javascript"; }; export type RunDatabaseBackupResult = { @@ -61,6 +64,9 @@ type ExtensionDefinition = { const DRIZZLE_SCHEMA = "drizzle"; const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations"; const DEFAULT_BACKUP_WRITE_BUFFER_BYTES = 1024 * 1024; +const BACKUP_DATA_CURSOR_ROWS = 100; +const BACKUP_CLI_STDERR_BYTES = 64 * 1024; +const BACKUP_BREAKPOINT_DETECT_BYTES = 64 * 1024; const STATEMENT_BREAKPOINT = "-- taskcore statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900"; @@ -223,6 +229,134 @@ function tableKey(schemaName: string, tableName: string): string { return `${schemaName}.${tableName}`; } +function hasBackupTransforms(opts: RunDatabaseBackupOptions): boolean { + return opts.includeMigrationJournal === true || + (opts.excludeTables?.length ?? 0) > 0 || + Object.keys(opts.nullifyColumns ?? {}).length > 0; +} + +function formatSqlValue(rawValue: unknown, columnName: string | undefined, nullifiedColumns: Set): string { + const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue; + if (val === null || val === undefined) return "NULL"; + if (typeof val === "boolean") return val ? "true" : "false"; + if (typeof val === "number") return String(val); + if (val instanceof Date) return formatSqlLiteral(val.toISOString()); + if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val)); + return formatSqlLiteral(String(val)); +} + +function appendCapturedStderr(previous: string, chunk: Buffer | string): string { + const next = previous + (Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk); + if (Buffer.byteLength(next, "utf8") <= BACKUP_CLI_STDERR_BYTES) return next; + return Buffer.from(next, "utf8").subarray(-BACKUP_CLI_STDERR_BYTES).toString("utf8"); +} + +async function waitForChildExit(child: ReturnType, label: string): Promise { + let stderr = ""; + child.stderr?.on("data", (chunk) => { + stderr = appendCapturedStderr(stderr, chunk); + }); + + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + + if (result.signal) { + throw new Error(`${label} exited via ${result.signal}${stderr.trim() ? `: ${stderr.trim()}` : ""}`); + } + if (result.code !== 0) { + throw new Error(`${label} failed with exit code ${result.code ?? "unknown"}${stderr.trim() ? `: ${stderr.trim()}` : ""}`); + } +} + +async function runPgDumpBackup(opts: { + connectionString: string; + backupFile: string; + connectTimeout: number; +}): Promise { + const pgDumpBin = process.env.TASKCORE_PG_DUMP_PATH || "pg_dump"; + const child = spawn( + pgDumpBin, + [ + `--dbname=${opts.connectionString}`, + "--format=plain", + "--clean", + "--if-exists", + "--no-owner", + "--no-privileges", + "--schema=public", + ], + { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + PGCONNECT_TIMEOUT: String(opts.connectTimeout), + }, + }, + ); + + if (!child.stdout) { + throw new Error("pg_dump did not expose stdout"); + } + + await Promise.all([ + pipeline(child.stdout, createGzip(), createWriteStream(opts.backupFile)), + waitForChildExit(child, pgDumpBin), + ]); +} + +async function restoreWithPsql(opts: RunDatabaseRestoreOptions, connectTimeout: number): Promise { + const psqlBin = process.env.TASKCORE_PSQL_PATH || "psql"; + const child = spawn( + psqlBin, + [ + `--dbname=${opts.connectionString}`, + "--set=ON_ERROR_STOP=1", + "--quiet", + "--no-psqlrc", + ], + { + stdio: ["pipe", "ignore", "pipe"], + env: { + ...process.env, + PGCONNECT_TIMEOUT: String(connectTimeout), + }, + }, + ); + + if (!child.stdin) { + throw new Error("psql did not expose stdin"); + } + + const input = opts.backupFile.endsWith(".gz") + ? createReadStream(opts.backupFile).pipe(createGunzip()) + : createReadStream(opts.backupFile); + + await Promise.all([ + pipeline(input, child.stdin), + waitForChildExit(child, psqlBin), + ]); +} + +async function hasStatementBreakpoints(backupFile: string): Promise { + const raw = createReadStream(backupFile); + const stream = backupFile.endsWith(".gz") ? raw.pipe(createGunzip()) : raw; + let text = ""; + + try { + for await (const chunk of stream) { + text += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + if (text.includes(STATEMENT_BREAKPOINT)) return true; + if (Buffer.byteLength(text, "utf8") >= BACKUP_BREAKPOINT_DETECT_BYTES) return false; + } + return text.includes(STATEMENT_BREAKPOINT); + } finally { + stream.destroy(); + raw.destroy(); + } +} + async function* readRestoreStatements(backupFile: string): AsyncGenerator { const raw = createReadStream(backupFile); const stream = backupFile.endsWith(".gz") ? raw.pipe(createGunzip()) : raw; @@ -263,41 +397,21 @@ async function* readRestoreStatements(backupFile: string): AsyncGenerator { - streamError = error; - }); - - const writeChunk = async (chunk: string): Promise => { - if (streamError) throw streamError; - const canContinue = stream.write(chunk); - if (!canContinue) { - await new Promise((resolve, reject) => { - const handleDrain = () => { - cleanup(); - resolve(); - }; - const handleError = (error: Error) => { - cleanup(); - reject(error); - }; - const cleanup = () => { - stream.off("drain", handleDrain); - stream.off("error", handleError); - }; - stream.once("drain", handleDrain); - stream.once("error", handleError); - }); + const writeChunk = async (chunk: string | Buffer): Promise => { + const file = await filePromise; + if (typeof chunk === "string") { + await file.write(chunk, null, "utf8"); + } else { + await file.write(chunk); } - if (streamError) throw streamError; }; const flushBufferedLines = () => { @@ -316,37 +430,43 @@ export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes if (closed) { throw new Error(`Cannot write to closed backup file: ${filePath}`); } - if (streamError) throw streamError; bufferedLines.push(line); bufferedBytes += Buffer.byteLength(line, "utf8") + 1; if (bufferedBytes >= flushThreshold) { flushBufferedLines(); } }, + async drain() { + if (closed) { + throw new Error(`Cannot drain closed backup file: ${filePath}`); + } + flushBufferedLines(); + await pendingWrite; + }, + async writeRaw(chunk: string | Buffer) { + if (closed) { + throw new Error(`Cannot write to closed backup file: ${filePath}`); + } + flushBufferedLines(); + firstChunk = false; + pendingWrite = pendingWrite.then(() => writeChunk(chunk)); + await pendingWrite; + }, async close() { if (closed) return; closed = true; flushBufferedLines(); await pendingWrite; - await new Promise((resolve, reject) => { - if (streamError) { - reject(streamError); - return; - } - stream.end((error?: Error | null) => { - if (error) reject(error); - else resolve(); - }); - }); - if (streamError) throw streamError; + const file = await filePromise; + await file.close(); }, async abort() { if (closed) return; closed = true; bufferedLines = []; bufferedBytes = 0; - stream.destroy(); - await pendingWrite.catch(() => { }); + await pendingWrite.catch(() => {}); + await filePromise.then((file) => file.close()).catch(() => {}); if (existsSync(filePath)) { try { unlinkSync(filePath); @@ -362,16 +482,53 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise const filenamePrefix = opts.filenamePrefix ?? "taskcore"; const retention = opts.retention; const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); + const backupEngine = opts.backupEngine ?? "auto"; + const canUsePgDump = !hasBackupTransforms(opts); const includeMigrationJournal = opts.includeMigrationJournal === true; const excludedTableNames = normalizeTableNameSet(opts.excludeTables); const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns); - const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); + let sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); + let sqlClosed = false; + const closeSql = async () => { + if (sqlClosed) return; + sqlClosed = true; + await sql.end(); + }; mkdirSync(opts.backupDir, { recursive: true }); const sqlFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`); const backupFile = `${sqlFile}.gz`; const writer = createBufferedTextFileWriter(sqlFile); try { + if (backupEngine === "pg_dump" || (backupEngine === "auto" && canUsePgDump)) { + await sql`SELECT 1`; + try { + await closeSql(); + await runPgDumpBackup({ + connectionString: opts.connectionString, + backupFile, + connectTimeout, + }); + await writer.abort(); + const sizeBytes = statSync(backupFile).size; + const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix); + return { + backupFile, + sizeBytes, + prunedCount, + }; + } catch (error) { + if (existsSync(backupFile)) { + try { unlinkSync(backupFile); } catch { /* ignore */ } + } + if (backupEngine === "pg_dump") { + throw error; + } + sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); + sqlClosed = false; + } + } + await sql`SELECT 1`; const emit = (line: string) => writer.emit(line); @@ -703,20 +860,39 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise emit(`-- Data for: ${schema_name}.${tablename} (${count[0]!.n} rows)`); - const rows = await sql.unsafe(`SELECT * FROM ${qualifiedTableName}`).values(); const nullifiedColumns = nullifiedColumnsByTable.get(tablename) ?? new Set(); - for (const row of rows) { - const values = row.map((rawValue: unknown, index) => { - const columnName = cols[index]?.column_name; - const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue; - if (val === null || val === undefined) return "NULL"; - if (typeof val === "boolean") return val ? "true" : "false"; - if (typeof val === "number") return String(val); - if (val instanceof Date) return formatSqlLiteral(val.toISOString()); - if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val)); - return formatSqlLiteral(String(val)); - }); - emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`); + if (backupEngine !== "javascript" && nullifiedColumns.size === 0) { + emit(`COPY ${qualifiedTableName} (${colNames}) FROM stdin;`); + await writer.writeRaw("\n"); + const copySql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); + try { + const copyStream = await copySql + .unsafe(`COPY ${qualifiedTableName} (${colNames}) TO STDOUT`) + .readable(); + for await (const chunk of copyStream) { + await writer.writeRaw(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + } + } finally { + await copySql.end(); + } + await writer.writeRaw("\\.\n"); + emitStatementBoundary(); + emit(""); + continue; + } + + const rowCursor = sql + .unsafe(`SELECT * FROM ${qualifiedTableName}`) + .values() + .cursor(BACKUP_DATA_CURSOR_ROWS) as AsyncIterable; + for await (const rows of rowCursor) { + for (const row of rows) { + const values = row.map((rawValue, index) => + formatSqlValue(rawValue, cols[index]?.column_name, nullifiedColumns), + ); + emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`); + } + await writer.drain(); } emit(""); } @@ -731,7 +907,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise ); const skipSequenceValue = seq.owner_table !== null - && excludedTableNames.has(seq.owner_table); + && excludedTableNames.has(seq.owner_table); if (val[0] && !skipSequenceValue) { emitStatement(`SELECT setval('${qualifiedSequenceName.replaceAll("'", "''")}', ${val[0].last_value}, ${val[0].is_called ? "true" : "false"});`); } @@ -768,12 +944,23 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise } throw error; } finally { - await sql.end(); + await closeSql(); } } export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promise { const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); + try { + await restoreWithPsql(opts, connectTimeout); + return; + } catch (error) { + if (!(await hasStatementBreakpoints(opts.backupFile))) { + throw new Error( + `Failed to restore ${basename(opts.backupFile)} with psql: ${sanitizeRestoreErrorMessage(error)}`, + ); + } + } + const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); try { diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts index 468148c..185ec6e 100644 --- a/packages/db/src/client.test.ts +++ b/packages/db/src/client.test.ts @@ -50,7 +50,7 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { await applyPendingMigrations(connectionString); - const sql = postgres(connectionString, { max: 1, onnotice: () => { } }); + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); try { const richMagnetoHash = await migrationHash("0030_rich_magneto.sql"); @@ -74,7 +74,7 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { const finalState = await inspectMigrations(connectionString); expect(finalState.status).toBe("upToDate"); - const verifySql = postgres(connectionString, { max: 1, onnotice: () => { } }); + const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} }); try { const rows = await verifySql.unsafe<{ table_name: string }[]>( ` @@ -103,7 +103,7 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { await applyPendingMigrations(connectionString); - const sql = postgres(connectionString, { max: 1, onnotice: () => { } }); + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); try { const illegalToadHash = await migrationHash("0044_illegal_toad.sql"); @@ -147,7 +147,7 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { await applyPendingMigrations(connectionString); - const sql = postgres(connectionString, { max: 1, onnotice: () => { } }); + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); try { await sql.unsafe(` INSERT INTO "user" ("id", "name", "email", "email_verified", "created_at", "updated_at") @@ -177,7 +177,7 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { await applyPendingMigrations(connectionString); - const sql = postgres(connectionString, { max: 1, onnotice: () => { } }); + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); try { const smoothSentinelsHash = await migrationHash("0046_smooth_sentinels.sql"); @@ -212,7 +212,7 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { const finalState = await inspectMigrations(connectionString); expect(finalState.status).toBe("upToDate"); - const verifySql = postgres(connectionString, { max: 1, onnotice: () => { } }); + const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} }); try { const columns = await verifySql.unsafe<{ column_name: string; is_nullable: string; column_default: string | null }[]>( ` @@ -249,7 +249,7 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { await applyPendingMigrations(connectionString); - const sql = postgres(connectionString, { max: 1, onnotice: () => { } }); + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); try { const overjoyedGrootHash = await migrationHash("0047_overjoyed_groot.sql"); @@ -306,7 +306,7 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { const finalState = await inspectMigrations(connectionString); expect(finalState.status).toBe("upToDate"); - const verifySql = postgres(connectionString, { max: 1, onnotice: () => { } }); + const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} }); try { const constraints = await verifySql.unsafe<{ conname: string }[]>( ` @@ -343,7 +343,7 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { await applyPendingMigrations(connectionString); - const sql = postgres(connectionString, { max: 1, onnotice: () => { } }); + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); try { const flashyMarrowHash = await migrationHash("0048_flashy_marrow.sql"); @@ -377,7 +377,7 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { const finalState = await inspectMigrations(connectionString); expect(finalState.status).toBe("upToDate"); - const verifySql = postgres(connectionString, { max: 1, onnotice: () => { } }); + const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} }); try { const columns = await verifySql.unsafe<{ column_name: string; is_nullable: string; data_type: string }[]>( ` @@ -409,7 +409,7 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { await applyPendingMigrations(connectionString); - const sql = postgres(connectionString, { max: 1, onnotice: () => { } }); + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); try { const stiffLuckmanHash = await migrationHash("0050_stiff_luckman.sql"); @@ -443,7 +443,7 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { const finalState = await inspectMigrations(connectionString); expect(finalState.status).toBe("upToDate"); - const verifySql = postgres(connectionString, { max: 1, onnotice: () => { } }); + const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} }); try { const columns = await verifySql.unsafe<{ column_name: string; is_nullable: string; data_type: string }[]>( ` @@ -467,4 +467,78 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { }, 20_000, ); + + it( + "replays migration 0059 safely when plugin_database_namespaces already exists", + async () => { + const connectionString = await createTempDatabase(); + + await applyPendingMigrations(connectionString); + + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + const pluginNamespacesHash = await migrationHash( + "0059_plugin_database_namespaces.sql", + ); + + await sql.unsafe( + `DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${pluginNamespacesHash}'`, + ); + + const tables = await sql.unsafe<{ table_name: string }[]>( + ` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ('plugin_database_namespaces', 'plugin_migrations') + ORDER BY table_name + `, + ); + expect(tables.map((row) => row.table_name)).toEqual([ + "plugin_database_namespaces", + "plugin_migrations", + ]); + } finally { + await sql.end(); + } + + const pendingState = await inspectMigrations(connectionString); + expect(pendingState).toMatchObject({ + status: "needsMigrations", + pendingMigrations: ["0059_plugin_database_namespaces.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 indexes = await verifySql.unsafe<{ indexname: string }[]>( + ` + SELECT indexname + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename IN ('plugin_database_namespaces', 'plugin_migrations') + ORDER BY indexname + `, + ); + expect(indexes.map((row) => row.indexname)).toEqual( + expect.arrayContaining([ + "plugin_database_namespaces_namespace_idx", + "plugin_database_namespaces_plugin_idx", + "plugin_database_namespaces_status_idx", + "plugin_migrations_plugin_idx", + "plugin_migrations_plugin_key_idx", + "plugin_migrations_status_idx", + ]), + ); + } finally { + await verifySql.end(); + } + }, + 20_000, + ); }); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 200e298..3bf4d22 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -31,4 +31,5 @@ export { formatEmbeddedPostgresError, } from "./embedded-postgres-error.js"; export { issueRelations } from "./schema/issue_relations.js"; +export { issueReferenceMentions } from "./schema/issue_reference_mentions.js"; export * from "./schema/index.js"; diff --git a/packages/db/src/migration-runtime.ts b/packages/db/src/migration-runtime.ts index 96e261d..6716459 100644 --- a/packages/db/src/migration-runtime.ts +++ b/packages/db/src/migration-runtime.ts @@ -116,7 +116,7 @@ async function ensureEmbeddedPostgresConnection( return { connectionString: `postgres://taskcore:taskcore@127.0.0.1:${preferredPort}/taskcore`, source: `embedded-postgres@${preferredPort}`, - stop: async () => { }, + stop: async () => {}, }; } catch { // Fall through and attempt to start the configured embedded cluster. @@ -130,7 +130,7 @@ async function ensureEmbeddedPostgresConnection( return { connectionString: `postgres://taskcore:taskcore@127.0.0.1:${port}/taskcore`, source: `embedded-postgres@${port}`, - stop: async () => { }, + stop: async () => {}, }; } @@ -186,7 +186,7 @@ export async function resolveMigrationConnection(): Promise return { connectionString: target.connectionString, source: target.source, - stop: async () => { }, + stop: async () => {}, }; } diff --git a/packages/db/src/migrations/0057_cheerful_betty_ross.sql b/packages/db/src/migrations/0057_cheerful_betty_ross.sql deleted file mode 100644 index 7e1f87f..0000000 --- a/packages/db/src/migrations/0057_cheerful_betty_ross.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE "agents" ADD COLUMN "goal" text;--> statement-breakpoint -ALTER TABLE "agents" ADD COLUMN "constraints" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint -ALTER TABLE "agents" ADD COLUMN "memory_settings" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint -ALTER TABLE "agents" ADD COLUMN "tools" jsonb DEFAULT '[]'::jsonb NOT NULL; \ No newline at end of file diff --git a/packages/db/src/migrations/0057_tidy_join_requests.sql b/packages/db/src/migrations/0057_tidy_join_requests.sql new file mode 100644 index 0000000..8ab27b4 --- /dev/null +++ b/packages/db/src/migrations/0057_tidy_join_requests.sql @@ -0,0 +1,57 @@ +WITH ranked_user_requests AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY company_id, requesting_user_id + ORDER BY created_at ASC, id ASC + ) AS rank + FROM join_requests + WHERE request_type = 'human' + AND status = 'pending_approval' + AND requesting_user_id IS NOT NULL +) +UPDATE join_requests +SET + status = 'rejected', + rejected_at = COALESCE(rejected_at, now()), + updated_at = now() +WHERE id IN ( + SELECT id + FROM ranked_user_requests + WHERE rank > 1 +); +--> statement-breakpoint +WITH ranked_email_requests AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY company_id, lower(request_email_snapshot) + ORDER BY created_at ASC, id ASC + ) AS rank + FROM join_requests + WHERE request_type = 'human' + AND status = 'pending_approval' + AND request_email_snapshot IS NOT NULL +) +UPDATE join_requests +SET + status = 'rejected', + rejected_at = COALESCE(rejected_at, now()), + updated_at = now() +WHERE id IN ( + SELECT id + FROM ranked_email_requests + WHERE rank > 1 +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "join_requests_pending_human_user_uq" +ON "join_requests" USING btree ("company_id", "requesting_user_id") +WHERE "request_type" = 'human' + AND "status" = 'pending_approval' + AND "requesting_user_id" IS NOT NULL; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "join_requests_pending_human_email_uq" +ON "join_requests" USING btree ("company_id", lower("request_email_snapshot")) +WHERE "request_type" = 'human' + AND "status" = 'pending_approval' + AND "request_email_snapshot" IS NOT NULL; diff --git a/packages/db/src/migrations/0058_classy_edwin_jarvis.sql b/packages/db/src/migrations/0058_classy_edwin_jarvis.sql deleted file mode 100644 index b54e09d..0000000 --- a/packages/db/src/migrations/0058_classy_edwin_jarvis.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE "agent_wakeup_requests" ADD COLUMN "wake_reason" text;--> statement-breakpoint -ALTER TABLE "heartbeat_runs" ADD COLUMN "wake_reason" text;--> statement-breakpoint -ALTER TABLE "agents" DROP COLUMN "goal";--> statement-breakpoint -ALTER TABLE "agents" DROP COLUMN "constraints";--> statement-breakpoint -ALTER TABLE "agents" DROP COLUMN "memory_settings";--> statement-breakpoint -ALTER TABLE "agents" DROP COLUMN "tools"; \ No newline at end of file diff --git a/packages/db/src/migrations/0058_wealthy_starbolt.sql b/packages/db/src/migrations/0058_wealthy_starbolt.sql new file mode 100644 index 0000000..360dcf8 --- /dev/null +++ b/packages/db/src/migrations/0058_wealthy_starbolt.sql @@ -0,0 +1,6 @@ +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "liveness_state" text;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "liveness_reason" text;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "continuation_attempt" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "last_useful_action_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "next_action" text;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "heartbeat_runs_company_liveness_idx" ON "heartbeat_runs" USING btree ("company_id","liveness_state","created_at"); diff --git a/packages/db/src/migrations/0059_plugin_database_namespaces.sql b/packages/db/src/migrations/0059_plugin_database_namespaces.sql new file mode 100644 index 0000000..031713b --- /dev/null +++ b/packages/db/src/migrations/0059_plugin_database_namespaces.sql @@ -0,0 +1,41 @@ +CREATE TABLE IF NOT EXISTS "plugin_database_namespaces" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "plugin_key" text NOT NULL, + "namespace_name" text NOT NULL, + "namespace_mode" text DEFAULT 'schema' NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "plugin_migrations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "plugin_key" text NOT NULL, + "namespace_name" text NOT NULL, + "migration_key" text NOT NULL, + "checksum" text NOT NULL, + "plugin_version" text NOT NULL, + "status" text NOT NULL, + "started_at" timestamp with time zone DEFAULT now() NOT NULL, + "applied_at" timestamp with time zone, + "error_message" text +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plugin_database_namespaces_plugin_id_plugins_id_fk') THEN + ALTER TABLE "plugin_database_namespaces" ADD CONSTRAINT "plugin_database_namespaces_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plugin_migrations_plugin_id_plugins_id_fk') THEN + ALTER TABLE "plugin_migrations" ADD CONSTRAINT "plugin_migrations_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "plugin_database_namespaces_plugin_idx" ON "plugin_database_namespaces" USING btree ("plugin_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "plugin_database_namespaces_namespace_idx" ON "plugin_database_namespaces" USING btree ("namespace_name");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "plugin_database_namespaces_status_idx" ON "plugin_database_namespaces" USING btree ("status");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "plugin_migrations_plugin_key_idx" ON "plugin_migrations" USING btree ("plugin_id","migration_key");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "plugin_migrations_plugin_idx" ON "plugin_migrations" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "plugin_migrations_status_idx" ON "plugin_migrations" USING btree ("status"); diff --git a/packages/db/src/migrations/0059_sour_micromax.sql b/packages/db/src/migrations/0059_sour_micromax.sql deleted file mode 100644 index 8b8643b..0000000 --- a/packages/db/src/migrations/0059_sour_micromax.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "agent_runtime_state" ADD COLUMN "consecutive_failures" integer DEFAULT 0 NOT NULL;--> statement-breakpoint -ALTER TABLE "budget_policies" ADD COLUMN "circuit_breaker_json" jsonb; \ No newline at end of file diff --git a/packages/db/src/migrations/0060_orange_annihilus.sql b/packages/db/src/migrations/0060_orange_annihilus.sql new file mode 100644 index 0000000..2536a3a --- /dev/null +++ b/packages/db/src/migrations/0060_orange_annihilus.sql @@ -0,0 +1,50 @@ +CREATE TABLE IF NOT EXISTS "issue_reference_mentions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "source_issue_id" uuid NOT NULL, + "target_issue_id" uuid NOT NULL, + "source_kind" text NOT NULL, + "source_record_id" uuid, + "document_key" text, + "matched_text" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_reference_mentions_company_id_companies_id_fk') THEN + ALTER TABLE "issue_reference_mentions" ADD CONSTRAINT "issue_reference_mentions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_reference_mentions_source_issue_id_issues_id_fk') THEN + ALTER TABLE "issue_reference_mentions" ADD CONSTRAINT "issue_reference_mentions_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_reference_mentions_target_issue_id_issues_id_fk') THEN + ALTER TABLE "issue_reference_mentions" ADD CONSTRAINT "issue_reference_mentions_target_issue_id_issues_id_fk" FOREIGN KEY ("target_issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_reference_mentions_company_source_issue_idx" ON "issue_reference_mentions" USING btree ("company_id","source_issue_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_reference_mentions_company_target_issue_idx" ON "issue_reference_mentions" USING btree ("company_id","target_issue_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_reference_mentions_company_issue_pair_idx" ON "issue_reference_mentions" USING btree ("company_id","source_issue_id","target_issue_id");--> statement-breakpoint +DELETE FROM "issue_reference_mentions" +WHERE "id" IN ( + SELECT "id" + FROM ( + SELECT + "id", + row_number() OVER ( + PARTITION BY "company_id", "source_issue_id", "target_issue_id", "source_kind", "source_record_id" + ORDER BY "created_at", "id" + ) AS "row_number" + FROM "issue_reference_mentions" + ) AS "duplicates" + WHERE "duplicates"."row_number" > 1 +);--> statement-breakpoint +DROP INDEX IF EXISTS "issue_reference_mentions_company_source_mention_uq";--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "issue_reference_mentions_company_source_mention_record_uq" ON "issue_reference_mentions" USING btree ("company_id","source_issue_id","target_issue_id","source_kind","source_record_id") WHERE "source_record_id" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "issue_reference_mentions_company_source_mention_null_record_uq" ON "issue_reference_mentions" USING btree ("company_id","source_issue_id","target_issue_id","source_kind") WHERE "source_record_id" IS NULL; diff --git a/packages/db/src/migrations/0060_wild_psylocke.sql b/packages/db/src/migrations/0060_wild_psylocke.sql deleted file mode 100644 index 8ac3ef4..0000000 --- a/packages/db/src/migrations/0060_wild_psylocke.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE "issue_artifacts" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "company_id" uuid NOT NULL, - "issue_id" uuid NOT NULL, - "artifact_id" text NOT NULL, - "version" integer DEFAULT 1 NOT NULL, - "title" text NOT NULL, - "mime_type" text NOT NULL, - "size_bytes" integer DEFAULT 0 NOT NULL, - "sha256" text NOT NULL, - "metadata_json" jsonb, - "created_by_agent_id" uuid, - "created_by_user_id" text, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "issue_artifacts" ADD CONSTRAINT "issue_artifacts_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "issue_artifacts" ADD CONSTRAINT "issue_artifacts_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "issue_artifacts" ADD CONSTRAINT "issue_artifacts_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "issue_artifacts_company_issue_idx" ON "issue_artifacts" USING btree ("company_id","issue_id");--> statement-breakpoint -CREATE UNIQUE INDEX "issue_artifacts_company_artifact_version_unique_idx" ON "issue_artifacts" USING btree ("company_id","issue_id","artifact_id","version"); \ No newline at end of file diff --git a/packages/db/src/migrations/0061_lively_thor_girl.sql b/packages/db/src/migrations/0061_lively_thor_girl.sql new file mode 100644 index 0000000..ffc02d6 --- /dev/null +++ b/packages/db/src/migrations/0061_lively_thor_girl.sql @@ -0,0 +1,3 @@ +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "scheduled_retry_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "scheduled_retry_attempt" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "scheduled_retry_reason" text; diff --git a/packages/db/src/migrations/0062_routine_run_dispatch_fingerprint.sql b/packages/db/src/migrations/0062_routine_run_dispatch_fingerprint.sql new file mode 100644 index 0000000..42e0ecf --- /dev/null +++ b/packages/db/src/migrations/0062_routine_run_dispatch_fingerprint.sql @@ -0,0 +1,9 @@ +ALTER TABLE "routine_runs" ADD COLUMN IF NOT EXISTS "dispatch_fingerprint" text;--> statement-breakpoint +ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "origin_fingerprint" text DEFAULT 'default' NOT NULL;--> statement-breakpoint +DROP INDEX IF EXISTS "issues_open_routine_execution_uq";--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "issues_open_routine_execution_uq" ON "issues" USING btree ("company_id","origin_kind","origin_id","origin_fingerprint") WHERE "issues"."origin_kind" = 'routine_execution' + and "issues"."origin_id" is not null + and "issues"."hidden_at" is null + and "issues"."execution_run_id" is not null + and "issues"."status" in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked');--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "routine_runs_dispatch_fingerprint_idx" ON "routine_runs" USING btree ("routine_id","dispatch_fingerprint"); diff --git a/packages/db/src/migrations/0063_issue_thread_interactions.sql b/packages/db/src/migrations/0063_issue_thread_interactions.sql new file mode 100644 index 0000000..121b097 --- /dev/null +++ b/packages/db/src/migrations/0063_issue_thread_interactions.sql @@ -0,0 +1,65 @@ +CREATE TABLE IF NOT EXISTS "issue_thread_interactions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "kind" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "continuation_policy" text DEFAULT 'wake_assignee' NOT NULL, + "source_comment_id" uuid, + "source_run_id" uuid, + "title" text, + "summary" text, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "resolved_by_agent_id" uuid, + "resolved_by_user_id" text, + "payload" jsonb NOT NULL, + "result" jsonb, + "resolved_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_company_id_companies_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_issue_id_issues_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_source_comment_id_issue_comments_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_source_comment_id_issue_comments_id_fk" FOREIGN KEY ("source_comment_id") REFERENCES "public"."issue_comments"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_source_run_id_heartbeat_runs_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("source_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_created_by_agent_id_agents_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_thread_interactions_resolved_by_agent_id_agents_id_fk') THEN + ALTER TABLE "issue_thread_interactions" ADD CONSTRAINT "issue_thread_interactions_resolved_by_agent_id_agents_id_fk" FOREIGN KEY ("resolved_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_thread_interactions_issue_idx" ON "issue_thread_interactions" USING btree ("issue_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_thread_interactions_company_issue_created_at_idx" ON "issue_thread_interactions" USING btree ("company_id","issue_id","created_at"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_thread_interactions_company_issue_status_idx" ON "issue_thread_interactions" USING btree ("company_id","issue_id","status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_thread_interactions_source_comment_idx" ON "issue_thread_interactions" USING btree ("source_comment_id"); diff --git a/packages/db/src/migrations/0064_issue_thread_interaction_idempotency.sql b/packages/db/src/migrations/0064_issue_thread_interaction_idempotency.sql new file mode 100644 index 0000000..27461e6 --- /dev/null +++ b/packages/db/src/migrations/0064_issue_thread_interaction_idempotency.sql @@ -0,0 +1,4 @@ +ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "idempotency_key" text;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "issue_thread_interactions_company_issue_idempotency_uq" + ON "issue_thread_interactions" USING btree ("company_id","issue_id","idempotency_key") + WHERE "issue_thread_interactions"."idempotency_key" IS NOT NULL; diff --git a/packages/db/src/migrations/meta/0057_snapshot.json b/packages/db/src/migrations/meta/0057_snapshot.json index 9f02f81..ed1207d 100644 --- a/packages/db/src/migrations/meta/0057_snapshot.json +++ b/packages/db/src/migrations/meta/0057_snapshot.json @@ -1,5 +1,5 @@ { - "id": "ed72f2cb-17f3-4fe2-b6fb-3c55b740bbce", + "id": "c13b1dd5-1860-4d0b-aeb2-5bb197766983", "prevId": "5b9211ec-73c0-4825-bdf7-08ba60b6915c", "version": "7", "dialect": "postgresql", @@ -1205,33 +1205,6 @@ "primaryKey": false, "notNull": false }, - "goal": { - "name": "goal", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "constraints": { - "name": "constraints", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "memory_settings": { - "name": "memory_settings", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "tools": { - "name": "tools", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, "permissions": { "name": "permissions", "type": "jsonb", @@ -9795,6 +9768,50 @@ "concurrently": false, "method": "btree", "with": {} + }, + "join_requests_pending_human_user_uq": { + "name": "join_requests_pending_human_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requesting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"requesting_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_pending_human_email_uq": { + "name": "join_requests_pending_human_email_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"request_email_snapshot\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"request_email_snapshot\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { diff --git a/packages/db/src/migrations/meta/0058_snapshot.json b/packages/db/src/migrations/meta/0058_snapshot.json index e057764..9df7df3 100644 --- a/packages/db/src/migrations/meta/0058_snapshot.json +++ b/packages/db/src/migrations/meta/0058_snapshot.json @@ -1,6 +1,6 @@ { - "id": "aea5333d-7ff4-43de-8d57-b06ece3ecda1", - "prevId": "ed72f2cb-17f3-4fe2-b6fb-3c55b740bbce", + "id": "58b07646-6b04-4945-b6d2-e3409d238235", + "prevId": "c13b1dd5-1860-4d0b-aeb2-5bb197766983", "version": "7", "dialect": "postgresql", "tables": { @@ -902,12 +902,6 @@ "primaryKey": false, "notNull": false }, - "wake_reason": { - "name": "wake_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, "reason": { "name": "reason", "type": "text", @@ -6308,12 +6302,6 @@ "primaryKey": false, "notNull": false }, - "wake_reason": { - "name": "wake_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, "status": { "name": "status", "type": "text", @@ -6486,6 +6474,37 @@ "primaryKey": false, "notNull": false }, + "liveness_state": { + "name": "liveness_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "liveness_reason": { + "name": "liveness_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation_attempt": { + "name": "continuation_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_useful_action_at": { + "name": "last_useful_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, "context_snapshot": { "name": "context_snapshot", "type": "jsonb", @@ -6534,6 +6553,33 @@ "concurrently": false, "method": "btree", "with": {} + }, + "heartbeat_runs_company_liveness_idx": { + "name": "heartbeat_runs_company_liveness_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "liveness_state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -9780,6 +9826,50 @@ "concurrently": false, "method": "btree", "with": {} + }, + "join_requests_pending_human_user_uq": { + "name": "join_requests_pending_human_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requesting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"requesting_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_pending_human_email_uq": { + "name": "join_requests_pending_human_email_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"request_email_snapshot\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"request_email_snapshot\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { diff --git a/packages/db/src/migrations/meta/0060_snapshot.json b/packages/db/src/migrations/meta/0060_snapshot.json index 6721b29..e50a16a 100644 --- a/packages/db/src/migrations/meta/0060_snapshot.json +++ b/packages/db/src/migrations/meta/0060_snapshot.json @@ -1,6 +1,6 @@ { - "id": "4743b2d7-0554-4207-b348-deceddc7946d", - "prevId": "5ffee2fa-7247-467f-ab76-12e6b04be030", + "id": "7f3c5f3d-b496-4aa6-a57e-5b35b7936318", + "prevId": "58b07646-6b04-4945-b6d2-e3409d238235", "version": "7", "dialect": "postgresql", "tables": { @@ -558,13 +558,6 @@ "notNull": true, "default": 0 }, - "consecutive_failures": { - "name": "consecutive_failures", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, "last_error": { "name": "last_error", "type": "text", @@ -909,12 +902,6 @@ "primaryKey": false, "notNull": false }, - "wake_reason": { - "name": "wake_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, "reason": { "name": "reason", "type": "text", @@ -2521,12 +2508,6 @@ "notNull": true, "default": true }, - "circuit_breaker_json": { - "name": "circuit_breaker_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, "created_by_user_id": { "name": "created_by_user_id", "type": "text", @@ -6321,12 +6302,6 @@ "primaryKey": false, "notNull": false }, - "wake_reason": { - "name": "wake_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, "status": { "name": "status", "type": "text", @@ -6499,6 +6474,37 @@ "primaryKey": false, "notNull": false }, + "liveness_state": { + "name": "liveness_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "liveness_reason": { + "name": "liveness_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation_attempt": { + "name": "continuation_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_useful_action_at": { + "name": "last_useful_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, "context_snapshot": { "name": "context_snapshot", "type": "jsonb", @@ -6547,6 +6553,33 @@ "concurrently": false, "method": "btree", "with": {} + }, + "heartbeat_runs_company_liveness_idx": { + "name": "heartbeat_runs_company_liveness_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "liveness_state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -7217,203 +7250,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.issue_artifacts": { - "name": "issue_artifacts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "company_id": { - "name": "company_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "issue_id": { - "name": "issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "artifact_id": { - "name": "artifact_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 1 - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "mime_type": { - "name": "mime_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "size_bytes": { - "name": "size_bytes", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "sha256": { - "name": "sha256", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "metadata_json": { - "name": "metadata_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_by_agent_id": { - "name": "created_by_agent_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "created_by_user_id": { - "name": "created_by_user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "issue_artifacts_company_issue_idx": { - "name": "issue_artifacts_company_issue_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "issue_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "issue_artifacts_company_artifact_version_unique_idx": { - "name": "issue_artifacts_company_artifact_version_unique_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "issue_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "artifact_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "version", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "issue_artifacts_company_id_companies_id_fk": { - "name": "issue_artifacts_company_id_companies_id_fk", - "tableFrom": "issue_artifacts", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "issue_artifacts_issue_id_issues_id_fk": { - "name": "issue_artifacts_issue_id_issues_id_fk", - "tableFrom": "issue_artifacts", - "tableTo": "issues", - "columnsFrom": [ - "issue_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "issue_artifacts_created_by_agent_id_agents_id_fk": { - "name": "issue_artifacts_created_by_agent_id_agents_id_fk", - "tableFrom": "issue_artifacts", - "tableTo": "agents", - "columnsFrom": [ - "created_by_agent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.issue_attachments": { "name": "issue_attachments", "schema": "", @@ -8609,8 +8445,8 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.issue_relations": { - "name": "issue_relations", + "public.issue_reference_mentions": { + "name": "issue_reference_mentions", "schema": "", "columns": { "id": { @@ -8626,32 +8462,38 @@ "primaryKey": false, "notNull": true }, - "issue_id": { - "name": "issue_id", + "source_issue_id": { + "name": "source_issue_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "related_issue_id": { - "name": "related_issue_id", + "target_issue_id": { + "name": "target_issue_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "type": { - "name": "type", + "source_kind": { + "name": "source_kind", "type": "text", "primaryKey": false, "notNull": true }, - "created_by_agent_id": { - "name": "created_by_agent_id", + "source_record_id": { + "name": "source_record_id", "type": "uuid", "primaryKey": false, "notNull": false }, - "created_by_user_id": { - "name": "created_by_user_id", + "document_key": { + "name": "document_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "matched_text": { + "name": "matched_text", "type": "text", "primaryKey": false, "notNull": false @@ -8672,7 +8514,261 @@ } }, "indexes": { - "issue_relations_company_issue_idx": { + "issue_reference_mentions_company_source_issue_idx": { + "name": "issue_reference_mentions_company_source_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_reference_mentions_company_target_issue_idx": { + "name": "issue_reference_mentions_company_target_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_reference_mentions_company_issue_pair_idx": { + "name": "issue_reference_mentions_company_issue_pair_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_reference_mentions_company_source_mention_record_uq": { + "name": "issue_reference_mentions_company_source_mention_record_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {}, + "where": "\"issue_reference_mentions\".\"source_record_id\" is not null" + }, + "issue_reference_mentions_company_source_mention_null_record_uq": { + "name": "issue_reference_mentions_company_source_mention_null_record_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {}, + "where": "\"issue_reference_mentions\".\"source_record_id\" is null" + } + }, + "foreignKeys": { + "issue_reference_mentions_company_id_companies_id_fk": { + "name": "issue_reference_mentions_company_id_companies_id_fk", + "tableFrom": "issue_reference_mentions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_reference_mentions_source_issue_id_issues_id_fk": { + "name": "issue_reference_mentions_source_issue_id_issues_id_fk", + "tableFrom": "issue_reference_mentions", + "tableTo": "issues", + "columnsFrom": [ + "source_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_reference_mentions_target_issue_id_issues_id_fk": { + "name": "issue_reference_mentions_target_issue_id_issues_id_fk", + "tableFrom": "issue_reference_mentions", + "tableTo": "issues", + "columnsFrom": [ + "target_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_relations": { + "name": "issue_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "related_issue_id": { + "name": "related_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_relations_company_issue_idx": { "name": "issue_relations_company_issue_idx", "columns": [ { @@ -9990,6 +10086,50 @@ "concurrently": false, "method": "btree", "with": {} + }, + "join_requests_pending_human_user_uq": { + "name": "join_requests_pending_human_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requesting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"requesting_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_pending_human_email_uq": { + "name": "join_requests_pending_human_email_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"request_email_snapshot\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"request_email_snapshot\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -10370,6 +10510,132 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.plugin_database_namespaces": { + "name": "plugin_database_namespaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace_name": { + "name": "namespace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace_mode": { + "name": "namespace_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schema'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_database_namespaces_plugin_idx": { + "name": "plugin_database_namespaces_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_database_namespaces_namespace_idx": { + "name": "plugin_database_namespaces_namespace_idx", + "columns": [ + { + "expression": "namespace_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_database_namespaces_status_idx": { + "name": "plugin_database_namespaces_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_database_namespaces_plugin_id_plugins_id_fk": { + "name": "plugin_database_namespaces_plugin_id_plugins_id_fk", + "tableFrom": "plugin_database_namespaces", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.plugin_entities": { "name": "plugin_entities", "schema": "", @@ -10942,6 +11208,153 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.plugin_migrations": { + "name": "plugin_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace_name": { + "name": "namespace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_key": { + "name": "migration_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_version": { + "name": "plugin_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "plugin_migrations_plugin_key_idx": { + "name": "plugin_migrations_plugin_key_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_migrations_plugin_idx": { + "name": "plugin_migrations_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_migrations_status_idx": { + "name": "plugin_migrations_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.plugin_state": { "name": "plugin_state", "schema": "", @@ -13607,4 +14020,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/packages/db/src/migrations/meta/0059_snapshot.json b/packages/db/src/migrations/meta/0061_snapshot.json similarity index 95% rename from packages/db/src/migrations/meta/0059_snapshot.json rename to packages/db/src/migrations/meta/0061_snapshot.json index 0a32d55..5aaad5b 100644 --- a/packages/db/src/migrations/meta/0059_snapshot.json +++ b/packages/db/src/migrations/meta/0061_snapshot.json @@ -1,6 +1,6 @@ { - "id": "5ffee2fa-7247-467f-ab76-12e6b04be030", - "prevId": "aea5333d-7ff4-43de-8d57-b06ece3ecda1", + "id": "7b3f3a82-9e71-47a6-a8f2-5b886af8ecce", + "prevId": "7f3c5f3d-b496-4aa6-a57e-5b35b7936318", "version": "7", "dialect": "postgresql", "tables": { @@ -558,13 +558,6 @@ "notNull": true, "default": 0 }, - "consecutive_failures": { - "name": "consecutive_failures", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, "last_error": { "name": "last_error", "type": "text", @@ -909,12 +902,6 @@ "primaryKey": false, "notNull": false }, - "wake_reason": { - "name": "wake_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, "reason": { "name": "reason", "type": "text", @@ -2521,12 +2508,6 @@ "notNull": true, "default": true }, - "circuit_breaker_json": { - "name": "circuit_breaker_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, "created_by_user_id": { "name": "created_by_user_id", "type": "text", @@ -6321,12 +6302,6 @@ "primaryKey": false, "notNull": false }, - "wake_reason": { - "name": "wake_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, "status": { "name": "status", "type": "text", @@ -6480,6 +6455,25 @@ "notNull": true, "default": 0 }, + "scheduled_retry_at": { + "name": "scheduled_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scheduled_retry_attempt": { + "name": "scheduled_retry_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scheduled_retry_reason": { + "name": "scheduled_retry_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, "issue_comment_status": { "name": "issue_comment_status", "type": "text", @@ -6499,6 +6493,37 @@ "primaryKey": false, "notNull": false }, + "liveness_state": { + "name": "liveness_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "liveness_reason": { + "name": "liveness_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation_attempt": { + "name": "continuation_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_useful_action_at": { + "name": "last_useful_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, "context_snapshot": { "name": "context_snapshot", "type": "jsonb", @@ -6547,6 +6572,33 @@ "concurrently": false, "method": "btree", "with": {} + }, + "heartbeat_runs_company_liveness_idx": { + "name": "heartbeat_runs_company_liveness_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "liveness_state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -8412,8 +8464,8 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.issue_relations": { - "name": "issue_relations", + "public.issue_reference_mentions": { + "name": "issue_reference_mentions", "schema": "", "columns": { "id": { @@ -8429,32 +8481,38 @@ "primaryKey": false, "notNull": true }, - "issue_id": { - "name": "issue_id", + "source_issue_id": { + "name": "source_issue_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "related_issue_id": { - "name": "related_issue_id", + "target_issue_id": { + "name": "target_issue_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "type": { - "name": "type", + "source_kind": { + "name": "source_kind", "type": "text", "primaryKey": false, "notNull": true }, - "created_by_agent_id": { - "name": "created_by_agent_id", + "source_record_id": { + "name": "source_record_id", "type": "uuid", "primaryKey": false, "notNull": false }, - "created_by_user_id": { - "name": "created_by_user_id", + "document_key": { + "name": "document_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "matched_text": { + "name": "matched_text", "type": "text", "primaryKey": false, "notNull": false @@ -8475,8 +8533,8 @@ } }, "indexes": { - "issue_relations_company_issue_idx": { - "name": "issue_relations_company_issue_idx", + "issue_reference_mentions_company_source_issue_idx": { + "name": "issue_reference_mentions_company_source_issue_idx", "columns": [ { "expression": "company_id", @@ -8485,7 +8543,7 @@ "nulls": "last" }, { - "expression": "issue_id", + "expression": "source_issue_id", "isExpression": false, "asc": true, "nulls": "last" @@ -8496,8 +8554,8 @@ "method": "btree", "with": {} }, - "issue_relations_company_related_issue_idx": { - "name": "issue_relations_company_related_issue_idx", + "issue_reference_mentions_company_target_issue_idx": { + "name": "issue_reference_mentions_company_target_issue_idx", "columns": [ { "expression": "company_id", @@ -8506,7 +8564,7 @@ "nulls": "last" }, { - "expression": "related_issue_id", + "expression": "target_issue_id", "isExpression": false, "asc": true, "nulls": "last" @@ -8517,8 +8575,8 @@ "method": "btree", "with": {} }, - "issue_relations_company_type_idx": { - "name": "issue_relations_company_type_idx", + "issue_reference_mentions_company_issue_pair_idx": { + "name": "issue_reference_mentions_company_issue_pair_idx", "columns": [ { "expression": "company_id", @@ -8527,7 +8585,13 @@ "nulls": "last" }, { - "expression": "type", + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_issue_id", "isExpression": false, "asc": true, "nulls": "last" @@ -8538,8 +8602,8 @@ "method": "btree", "with": {} }, - "issue_relations_company_edge_uq": { - "name": "issue_relations_company_edge_uq", + "issue_reference_mentions_company_source_mention_record_uq": { + "name": "issue_reference_mentions_company_source_mention_record_uq", "columns": [ { "expression": "company_id", @@ -8548,34 +8612,75 @@ "nulls": "last" }, { - "expression": "issue_id", + "expression": "source_issue_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "related_issue_id", + "expression": "target_issue_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "type", + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issue_reference_mentions\".\"source_record_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_reference_mentions_company_source_mention_null_record_uq": { + "name": "issue_reference_mentions_company_source_mention_null_record_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": true, + "where": "\"issue_reference_mentions\".\"source_record_id\" is null", "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "issue_relations_company_id_companies_id_fk": { - "name": "issue_relations_company_id_companies_id_fk", - "tableFrom": "issue_relations", + "issue_reference_mentions_company_id_companies_id_fk": { + "name": "issue_reference_mentions_company_id_companies_id_fk", + "tableFrom": "issue_reference_mentions", "tableTo": "companies", "columnsFrom": [ "company_id" @@ -8586,12 +8691,12 @@ "onDelete": "no action", "onUpdate": "no action" }, - "issue_relations_issue_id_issues_id_fk": { - "name": "issue_relations_issue_id_issues_id_fk", - "tableFrom": "issue_relations", + "issue_reference_mentions_source_issue_id_issues_id_fk": { + "name": "issue_reference_mentions_source_issue_id_issues_id_fk", + "tableFrom": "issue_reference_mentions", "tableTo": "issues", "columnsFrom": [ - "issue_id" + "source_issue_id" ], "columnsTo": [ "id" @@ -8599,31 +8704,18 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "issue_relations_related_issue_id_issues_id_fk": { - "name": "issue_relations_related_issue_id_issues_id_fk", - "tableFrom": "issue_relations", + "issue_reference_mentions_target_issue_id_issues_id_fk": { + "name": "issue_reference_mentions_target_issue_id_issues_id_fk", + "tableFrom": "issue_reference_mentions", "tableTo": "issues", "columnsFrom": [ - "related_issue_id" + "target_issue_id" ], "columnsTo": [ "id" ], "onDelete": "cascade", "onUpdate": "no action" - }, - "issue_relations_created_by_agent_id_agents_id_fk": { - "name": "issue_relations_created_by_agent_id_agents_id_fk", - "tableFrom": "issue_relations", - "tableTo": "agents", - "columnsFrom": [ - "created_by_agent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -8632,8 +8724,8 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.issue_work_products": { - "name": "issue_work_products", + "public.issue_relations": { + "name": "issue_relations", "schema": "", "columns": { "id": { @@ -8649,29 +8741,17 @@ "primaryKey": false, "notNull": true }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, "issue_id": { "name": "issue_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "execution_workspace_id": { - "name": "execution_workspace_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "runtime_service_id": { - "name": "runtime_service_id", + "related_issue_id": { + "name": "related_issue_id", "type": "uuid", "primaryKey": false, - "notNull": false + "notNull": true }, "type": { "name": "type", @@ -8679,66 +8759,298 @@ "primaryKey": false, "notNull": true }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "external_id": { - "name": "external_id", - "type": "text", + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", "primaryKey": false, "notNull": false }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "url": { - "name": "url", + "created_by_user_id": { + "name": "created_by_user_id", "type": "text", "primaryKey": false, "notNull": false }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "review_state": { - "name": "review_state", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'none'" - }, - "is_primary": { - "name": "is_primary", - "type": "boolean", + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "default": false + "default": "now()" }, - "health_status": { - "name": "health_status", - "type": "text", + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "default": "'unknown'" - }, - "summary": { - "name": "summary", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "metadata": { - "name": "metadata", - "type": "jsonb", + "default": "now()" + } + }, + "indexes": { + "issue_relations_company_issue_idx": { + "name": "issue_relations_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_relations_company_related_issue_idx": { + "name": "issue_relations_company_related_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "related_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_relations_company_type_idx": { + "name": "issue_relations_company_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_relations_company_edge_uq": { + "name": "issue_relations_company_edge_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "related_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_relations_company_id_companies_id_fk": { + "name": "issue_relations_company_id_companies_id_fk", + "tableFrom": "issue_relations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_relations_issue_id_issues_id_fk": { + "name": "issue_relations_issue_id_issues_id_fk", + "tableFrom": "issue_relations", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_relations_related_issue_id_issues_id_fk": { + "name": "issue_relations_related_issue_id_issues_id_fk", + "tableFrom": "issue_relations", + "tableTo": "issues", + "columnsFrom": [ + "related_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_relations_created_by_agent_id_agents_id_fk": { + "name": "issue_relations_created_by_agent_id_agents_id_fk", + "tableFrom": "issue_relations", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_work_products": { + "name": "issue_work_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "runtime_service_id": { + "name": "runtime_service_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", "primaryKey": false, "notNull": false }, @@ -9750,19 +10062,52 @@ "name": "join_requests_invite_unique_idx", "columns": [ { - "expression": "invite_id", + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_company_status_type_created_idx": { + "name": "join_requests_company_status_type_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": true, + "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, - "join_requests_company_status_type_created_idx": { - "name": "join_requests_company_status_type_created_idx", + "join_requests_pending_human_user_uq": { + "name": "join_requests_pending_human_user_uq", "columns": [ { "expression": "company_id", @@ -9771,25 +10116,36 @@ "nulls": "last" }, { - "expression": "status", + "expression": "requesting_user_id", "isExpression": false, "asc": true, "nulls": "last" - }, + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"requesting_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_pending_human_email_uq": { + "name": "join_requests_pending_human_email_uq", + "columns": [ { - "expression": "request_type", + "expression": "company_id", "isExpression": false, "asc": true, "nulls": "last" }, { - "expression": "created_at", - "isExpression": false, + "expression": "lower(\"request_email_snapshot\")", "asc": true, + "isExpression": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"request_email_snapshot\" IS NOT NULL", "concurrently": false, "method": "btree", "with": {} @@ -10173,6 +10529,132 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.plugin_database_namespaces": { + "name": "plugin_database_namespaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace_name": { + "name": "namespace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace_mode": { + "name": "namespace_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schema'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_database_namespaces_plugin_idx": { + "name": "plugin_database_namespaces_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_database_namespaces_namespace_idx": { + "name": "plugin_database_namespaces_namespace_idx", + "columns": [ + { + "expression": "namespace_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_database_namespaces_status_idx": { + "name": "plugin_database_namespaces_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_database_namespaces_plugin_id_plugins_id_fk": { + "name": "plugin_database_namespaces_plugin_id_plugins_id_fk", + "tableFrom": "plugin_database_namespaces", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.plugin_entities": { "name": "plugin_entities", "schema": "", @@ -10745,6 +11227,153 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.plugin_migrations": { + "name": "plugin_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace_name": { + "name": "namespace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_key": { + "name": "migration_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_version": { + "name": "plugin_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "plugin_migrations_plugin_key_idx": { + "name": "plugin_migrations_plugin_key_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_migrations_plugin_idx": { + "name": "plugin_migrations_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_migrations_status_idx": { + "name": "plugin_migrations_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.plugin_state": { "name": "plugin_state", "schema": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 7c4f2e9..82cdd65 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -404,30 +404,58 @@ { "idx": 57, "version": "7", - "when": 1776509584240, - "tag": "0057_cheerful_betty_ross", + "when": 1776309613598, + "tag": "0057_tidy_join_requests", "breakpoints": true }, { "idx": 58, "version": "7", - "when": 1776552222724, - "tag": "0058_classy_edwin_jarvis", + "when": 1776542245004, + "tag": "0058_wealthy_starbolt", "breakpoints": true }, { "idx": 59, "version": "7", - "when": 1776552553130, - "tag": "0059_sour_micromax", + "when": 1776542246000, + "tag": "0059_plugin_database_namespaces", "breakpoints": true }, { "idx": 60, "version": "7", - "when": 1776552840177, - "tag": "0060_wild_psylocke", + "when": 1776717606743, + "tag": "0060_orange_annihilus", + "breakpoints": true + }, + { + "idx": 61, + "version": "7", + "when": 1776785165389, + "tag": "0061_lively_thor_girl", + "breakpoints": true + }, + { + "idx": 62, + "version": "7", + "when": 1776780000000, + "tag": "0062_routine_run_dispatch_fingerprint", + "breakpoints": true + }, + { + "idx": 63, + "version": "7", + "when": 1776780001000, + "tag": "0063_issue_thread_interactions", + "breakpoints": true + }, + { + "idx": 64, + "version": "7", + "when": 1776780002000, + "tag": "0064_issue_thread_interaction_idempotency", "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/runtime-config.ts b/packages/db/src/runtime-config.ts index 3527d74..1bddabd 100644 --- a/packages/db/src/runtime-config.ts +++ b/packages/db/src/runtime-config.ts @@ -20,20 +20,20 @@ type PartialConfig = { export type ResolvedDatabaseTarget = | { - mode: "postgres"; - connectionString: string; - source: "DATABASE_URL" | "taskcore-env" | "config.database.connectionString"; - configPath: string; - envPath: string; - } + mode: "postgres"; + connectionString: string; + source: "DATABASE_URL" | "taskcore-env" | "config.database.connectionString"; + configPath: string; + envPath: string; + } | { - mode: "embedded-postgres"; - dataDir: string; - port: number; - source: `embedded-postgres@${number}`; - configPath: string; - envPath: string; - }; + mode: "embedded-postgres"; + dataDir: string; + port: number; + source: `embedded-postgres@${number}`; + configPath: string; + envPath: string; + }; function expandHomePrefix(value: string): string { if (value === "~") return os.homedir(); @@ -189,25 +189,25 @@ function readConfig(configPath: string): PartialConfig | null { const database = typeof migrated.database === "object" && - migrated.database !== null && - !Array.isArray(migrated.database) + migrated.database !== null && + !Array.isArray(migrated.database) ? migrated.database : undefined; return { database: database ? { - mode: database.mode === "postgres" ? "postgres" : "embedded-postgres", - connectionString: - typeof database.connectionString === "string" ? database.connectionString : undefined, - embeddedPostgresDataDir: - typeof database.embeddedPostgresDataDir === "string" - ? database.embeddedPostgresDataDir - : undefined, - embeddedPostgresPort: asPositiveInt(database.embeddedPostgresPort) ?? undefined, - pgliteDataDir: typeof database.pgliteDataDir === "string" ? database.pgliteDataDir : undefined, - pglitePort: asPositiveInt(database.pglitePort) ?? undefined, - } + mode: database.mode === "postgres" ? "postgres" : "embedded-postgres", + connectionString: + typeof database.connectionString === "string" ? database.connectionString : undefined, + embeddedPostgresDataDir: + typeof database.embeddedPostgresDataDir === "string" + ? database.embeddedPostgresDataDir + : undefined, + embeddedPostgresPort: asPositiveInt(database.embeddedPostgresPort) ?? undefined, + pgliteDataDir: typeof database.pgliteDataDir === "string" ? database.pgliteDataDir : undefined, + pglitePort: asPositiveInt(database.pglitePort) ?? undefined, + } : undefined, }; } diff --git a/packages/db/src/schema/agent_runtime_state.ts b/packages/db/src/schema/agent_runtime_state.ts index a4315bf..b0095bb 100644 --- a/packages/db/src/schema/agent_runtime_state.ts +++ b/packages/db/src/schema/agent_runtime_state.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, text, timestamp, jsonb, bigint, index, integer } from "drizzle-orm/pg-core"; +import { pgTable, uuid, text, timestamp, jsonb, bigint, index } from "drizzle-orm/pg-core"; import { agents } from "./agents.js"; import { companies } from "./companies.js"; @@ -16,7 +16,6 @@ export const agentRuntimeState = pgTable( totalOutputTokens: bigint("total_output_tokens", { mode: "number" }).notNull().default(0), totalCachedInputTokens: bigint("total_cached_input_tokens", { mode: "number" }).notNull().default(0), totalCostCents: bigint("total_cost_cents", { mode: "number" }).notNull().default(0), - consecutiveFailures: integer("consecutive_failures").notNull().default(0), lastError: text("last_error"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), diff --git a/packages/db/src/schema/agent_wakeup_requests.ts b/packages/db/src/schema/agent_wakeup_requests.ts index 010c461..0d01944 100644 --- a/packages/db/src/schema/agent_wakeup_requests.ts +++ b/packages/db/src/schema/agent_wakeup_requests.ts @@ -10,7 +10,6 @@ export const agentWakeupRequests = pgTable( agentId: uuid("agent_id").notNull().references(() => agents.id), source: text("source").notNull(), triggerDetail: text("trigger_detail"), - wakeReason: text("wake_reason"), reason: text("reason"), payload: jsonb("payload").$type>(), status: text("status").notNull().default("queued"), diff --git a/packages/db/src/schema/budget_policies.ts b/packages/db/src/schema/budget_policies.ts index 6637e39..3713889 100644 --- a/packages/db/src/schema/budget_policies.ts +++ b/packages/db/src/schema/budget_policies.ts @@ -1,4 +1,4 @@ -import { boolean, index, integer, pgTable, text, timestamp, uuid, uniqueIndex, jsonb } from "drizzle-orm/pg-core"; +import { boolean, index, integer, pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; export const budgetPolicies = pgTable( @@ -15,11 +15,6 @@ export const budgetPolicies = pgTable( hardStopEnabled: boolean("hard_stop_enabled").notNull().default(true), notifyEnabled: boolean("notify_enabled").notNull().default(true), isActive: boolean("is_active").notNull().default(true), - circuitBreakerJson: jsonb("circuit_breaker_json").$type<{ - failureThreshold: number; - windowMs: number; - autoPause: boolean; - }>(), createdByUserId: text("created_by_user_id"), updatedByUserId: text("updated_by_user_id"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), diff --git a/packages/db/src/schema/heartbeat_runs.ts b/packages/db/src/schema/heartbeat_runs.ts index 671dae3..4010e2b 100644 --- a/packages/db/src/schema/heartbeat_runs.ts +++ b/packages/db/src/schema/heartbeat_runs.ts @@ -11,7 +11,6 @@ export const heartbeatRuns = pgTable( agentId: uuid("agent_id").notNull().references(() => agents.id), invocationSource: text("invocation_source").notNull().default("on_demand"), triggerDetail: text("trigger_detail"), - wakeReason: text("wake_reason"), status: text("status").notNull().default("queued"), startedAt: timestamp("started_at", { withTimezone: true }), finishedAt: timestamp("finished_at", { withTimezone: true }), @@ -39,9 +38,17 @@ export const heartbeatRuns = pgTable( onDelete: "set null", }), processLossRetryCount: integer("process_loss_retry_count").notNull().default(0), + scheduledRetryAt: timestamp("scheduled_retry_at", { withTimezone: true }), + scheduledRetryAttempt: integer("scheduled_retry_attempt").notNull().default(0), + scheduledRetryReason: text("scheduled_retry_reason"), issueCommentStatus: text("issue_comment_status").notNull().default("not_applicable"), issueCommentSatisfiedByCommentId: uuid("issue_comment_satisfied_by_comment_id"), issueCommentRetryQueuedAt: timestamp("issue_comment_retry_queued_at", { withTimezone: true }), + livenessState: text("liveness_state"), + livenessReason: text("liveness_reason"), + continuationAttempt: integer("continuation_attempt").notNull().default(0), + lastUsefulActionAt: timestamp("last_useful_action_at", { withTimezone: true }), + nextAction: text("next_action"), contextSnapshot: jsonb("context_snapshot").$type>(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), @@ -52,5 +59,10 @@ export const heartbeatRuns = pgTable( table.agentId, table.startedAt, ), + companyLivenessIdx: index("heartbeat_runs_company_liveness_idx").on( + table.companyId, + table.livenessState, + table.createdAt, + ), }), ); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 006ecd2..d191434 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -27,6 +27,7 @@ export { workspaceRuntimeServices } from "./workspace_runtime_services.js"; export { projectGoals } from "./project_goals.js"; export { goals } from "./goals.js"; export { issues } from "./issues.js"; +export { issueReferenceMentions } from "./issue_reference_mentions.js"; export { issueRelations } from "./issue_relations.js"; export { routines, routineTriggers, routineRuns } from "./routines.js"; export { issueWorkProducts } from "./issue_work_products.js"; @@ -34,6 +35,7 @@ export { labels } from "./labels.js"; export { issueLabels } from "./issue_labels.js"; export { issueApprovals } from "./issue_approvals.js"; export { issueComments } from "./issue_comments.js"; +export { issueThreadInteractions } from "./issue_thread_interactions.js"; export { issueExecutionDecisions } from "./issue_execution_decisions.js"; export { issueInboxArchives } from "./issue_inbox_archives.js"; export { inboxDismissals } from "./inbox_dismissals.js"; @@ -60,7 +62,7 @@ export { pluginConfig } from "./plugin_config.js"; export { pluginCompanySettings } from "./plugin_company_settings.js"; export { pluginState } from "./plugin_state.js"; export { pluginEntities } from "./plugin_entities.js"; +export { pluginDatabaseNamespaces, pluginMigrations } from "./plugin_database.js"; export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js"; export { pluginWebhookDeliveries } from "./plugin_webhooks.js"; export { pluginLogs } from "./plugin_logs.js"; -export { issueArtifacts } from "./issue_artifacts.js"; diff --git a/packages/db/src/schema/issue_artifacts.ts b/packages/db/src/schema/issue_artifacts.ts deleted file mode 100644 index 9725826..0000000 --- a/packages/db/src/schema/issue_artifacts.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { integer, pgTable, text, timestamp, uuid, index, uniqueIndex, jsonb } from "drizzle-orm/pg-core"; -import { companies } from "./companies.js"; -import { issues } from "./issues.js"; -import { agents } from "./agents.js"; - -export const issueArtifacts = pgTable( - "issue_artifacts", - { - id: uuid("id").primaryKey().defaultRandom(), - companyId: uuid("company_id").notNull().references(() => companies.id), - issueId: uuid("issue_id").notNull().references(() => issues.id), - artifactId: text("artifact_id").notNull(), // Logical ID for the artifact (e.g., "implementation-plan") - version: integer("version").notNull().default(1), - title: text("title").notNull(), - mimeType: text("mime_type").notNull(), - provider: text("provider").notNull(), - objectKey: text("object_key").notNull(), - sizeBytes: integer("size_bytes").notNull().default(0), - sha256: text("sha256").notNull(), - metadataJson: jsonb("metadata_json").$type>(), - createdByAgentId: uuid("created_by_agent_id").references(() => agents.id), - createdByUserId: text("created_by_user_id"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - companyIssueIdx: index("issue_artifacts_company_issue_idx").on(table.companyId, table.issueId), - companyArtifactIdVersionUniqueIdx: uniqueIndex("issue_artifacts_company_artifact_version_unique_idx").on( - table.companyId, - table.issueId, - table.artifactId, - table.version - ), - }) -); diff --git a/packages/db/src/schema/issue_reference_mentions.ts b/packages/db/src/schema/issue_reference_mentions.ts new file mode 100644 index 0000000..6c44e54 --- /dev/null +++ b/packages/db/src/schema/issue_reference_mentions.ts @@ -0,0 +1,48 @@ +import { sql } from "drizzle-orm"; +import { index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { issues } from "./issues.js"; + +export const issueReferenceMentions = pgTable( + "issue_reference_mentions", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + sourceIssueId: uuid("source_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + targetIssueId: uuid("target_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + sourceKind: text("source_kind").$type<"title" | "description" | "comment" | "document">().notNull(), + sourceRecordId: uuid("source_record_id"), + documentKey: text("document_key"), + matchedText: text("matched_text"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companySourceIssueIdx: index("issue_reference_mentions_company_source_issue_idx").on( + table.companyId, + table.sourceIssueId, + ), + companyTargetIssueIdx: index("issue_reference_mentions_company_target_issue_idx").on( + table.companyId, + table.targetIssueId, + ), + companyIssuePairIdx: index("issue_reference_mentions_company_issue_pair_idx").on( + table.companyId, + table.sourceIssueId, + table.targetIssueId, + ), + companySourceMentionWithRecordUq: uniqueIndex("issue_reference_mentions_company_source_mention_record_uq").on( + table.companyId, + table.sourceIssueId, + table.targetIssueId, + table.sourceKind, + table.sourceRecordId, + ).where(sql`${table.sourceRecordId} is not null`), + companySourceMentionWithoutRecordUq: uniqueIndex("issue_reference_mentions_company_source_mention_null_record_uq").on( + table.companyId, + table.sourceIssueId, + table.targetIssueId, + table.sourceKind, + ).where(sql`${table.sourceRecordId} is null`), + }), +); diff --git a/packages/db/src/schema/issue_thread_interactions.ts b/packages/db/src/schema/issue_thread_interactions.ts new file mode 100644 index 0000000..f2ff0f4 --- /dev/null +++ b/packages/db/src/schema/issue_thread_interactions.ts @@ -0,0 +1,54 @@ +import type { + IssueThreadInteractionPayload, + IssueThreadInteractionResult, +} from "@taskcore/shared"; +import { sql } from "drizzle-orm"; +import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; +import { issueComments } from "./issue_comments.js"; +import { issues } from "./issues.js"; + +export const issueThreadInteractions = pgTable( + "issue_thread_interactions", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + issueId: uuid("issue_id").notNull().references(() => issues.id), + kind: text("kind").notNull(), + status: text("status").notNull().default("pending"), + continuationPolicy: text("continuation_policy").notNull().default("wake_assignee"), + idempotencyKey: text("idempotency_key"), + sourceCommentId: uuid("source_comment_id").references(() => issueComments.id, { onDelete: "set null" }), + sourceRunId: uuid("source_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + title: text("title"), + summary: text("summary"), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id), + createdByUserId: text("created_by_user_id"), + resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id), + resolvedByUserId: text("resolved_by_user_id"), + payload: jsonb("payload").$type().notNull(), + result: jsonb("result").$type(), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + issueIdx: index("issue_thread_interactions_issue_idx").on(table.issueId), + companyIssueCreatedAtIdx: index("issue_thread_interactions_company_issue_created_at_idx").on( + table.companyId, + table.issueId, + table.createdAt, + ), + companyIssueStatusIdx: index("issue_thread_interactions_company_issue_status_idx").on( + table.companyId, + table.issueId, + table.status, + ), + companyIssueIdempotencyUq: uniqueIndex("issue_thread_interactions_company_issue_idempotency_uq") + .on(table.companyId, table.issueId, table.idempotencyKey) + .where(sql`${table.idempotencyKey} IS NOT NULL`), + sourceCommentIdx: index("issue_thread_interactions_source_comment_idx").on(table.sourceCommentId), + }), +); diff --git a/packages/db/src/schema/issues.ts b/packages/db/src/schema/issues.ts index f32e292..d4dae91 100644 --- a/packages/db/src/schema/issues.ts +++ b/packages/db/src/schema/issues.ts @@ -44,6 +44,7 @@ export const issues = pgTable( originKind: text("origin_kind").notNull().default("manual"), originId: text("origin_id"), originRunId: text("origin_run_id"), + originFingerprint: text("origin_fingerprint").notNull().default("default"), requestDepth: integer("request_depth").notNull().default(0), billingCode: text("billing_code"), assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type>(), @@ -82,7 +83,7 @@ export const issues = pgTable( identifierSearchIdx: index("issues_identifier_search_idx").using("gin", table.identifier.op("gin_trgm_ops")), descriptionSearchIdx: index("issues_description_search_idx").using("gin", table.description.op("gin_trgm_ops")), openRoutineExecutionIdx: uniqueIndex("issues_open_routine_execution_uq") - .on(table.companyId, table.originKind, table.originId) + .on(table.companyId, table.originKind, table.originId, table.originFingerprint) .where( sql`${table.originKind} = 'routine_execution' and ${table.originId} is not null diff --git a/packages/db/src/schema/join_requests.ts b/packages/db/src/schema/join_requests.ts index 458e45d..3813c27 100644 --- a/packages/db/src/schema/join_requests.ts +++ b/packages/db/src/schema/join_requests.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; import { invites } from "./invites.js"; @@ -37,5 +38,11 @@ export const joinRequests = pgTable( table.requestType, table.createdAt, ), + pendingHumanUserUniqueIdx: uniqueIndex("join_requests_pending_human_user_uq") + .on(table.companyId, table.requestingUserId) + .where(sql`${table.requestType} = 'human' AND ${table.status} = 'pending_approval' AND ${table.requestingUserId} IS NOT NULL`), + pendingHumanEmailUniqueIdx: uniqueIndex("join_requests_pending_human_email_uq") + .on(table.companyId, sql`lower(${table.requestEmailSnapshot})`) + .where(sql`${table.requestType} = 'human' AND ${table.status} = 'pending_approval' AND ${table.requestEmailSnapshot} IS NOT NULL`), }), ); diff --git a/packages/db/src/schema/plugin_database.ts b/packages/db/src/schema/plugin_database.ts new file mode 100644 index 0000000..c205026 --- /dev/null +++ b/packages/db/src/schema/plugin_database.ts @@ -0,0 +1,75 @@ +import { + pgTable, + uuid, + text, + timestamp, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import type { + PluginDatabaseMigrationStatus, + PluginDatabaseNamespaceMode, + PluginDatabaseNamespaceStatus, +} from "@taskcore/shared"; +import { plugins } from "./plugins.js"; + +/** + * Database namespace allocated to an installed plugin. + * + * Namespaces are deterministic and owned by the host. Plugin SQL may create + * objects only inside its namespace, while selected public core tables remain + * read-only join targets through runtime checks. + */ +export const pluginDatabaseNamespaces = pgTable( + "plugin_database_namespaces", + { + id: uuid("id").primaryKey().defaultRandom(), + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + pluginKey: text("plugin_key").notNull(), + namespaceName: text("namespace_name").notNull(), + namespaceMode: text("namespace_mode").$type().notNull().default("schema"), + status: text("status").$type().notNull().default("active"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginIdx: uniqueIndex("plugin_database_namespaces_plugin_idx").on(table.pluginId), + namespaceIdx: uniqueIndex("plugin_database_namespaces_namespace_idx").on(table.namespaceName), + statusIdx: index("plugin_database_namespaces_status_idx").on(table.status), + }), +); + +/** + * Per-plugin migration ledger. + * + * Every migration file is recorded with a checksum. A previously applied + * migration whose checksum changes is rejected during later activation. + */ +export const pluginMigrations = pgTable( + "plugin_migrations", + { + id: uuid("id").primaryKey().defaultRandom(), + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + pluginKey: text("plugin_key").notNull(), + namespaceName: text("namespace_name").notNull(), + migrationKey: text("migration_key").notNull(), + checksum: text("checksum").notNull(), + pluginVersion: text("plugin_version").notNull(), + status: text("status").$type().notNull(), + startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(), + appliedAt: timestamp("applied_at", { withTimezone: true }), + errorMessage: text("error_message"), + }, + (table) => ({ + pluginMigrationIdx: uniqueIndex("plugin_migrations_plugin_key_idx").on( + table.pluginId, + table.migrationKey, + ), + pluginIdx: index("plugin_migrations_plugin_idx").on(table.pluginId), + statusIdx: index("plugin_migrations_status_idx").on(table.status), + }), +); diff --git a/packages/db/src/schema/routines.ts b/packages/db/src/schema/routines.ts index c15228c..0ee219c 100644 --- a/packages/db/src/schema/routines.ts +++ b/packages/db/src/schema/routines.ts @@ -96,6 +96,7 @@ export const routineRuns = pgTable( triggeredAt: timestamp("triggered_at", { withTimezone: true }).notNull().defaultNow(), idempotencyKey: text("idempotency_key"), triggerPayload: jsonb("trigger_payload").$type>(), + dispatchFingerprint: text("dispatch_fingerprint"), linkedIssueId: uuid("linked_issue_id").references(() => issues.id, { onDelete: "set null" }), coalescedIntoRunId: uuid("coalesced_into_run_id"), failureReason: text("failure_reason"), @@ -106,6 +107,7 @@ export const routineRuns = pgTable( (table) => ({ companyRoutineIdx: index("routine_runs_company_routine_idx").on(table.companyId, table.routineId, table.createdAt), triggerIdx: index("routine_runs_trigger_idx").on(table.triggerId, table.createdAt), + dispatchFingerprintIdx: index("routine_runs_dispatch_fingerprint_idx").on(table.routineId, table.dispatchFingerprint), linkedIssueIdx: index("routine_runs_linked_issue_idx").on(table.linkedIssueId), idempotencyIdx: index("routine_runs_trigger_idempotency_idx").on(table.triggerId, table.idempotencyKey), }), diff --git a/packages/db/src/test-embedded-postgres.ts b/packages/db/src/test-embedded-postgres.ts index 514f49b..42f1cf8 100644 --- a/packages/db/src/test-embedded-postgres.ts +++ b/packages/db/src/test-embedded-postgres.ts @@ -75,8 +75,8 @@ async function probeEmbeddedPostgresSupport(): Promise { }, - onError: () => { }, + onLog: () => {}, + onError: () => {}, }); try { @@ -89,7 +89,7 @@ async function probeEmbeddedPostgresSupport(): Promise { }); + await instance.stop().catch(() => {}); fs.rmSync(dataDir, { recursive: true, force: true }); } } @@ -114,8 +114,8 @@ export async function startEmbeddedPostgresTestDatabase( port, persistent: true, initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => { }, - onError: () => { }, + onLog: () => {}, + onError: () => {}, }); try { @@ -130,12 +130,12 @@ export async function startEmbeddedPostgresTestDatabase( return { connectionString, cleanup: async () => { - await instance.stop().catch(() => { }); + await instance.stop().catch(() => {}); fs.rmSync(dataDir, { recursive: true, force: true }); }, }; } catch (error) { - await instance.stop().catch(() => { }); + await instance.stop().catch(() => {}); fs.rmSync(dataDir, { recursive: true, force: true }); throw new Error( `Failed to start embedded PostgreSQL test database: ${formatEmbeddedPostgresError(error)}`, From a4c37a522fa4e51b51dd0f9a28ba425ff937dc33 Mon Sep 17 00:00:00 2001 From: NeoPilot <221231603+neopilotai@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:08:34 +0000 Subject: [PATCH 02/13] =?UTF-8?q?=F0=9F=93=A6=20shared:=20update=20types,?= =?UTF-8?q?=20constants,=20and=20validators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/shared/CHANGELOG.md | 52 ++++ packages/shared/package.json | 2 +- packages/shared/src/constants.ts | 159 ++++++++++- packages/shared/src/index.ts | 151 +++++++++- packages/shared/src/issue-references.test.ts | 68 +++++ packages/shared/src/issue-references.ts | 188 ++++++++++++ .../src/issue-thread-interactions.test.ts | 123 ++++++++ packages/shared/src/project-mentions.test.ts | 11 + packages/shared/src/project-mentions.ts | 40 +++ packages/shared/src/types/access.ts | 89 ++++++ packages/shared/src/types/activity.ts | 2 +- packages/shared/src/types/budget.ts | 5 - .../shared/src/types/company-portability.ts | 34 +-- packages/shared/src/types/dashboard.ts | 9 + packages/shared/src/types/feedback.ts | 30 +- packages/shared/src/types/heartbeat.ts | 13 +- packages/shared/src/types/index.ts | 60 +++- packages/shared/src/types/issue.ts | 215 ++++++++++++-- packages/shared/src/types/plugin.ts | 81 ++++++ packages/shared/src/types/project.ts | 2 +- packages/shared/src/types/routine.ts | 3 +- packages/shared/src/types/user-profile.ts | 88 ++++++ .../shared/src/types/workspace-runtime.ts | 9 +- packages/shared/src/validators/access.ts | 107 +++++++ packages/shared/src/validators/approval.ts | 2 - .../src/validators/execution-workspace.ts | 5 +- packages/shared/src/validators/index.ts | 47 +++ packages/shared/src/validators/issue.ts | 270 +++++++++++++++++- packages/shared/src/validators/plugin.ts | 105 +++++++ packages/shared/src/validators/project.ts | 4 +- .../shared/src/workspace-commands.test.ts | 22 ++ packages/shared/src/workspace-commands.ts | 4 + packages/shared/vitest.config.ts | 7 + 33 files changed, 1915 insertions(+), 92 deletions(-) create mode 100644 packages/shared/src/issue-references.test.ts create mode 100644 packages/shared/src/issue-references.ts create mode 100644 packages/shared/src/issue-thread-interactions.test.ts create mode 100644 packages/shared/src/types/user-profile.ts create mode 100644 packages/shared/vitest.config.ts diff --git a/packages/shared/CHANGELOG.md b/packages/shared/CHANGELOG.md index c0d239b..f5cb2b9 100644 --- a/packages/shared/CHANGELOG.md +++ b/packages/shared/CHANGELOG.md @@ -1,4 +1,56 @@ # @taskcore/shared +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 + +## 0.3.0 + +### Minor Changes + +- 6077ae6: Add support for Pi local adapter in constants and onboarding UI. +- Stable release preparation for 0.3.0 + +## 0.2.7 + +### Patch Changes + +- Version bump (patch) + +## 0.2.6 + +### Patch Changes + +- Version bump (patch) + +## 0.2.5 + +### Patch Changes + +- Version bump (patch) + +## 0.2.4 + +### Patch Changes + +- Version bump (patch) + +## 0.2.3 + +### Patch Changes + +- Version bump (patch) + +## 0.2.2 + +### Patch Changes + +- Version bump (patch) + ## 0.2.1 +### Patch Changes + +- Version bump (patch) diff --git a/packages/shared/package.json b/packages/shared/package.json index 6f31505..89aa8bc 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -50,4 +50,4 @@ "devDependencies": { "typescript": "^5.7.3" } -} \ No newline at end of file +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 1b72e3e..0ddde1e 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -66,6 +66,8 @@ export const AGENT_ROLE_LABELS: Record = { general: "General", }; +export const AGENT_DEFAULT_MAX_CONCURRENT_RUNS = 5; +export const WORKSPACE_BRANCH_ROUTINE_VARIABLE = "workspaceBranch"; export const AGENT_ICON_NAMES = [ "bot", "cpu", @@ -135,12 +137,51 @@ export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join("," export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const; export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; +export const ISSUE_THREAD_INTERACTION_KINDS = [ + "suggest_tasks", + "ask_user_questions", + "request_confirmation", +] as const; +export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number]; + +export const ISSUE_THREAD_INTERACTION_STATUSES = [ + "pending", + "accepted", + "rejected", + "answered", + "expired", + "failed", +] as const; +export type IssueThreadInteractionStatus = (typeof ISSUE_THREAD_INTERACTION_STATUSES)[number]; + +export const ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES = [ + "none", + "wake_assignee", + "wake_assignee_on_accept", +] as const; +export type IssueThreadInteractionContinuationPolicy = + (typeof ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES)[number]; + export const ISSUE_ORIGIN_KINDS = ["manual", "routine_execution"] as const; -export type IssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number]; +export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number]; +export type PluginIssueOriginKind = `plugin:${string}`; +export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind; export const ISSUE_RELATION_TYPES = ["blocks"] as const; export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number]; +export const ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY = "continuation-summary" as const; +export const SYSTEM_ISSUE_DOCUMENT_KEYS = [ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY] as const; +export type SystemIssueDocumentKey = (typeof SYSTEM_ISSUE_DOCUMENT_KEYS)[number]; + +const SYSTEM_ISSUE_DOCUMENT_KEY_SET = new Set(SYSTEM_ISSUE_DOCUMENT_KEYS); + +export function isSystemIssueDocumentKey(key: string): key is SystemIssueDocumentKey { + return SYSTEM_ISSUE_DOCUMENT_KEY_SET.has(key); +} +export const ISSUE_REFERENCE_SOURCE_KINDS = ["title", "description", "comment", "document"] as const; +export type IssueReferenceSourceKind = (typeof ISSUE_REFERENCE_SOURCE_KINDS)[number]; + export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const; export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number]; @@ -193,7 +234,7 @@ export const ROUTINE_RUN_STATUSES = [ "issue_created", "completed", "failed", -] as const; + ] as const; export type RoutineRunStatus = (typeof ROUTINE_RUN_STATUSES)[number]; export const ROUTINE_RUN_SOURCES = ["schedule", "manual", "api", "webhook"] as const; @@ -333,18 +374,9 @@ export const WAKEUP_REQUEST_STATUSES = [ ] as const; export type WakeupRequestStatus = (typeof WAKEUP_REQUEST_STATUSES)[number]; -export const WAKE_REASONS = [ - "heartbeat_timer", - "mention", - "task_update", - "force_wake", - "budget_check", - "system", -] as const; -export type WakeReason = (typeof WAKE_REASONS)[number]; - export const HEARTBEAT_RUN_STATUSES = [ "queued", + "scheduled_retry", "running", "succeeded", "failed", @@ -353,6 +385,17 @@ export const HEARTBEAT_RUN_STATUSES = [ ] as const; export type HeartbeatRunStatus = (typeof HEARTBEAT_RUN_STATUSES)[number]; +export const RUN_LIVENESS_STATES = [ + "completed", + "advanced", + "plan_only", + "empty_response", + "blocked", + "failed", + "needs_followup", +] as const; +export type RunLivenessState = (typeof RUN_LIVENESS_STATES)[number]; + export const LIVE_EVENT_TYPES = [ "heartbeat.run.queued", "heartbeat.run.status", @@ -369,9 +412,33 @@ export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number]; export const PRINCIPAL_TYPES = ["user", "agent"] as const; export type PrincipalType = (typeof PRINCIPAL_TYPES)[number]; -export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended"] as const; +export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended", "archived"] as const; export type MembershipStatus = (typeof MEMBERSHIP_STATUSES)[number]; +export const COMPANY_MEMBERSHIP_ROLES = [ + "owner", + "admin", + "operator", + "viewer", + "member", +] as const; +export type CompanyMembershipRole = (typeof COMPANY_MEMBERSHIP_ROLES)[number]; + +export const HUMAN_COMPANY_MEMBERSHIP_ROLES = [ + "owner", + "admin", + "operator", + "viewer", +] as const; +export type HumanCompanyMembershipRole = (typeof HUMAN_COMPANY_MEMBERSHIP_ROLES)[number]; + +export const HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS: Record = { + owner: "Owner", + admin: "Admin", + operator: "Operator", + viewer: "Viewer", +}; + export const INSTANCE_USER_ROLES = ["instance_admin"] as const; export type InstanceUserRole = (typeof INSTANCE_USER_ROLES)[number]; @@ -393,6 +460,7 @@ export const PERMISSION_KEYS = [ "users:manage_permissions", "tasks:assign", "tasks:assign_scope", + "tasks:manage_active_checkouts", "joins:approve", ] as const; export type PermissionKey = (typeof PERMISSION_KEYS)[number]; @@ -461,6 +529,8 @@ export const PLUGIN_CAPABILITIES = [ "projects.read", "project.workspaces.read", "issues.read", + "issue.relations.read", + "issue.subtree.read", "issue.comments.read", "issue.documents.read", "agents.read", @@ -469,10 +539,16 @@ export const PLUGIN_CAPABILITIES = [ "goals.update", "activity.read", "costs.read", + "issues.orchestration.read", + "database.namespace.read", // Data Write "issues.create", "issues.update", + "issue.relations.write", + "issues.checkout", + "issues.wakeup", "issue.comments.create", + "issue.interactions.create", "issue.documents.write", "agents.pause", "agents.resume", @@ -484,6 +560,8 @@ export const PLUGIN_CAPABILITIES = [ "activity.log.write", "metrics.write", "telemetry.track", + "database.namespace.migrate", + "database.namespace.write", // Plugin State "plugin.state.read", "plugin.state.write", @@ -492,6 +570,7 @@ export const PLUGIN_CAPABILITIES = [ "events.emit", "jobs.schedule", "webhooks.receive", + "api.routes.register", "http.outbound", "secrets.read-ref", // Agent Tools @@ -507,6 +586,51 @@ export const PLUGIN_CAPABILITIES = [ ] as const; export type PluginCapability = (typeof PLUGIN_CAPABILITIES)[number]; +export const PLUGIN_DATABASE_NAMESPACE_MODES = ["schema"] as const; +export type PluginDatabaseNamespaceMode = (typeof PLUGIN_DATABASE_NAMESPACE_MODES)[number]; + +export const PLUGIN_DATABASE_NAMESPACE_STATUSES = [ + "active", + "migration_failed", +] as const; +export type PluginDatabaseNamespaceStatus = (typeof PLUGIN_DATABASE_NAMESPACE_STATUSES)[number]; + +export const PLUGIN_DATABASE_MIGRATION_STATUSES = [ + "applied", + "failed", +] as const; +export type PluginDatabaseMigrationStatus = (typeof PLUGIN_DATABASE_MIGRATION_STATUSES)[number]; + +export const PLUGIN_DATABASE_CORE_READ_TABLES = [ + "companies", + "projects", + "goals", + "agents", + "issues", + "issue_documents", + "issue_relations", + "issue_comments", + "heartbeat_runs", + "cost_events", + "approvals", + "issue_approvals", + "budget_incidents", +] as const; +export type PluginDatabaseCoreReadTable = (typeof PLUGIN_DATABASE_CORE_READ_TABLES)[number]; + +export const PLUGIN_API_ROUTE_METHODS = ["GET", "POST", "PATCH", "DELETE"] as const; +export type PluginApiRouteMethod = (typeof PLUGIN_API_ROUTE_METHODS)[number]; + +export const PLUGIN_API_ROUTE_AUTH_MODES = ["board", "agent", "board-or-agent", "webhook"] as const; +export type PluginApiRouteAuthMode = (typeof PLUGIN_API_ROUTE_AUTH_MODES)[number]; + +export const PLUGIN_API_ROUTE_CHECKOUT_POLICIES = [ + "none", + "required-for-agent-in-progress", + "always-for-agent", +] as const; +export type PluginApiRouteCheckoutPolicy = (typeof PLUGIN_API_ROUTE_CHECKOUT_POLICIES)[number]; + /** * UI extension slot types. Each slot type corresponds to a mount point in the * Taskcore UI where plugin components can be rendered. @@ -705,6 +829,13 @@ export const PLUGIN_EVENT_TYPES = [ "issue.created", "issue.updated", "issue.comment.created", + "issue.document.created", + "issue.document.updated", + "issue.document.deleted", + "issue.relations.updated", + "issue.checked_out", + "issue.released", + "issue.assignment_wakeup_requested", "agent.created", "agent.updated", "agent.status_changed", @@ -716,6 +847,8 @@ export const PLUGIN_EVENT_TYPES = [ "goal.updated", "approval.created", "approval.decided", + "budget.incident.opened", + "budget.incident.resolved", "cost_event.created", "activity.logged", ] as const; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5912ea5..15df47b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,13 +9,22 @@ export { AGENT_ADAPTER_TYPES, AGENT_ROLES, AGENT_ROLE_LABELS, + AGENT_DEFAULT_MAX_CONCURRENT_RUNS, + WORKSPACE_BRANCH_ROUTINE_VARIABLE, AGENT_ICON_NAMES, ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUS_FILTER, ISSUE_PRIORITIES, + ISSUE_THREAD_INTERACTION_KINDS, + ISSUE_THREAD_INTERACTION_STATUSES, + ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, ISSUE_ORIGIN_KINDS, ISSUE_RELATION_TYPES, + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + SYSTEM_ISSUE_DOCUMENT_KEYS, + isSystemIssueDocumentKey, + ISSUE_REFERENCE_SOURCE_KINDS, ISSUE_EXECUTION_POLICY_MODES, ISSUE_EXECUTION_STAGE_TYPES, ISSUE_EXECUTION_STATE_STATUSES, @@ -49,11 +58,15 @@ export { BUDGET_INCIDENT_RESOLUTION_ACTIONS, HEARTBEAT_INVOCATION_SOURCES, HEARTBEAT_RUN_STATUSES, + RUN_LIVENESS_STATES, WAKEUP_TRIGGER_DETAILS, WAKEUP_REQUEST_STATUSES, LIVE_EVENT_TYPES, PRINCIPAL_TYPES, MEMBERSHIP_STATUSES, + COMPANY_MEMBERSHIP_ROLES, + HUMAN_COMPANY_MEMBERSHIP_ROLES, + HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, INSTANCE_USER_ROLES, INVITE_TYPES, INVITE_JOIN_TYPES, @@ -75,6 +88,13 @@ export { PLUGIN_JOB_RUN_STATUSES, PLUGIN_JOB_RUN_TRIGGERS, PLUGIN_WEBHOOK_DELIVERY_STATUSES, + PLUGIN_DATABASE_NAMESPACE_MODES, + PLUGIN_DATABASE_NAMESPACE_STATUSES, + PLUGIN_DATABASE_MIGRATION_STATUSES, + PLUGIN_DATABASE_CORE_READ_TABLES, + PLUGIN_API_ROUTE_METHODS, + PLUGIN_API_ROUTE_AUTH_MODES, + PLUGIN_API_ROUTE_CHECKOUT_POLICIES, PLUGIN_EVENT_TYPES, PLUGIN_BRIDGE_ERROR_CODES, type CompanyStatus, @@ -88,8 +108,15 @@ export { type AgentIconName, type IssueStatus, type IssuePriority, + type IssueThreadInteractionKind, + type IssueThreadInteractionStatus, + type IssueThreadInteractionContinuationPolicy, + type BuiltInIssueOriginKind, + type PluginIssueOriginKind, type IssueOriginKind, type IssueRelationType, + type SystemIssueDocumentKey, + type IssueReferenceSourceKind, type IssueExecutionPolicyMode, type IssueExecutionStageType, type IssueExecutionStateStatus, @@ -122,11 +149,14 @@ export { type BudgetIncidentResolutionAction, type HeartbeatInvocationSource, type HeartbeatRunStatus, + type RunLivenessState, type WakeupTriggerDetail, type WakeupRequestStatus, type LiveEventType, type PrincipalType, type MembershipStatus, + type CompanyMembershipRole, + type HumanCompanyMembershipRole, type InstanceUserRole, type InviteType, type InviteJoinType, @@ -147,6 +177,13 @@ export { type PluginJobRunStatus, type PluginJobRunTrigger, type PluginWebhookDeliveryStatus, + type PluginDatabaseNamespaceMode, + type PluginDatabaseNamespaceStatus, + type PluginDatabaseMigrationStatus, + type PluginDatabaseCoreReadTable, + type PluginApiRouteMethod, + type PluginApiRouteAuthMode, + type PluginApiRouteCheckoutPolicy, type PluginEventType, type PluginBridgeErrorCode, } from "./constants.js"; @@ -224,6 +261,7 @@ export type { ProjectGoalRef, ProjectWorkspace, ExecutionWorkspace, + ExecutionWorkspaceSummary, ExecutionWorkspaceConfig, ExecutionWorkspaceCloseAction, ExecutionWorkspaceCloseActionKind, @@ -256,6 +294,9 @@ export type { IssueWorkProductReviewState, Issue, IssueAssigneeAdapterOverrides, + IssueReferenceSource, + IssueRelatedWorkItem, + IssueRelatedWorkSummary, IssueRelation, IssueRelationIssueSummary, IssueExecutionPolicy, @@ -265,6 +306,28 @@ export type { IssueExecutionStagePrincipal, IssueExecutionDecision, IssueComment, + IssueThreadInteractionActorFields, + SuggestedTaskDraft, + SuggestTasksPayload, + SuggestTasksResultCreatedTask, + SuggestTasksResult, + AskUserQuestionsQuestionOption, + AskUserQuestionsQuestion, + AskUserQuestionsPayload, + AskUserQuestionsAnswer, + AskUserQuestionsResult, + RequestConfirmationIssueDocumentTarget, + RequestConfirmationCustomTarget, + RequestConfirmationTarget, + RequestConfirmationPayload, + RequestConfirmationResult, + IssueThreadInteractionBase, + SuggestTasksInteraction, + AskUserQuestionsInteraction, + RequestConfirmationInteraction, + IssueThreadInteraction, + IssueThreadInteractionPayload, + IssueThreadInteractionResult, IssueDocument, IssueDocumentSummary, DocumentRevision, @@ -302,16 +365,35 @@ export type { AgentWakeupRequest, InstanceSchedulerHeartbeatAgent, LiveEvent, + DashboardRunActivityDay, DashboardSummary, ActivityEvent, + UserProfileActivitySummary, + UserProfileAgentUsage, + UserProfileDailyPoint, + UserProfileIdentity, + UserProfileIssueSummary, + UserProfileProviderUsage, + UserProfileResponse, + UserProfileWindowStats, SidebarBadges, SidebarOrderPreference, InboxDismissal, + AccessUserProfile, + CompanyMemberRecord, + CompanyMembersResponse, CompanyMembership, + CompanyInviteListResponse, + CompanyInviteRecord, PrincipalPermissionGrant, Invite, JoinRequest, + JoinRequestInviteSummary, + JoinRequestRecord, InstanceUserRoleGrant, + AdminUserDirectoryEntry, + UserCompanyAccessEntry, + UserCompanyAccessResponse, CompanyPortabilityInclude, CompanyPortabilityEnvInput, CompanyPortabilityFileEntry, @@ -366,8 +448,13 @@ export type { PluginLauncherDeclaration, PluginMinimumHostVersion, PluginUiDeclaration, + PluginDatabaseDeclaration, + PluginApiRouteCompanyResolution, + PluginApiRouteDeclaration, TaskcorePluginManifestV1, PluginRecord, + PluginDatabaseNamespaceRecord, + PluginMigrationRecord, PluginStateRecord, PluginConfig, PluginEntityRecord, @@ -378,6 +465,16 @@ export type { QuotaWindow, ProviderQuotaResult, } from "./types/index.js"; +export { + ISSUE_REFERENCE_IDENTIFIER_RE, + buildIssueReferenceHref, + extractIssueReferenceIdentifiers, + extractIssueReferenceMatches, + findIssueReferenceMatches, + normalizeIssueIdentifier, + parseIssueReferenceHref, + type IssueReferenceMatch, +} from "./issue-references.js"; export { sidebarOrderPreferenceSchema, @@ -478,6 +575,7 @@ export { type UpdateProjectWorkspace, projectExecutionWorkspacePolicySchema, createIssueSchema, + createChildIssueSchema, createIssueLabelSchema, updateIssueSchema, issueExecutionPolicySchema, @@ -485,6 +583,27 @@ export { issueExecutionWorkspaceSettingsSchema, checkoutIssueSchema, addIssueCommentSchema, + issueThreadInteractionStatusSchema, + issueThreadInteractionKindSchema, + issueThreadInteractionContinuationPolicySchema, + suggestedTaskDraftSchema, + suggestTasksPayloadSchema, + suggestTasksResultCreatedTaskSchema, + suggestTasksResultSchema, + askUserQuestionsQuestionOptionSchema, + askUserQuestionsQuestionSchema, + askUserQuestionsPayloadSchema, + askUserQuestionsAnswerSchema, + askUserQuestionsResultSchema, + requestConfirmationIssueDocumentTargetSchema, + requestConfirmationCustomTargetSchema, + requestConfirmationTargetSchema, + requestConfirmationPayloadSchema, + requestConfirmationResultSchema, + createIssueThreadInteractionSchema, + acceptIssueThreadInteractionSchema, + rejectIssueThreadInteractionSchema, + respondIssueThreadInteractionSchema, linkIssueApprovalSchema, createIssueAttachmentMetadataSchema, createIssueWorkProductSchema, @@ -505,10 +624,15 @@ export { upsertIssueDocumentSchema, restoreIssueDocumentRevisionSchema, type CreateIssue, + type CreateChildIssue, type CreateIssueLabel, type UpdateIssue, type CheckoutIssue, type AddIssueComment, + type CreateIssueThreadInteraction, + type AcceptIssueThreadInteraction, + type RejectIssueThreadInteraction, + type RespondIssueThreadInteraction, type LinkIssueApproval, type CreateIssueAttachmentMetadata, type CreateIssueWorkProduct, @@ -565,12 +689,20 @@ export { createCompanyInviteSchema, createOpenClawInvitePromptSchema, acceptInviteSchema, + listCompanyInvitesQuerySchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, boardCliAuthAccessLevelSchema, createCliAuthChallengeSchema, resolveCliAuthChallengeSchema, + currentUserProfileSchema, + authSessionSchema, + updateCurrentUserProfileSchema, + updateCompanyMemberSchema, + updateCompanyMemberWithPermissionsSchema, + archiveCompanyMemberSchema, updateMemberPermissionsSchema, + searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, type CreateCostEvent, type CreateFinanceEvent, @@ -579,12 +711,20 @@ export { type CreateCompanyInvite, type CreateOpenClawInvitePrompt, type AcceptInvite, + type ListCompanyInvitesQuery, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, type BoardCliAuthAccessLevel, type CreateCliAuthChallenge, type ResolveCliAuthChallenge, + type CurrentUserProfile, + type AuthSession, + type UpdateCurrentUserProfile, + type UpdateCompanyMember, + type UpdateCompanyMemberWithPermissions, + type ArchiveCompanyMember, type UpdateMemberPermissions, + type SearchAdminUsersQuery, type UpdateUserCompanyAccess, companySkillSourceTypeSchema, companySkillTrustLevelSchema, @@ -628,6 +768,8 @@ export { pluginLauncherActionDeclarationSchema, pluginLauncherRenderDeclarationSchema, pluginLauncherDeclarationSchema, + pluginDatabaseDeclarationSchema, + pluginApiRouteDeclarationSchema, pluginManifestV1Schema, installPluginSchema, upsertPluginConfigSchema, @@ -644,6 +786,8 @@ export { type PluginLauncherActionDeclarationInput, type PluginLauncherRenderDeclarationInput, type PluginLauncherDeclarationInput, + type PluginDatabaseDeclarationInput, + type PluginApiRouteDeclarationInput, type PluginManifestV1Input, type InstallPlugin, type UpsertPluginConfig, @@ -662,18 +806,23 @@ export { AGENT_MENTION_SCHEME, PROJECT_MENTION_SCHEME, SKILL_MENTION_SCHEME, + USER_MENTION_SCHEME, buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref, + buildUserMentionHref, extractAgentMentionIds, + extractProjectMentionIds, extractSkillMentionIds, + extractUserMentionIds, parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref, - extractProjectMentionIds, + parseUserMentionHref, type ParsedAgentMention, type ParsedProjectMention, type ParsedSkillMention, + type ParsedUserMention, } from "./project-mentions.js"; export { diff --git a/packages/shared/src/issue-references.test.ts b/packages/shared/src/issue-references.test.ts new file mode 100644 index 0000000..ea7b297 --- /dev/null +++ b/packages/shared/src/issue-references.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + buildIssueReferenceHref, + extractIssueReferenceIdentifiers, + findIssueReferenceMatches, + normalizeIssueIdentifier, + parseIssueReferenceHref, +} from "./issue-references.js"; + +describe("issue references", () => { + it("normalizes identifiers to uppercase", () => { + expect(normalizeIssueIdentifier("pap-123")).toBe("PAP-123"); + expect(normalizeIssueIdentifier("not-an-issue")).toBeNull(); + }); + + it("parses relative and absolute issue hrefs", () => { + expect(parseIssueReferenceHref("/issues/PAP-123")).toEqual({ identifier: "PAP-123" }); + expect(parseIssueReferenceHref("/PAP/issues/pap-456")).toEqual({ identifier: "PAP-456" }); + expect(parseIssueReferenceHref("https://taskcore.ing/PAP/issues/pap-789#comment-1")).toEqual({ + identifier: "PAP-789", + }); + expect(parseIssueReferenceHref("https://taskcore.ing/projects/PAP-789")).toBeNull(); + }); + + it("builds canonical issue hrefs", () => { + expect(buildIssueReferenceHref("pap-123")).toBe("/issues/PAP-123"); + }); + + it("finds identifiers and issue paths in plain text", () => { + expect(findIssueReferenceMatches("See PAP-1, /issues/PAP-2, and https://x.test/PAP/issues/pap-3.")).toEqual([ + { index: 4, length: 5, identifier: "PAP-1", matchedText: "PAP-1" }, + { index: 11, length: 13, identifier: "PAP-2", matchedText: "/issues/PAP-2" }, + { + index: 30, + length: 31, + identifier: "PAP-3", + matchedText: "https://x.test/PAP/issues/pap-3", + }, + ]); + }); + + it("trims unmatched square brackets from issue path tokens", () => { + expect(findIssueReferenceMatches("See /issues/PAP-123] for context.")).toEqual([ + { index: 4, length: 15, identifier: "PAP-123", matchedText: "/issues/PAP-123" }, + ]); + }); + + it("extracts and dedupes references from markdown", () => { + expect(extractIssueReferenceIdentifiers("PAP-1 [again](/issues/pap-1) PAP-2")).toEqual(["PAP-1", "PAP-2"]); + }); + + it("ignores inline code and fenced code blocks", () => { + const markdown = [ + "Use PAP-1 here.", + "", + "`PAP-2` should not count.", + "", + "```md", + "PAP-3", + "/issues/PAP-4", + "```", + "", + "Final /issues/PAP-5 mention.", + ].join("\n"); + + expect(extractIssueReferenceIdentifiers(markdown)).toEqual(["PAP-1", "PAP-5"]); + }); +}); diff --git a/packages/shared/src/issue-references.ts b/packages/shared/src/issue-references.ts new file mode 100644 index 0000000..86af97b --- /dev/null +++ b/packages/shared/src/issue-references.ts @@ -0,0 +1,188 @@ +export const ISSUE_REFERENCE_IDENTIFIER_RE = /^[A-Z]+-\d+$/; + +export interface IssueReferenceMatch { + index: number; + length: number; + identifier: string; + matchedText: string; +} + +const ISSUE_REFERENCE_TOKEN_RE = /https?:\/\/[^\s<>()]+|\/[^\s<>()]+|[A-Z]+-\d+/gi; + +function preserveNewlinesAsWhitespace(value: string) { + return value.replace(/[^\n]/g, " "); +} + +function stripMarkdownCode(markdown: string): string { + if (!markdown) return ""; + + let output = ""; + let index = 0; + + while (index < markdown.length) { + const remaining = markdown.slice(index); + const fenceMatch = /^(?:```+|~~~+)/.exec(remaining); + const atLineStart = index === 0 || markdown[index - 1] === "\n"; + + if (atLineStart && fenceMatch) { + const fence = fenceMatch[0]!; + const blockStart = index; + index += fence.length; + while (index < markdown.length && markdown[index] !== "\n") index += 1; + if (index < markdown.length) index += 1; + + while (index < markdown.length) { + const lineStart = index === 0 || markdown[index - 1] === "\n"; + if (lineStart && markdown.startsWith(fence, index)) { + index += fence.length; + while (index < markdown.length && markdown[index] !== "\n") index += 1; + if (index < markdown.length) index += 1; + break; + } + index += 1; + } + + output += preserveNewlinesAsWhitespace(markdown.slice(blockStart, index)); + continue; + } + + if (markdown[index] === "`") { + let tickCount = 1; + while (index + tickCount < markdown.length && markdown[index + tickCount] === "`") { + tickCount += 1; + } + const fence = "`".repeat(tickCount); + const inlineStart = index; + index += tickCount; + const closeIndex = markdown.indexOf(fence, index); + if (closeIndex === -1) { + output += markdown.slice(inlineStart, inlineStart + tickCount); + index = inlineStart + tickCount; + continue; + } + index = closeIndex + tickCount; + output += preserveNewlinesAsWhitespace(markdown.slice(inlineStart, index)); + continue; + } + + output += markdown[index]!; + index += 1; + } + + return output; +} + +function trimTrailingPunctuation(token: string): string { + let trimmed = token; + while (trimmed.length > 0) { + const last = trimmed[trimmed.length - 1]!; + if (!".,!?;:".includes(last) && last !== ")" && last !== "]") break; + + if ( + (last === ")" && (trimmed.match(/\(/g)?.length ?? 0) >= (trimmed.match(/\)/g)?.length ?? 0)) + || (last === "]" && (trimmed.match(/\[/g)?.length ?? 0) >= (trimmed.match(/\]/g)?.length ?? 0)) + ) { + break; + } + trimmed = trimmed.slice(0, -1); + } + return trimmed; +} + +export function normalizeIssueIdentifier(value: string): string | null { + const trimmed = value.trim().toUpperCase(); + return ISSUE_REFERENCE_IDENTIFIER_RE.test(trimmed) ? trimmed : null; +} + +export function buildIssueReferenceHref(identifier: string): string { + const normalized = normalizeIssueIdentifier(identifier); + return `/issues/${normalized ?? identifier.trim()}`; +} + +export function parseIssueReferenceHref(href: string): { identifier: string } | null { + const raw = href.trim(); + if (!raw) return null; + + let url: URL; + try { + url = raw.startsWith("/") + ? new URL(raw, "https://taskcore.invalid") + : new URL(raw); + } catch { + return null; + } + + const segments = url.pathname + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); + + for (let index = 0; index < segments.length - 1; index += 1) { + if (segments[index]?.toLowerCase() !== "issues") continue; + const identifier = normalizeIssueIdentifier(segments[index + 1] ?? ""); + if (identifier) { + return { identifier }; + } + } + + return null; +} + +export function findIssueReferenceMatches(text: string): IssueReferenceMatch[] { + if (!text) return []; + + const matches: IssueReferenceMatch[] = []; + let match: RegExpExecArray | null; + const re = new RegExp(ISSUE_REFERENCE_TOKEN_RE); + + while ((match = re.exec(text)) !== null) { + const rawToken = match[0]; + const cleanedToken = trimTrailingPunctuation(rawToken); + if (!cleanedToken) continue; + + const identifier = + normalizeIssueIdentifier(cleanedToken) + ?? parseIssueReferenceHref(cleanedToken)?.identifier + ?? null; + + if (!identifier) continue; + + const cleanedIndex = match.index; + matches.push({ + index: cleanedIndex, + length: cleanedToken.length, + identifier, + matchedText: cleanedToken, + }); + } + + return matches; +} + +export function extractIssueReferenceIdentifiers(markdown: string): string[] { + const scrubbed = stripMarkdownCode(markdown); + const seen = new Set(); + const ordered: string[] = []; + + for (const match of findIssueReferenceMatches(scrubbed)) { + if (seen.has(match.identifier)) continue; + seen.add(match.identifier); + ordered.push(match.identifier); + } + + return ordered; +} + +export function extractIssueReferenceMatches(markdown: string): IssueReferenceMatch[] { + const scrubbed = stripMarkdownCode(markdown); + const seen = new Set(); + const ordered: IssueReferenceMatch[] = []; + + for (const match of findIssueReferenceMatches(scrubbed)) { + if (seen.has(match.identifier)) continue; + seen.add(match.identifier); + ordered.push(match); + } + + return ordered; +} diff --git a/packages/shared/src/issue-thread-interactions.test.ts b/packages/shared/src/issue-thread-interactions.test.ts new file mode 100644 index 0000000..f17ee0e --- /dev/null +++ b/packages/shared/src/issue-thread-interactions.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { createIssueThreadInteractionSchema } from "./validators/issue.js"; + +describe("issue thread interaction schemas", () => { + it("parses request_confirmation payloads with default no-wake continuation", () => { + const parsed = createIssueThreadInteractionSchema.parse({ + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Apply this plan?", + acceptLabel: "Apply", + rejectLabel: "Revise", + rejectRequiresReason: true, + rejectReasonLabel: "What needs to change?", + declineReasonPlaceholder: "Optional: tell the agent what you'd change.", + detailsMarkdown: "The current plan document will be accepted as-is.", + supersedeOnUserComment: true, + }, + }); + + expect(parsed).toMatchObject({ + kind: "request_confirmation", + continuationPolicy: "none", + payload: { + prompt: "Apply this plan?", + acceptLabel: "Apply", + rejectLabel: "Revise", + rejectRequiresReason: true, + rejectReasonLabel: "What needs to change?", + allowDeclineReason: true, + declineReasonPlaceholder: "Optional: tell the agent what you'd change.", + supersedeOnUserComment: true, + }, + }); + }); + + it("accepts issue document targets for request_confirmation interactions", () => { + const parsed = createIssueThreadInteractionSchema.parse({ + kind: "request_confirmation", + continuationPolicy: "wake_assignee_on_accept", + payload: { + version: 1, + prompt: "Accept the latest plan revision?", + allowDeclineReason: false, + target: { + type: "issue_document", + issueId: "11111111-1111-4111-8111-111111111111", + documentId: "22222222-2222-4222-8222-222222222222", + key: "plan", + revisionId: "33333333-3333-4333-8333-333333333333", + revisionNumber: 2, + label: "Plan v2", + href: "/issues/PAP-123#document-plan", + }, + }, + }); + + expect(parsed.kind).toBe("request_confirmation"); + if (parsed.kind !== "request_confirmation") return; + expect(parsed.payload.target).toMatchObject({ + type: "issue_document", + key: "plan", + revisionNumber: 2, + label: "Plan v2", + href: "/issues/PAP-123#document-plan", + }); + }); + + it("accepts custom targets for request_confirmation interactions", () => { + const parsed = createIssueThreadInteractionSchema.parse({ + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Proceed with the external checklist?", + target: { + type: "custom", + key: "external-checklist", + revisionId: "checklist-v1", + revisionNumber: 1, + label: "Checklist v1", + href: "https://example.com/checklist", + }, + }, + }); + + expect(parsed.kind).toBe("request_confirmation"); + if (parsed.kind !== "request_confirmation") return; + expect(parsed.payload.target).toMatchObject({ + type: "custom", + key: "external-checklist", + label: "Checklist v1", + }); + }); + + it("rejects unsafe request_confirmation target hrefs", () => { + const base = { + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Proceed?", + target: { + type: "custom", + key: "external-checklist", + revisionId: "checklist-v1", + label: "Checklist v1", + }, + }, + } as const; + + for (const href of ["javascript:alert(1)", "data:text/html,hi", "//evil.example/path"]) { + expect(() => createIssueThreadInteractionSchema.parse({ + ...base, + payload: { + ...base.payload, + target: { + ...base.payload.target, + href, + }, + }, + })).toThrow("href must not use javascript:, data:, or protocol-relative URLs"); + } + }); +}); diff --git a/packages/shared/src/project-mentions.test.ts b/packages/shared/src/project-mentions.test.ts index 1c11298..45f8de9 100644 --- a/packages/shared/src/project-mentions.test.ts +++ b/packages/shared/src/project-mentions.test.ts @@ -3,12 +3,15 @@ import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref, + buildUserMentionHref, extractAgentMentionIds, extractProjectMentionIds, extractSkillMentionIds, + extractUserMentionIds, parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref, + parseUserMentionHref, } from "./project-mentions.js"; describe("project-mentions", () => { @@ -30,6 +33,14 @@ describe("project-mentions", () => { expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]); }); + it("round-trips user mentions", () => { + const href = buildUserMentionHref("user-123"); + expect(parseUserMentionHref(href)).toEqual({ + userId: "user-123", + }); + expect(extractUserMentionIds(`[@Taylor](${href})`)).toEqual(["user-123"]); + }); + it("round-trips skill mentions with slug metadata", () => { const href = buildSkillMentionHref("skill-123", "release-changelog"); expect(parseSkillMentionHref(href)).toEqual({ diff --git a/packages/shared/src/project-mentions.ts b/packages/shared/src/project-mentions.ts index 117fad3..b2d3ee9 100644 --- a/packages/shared/src/project-mentions.ts +++ b/packages/shared/src/project-mentions.ts @@ -1,5 +1,6 @@ export const PROJECT_MENTION_SCHEME = "project://"; export const AGENT_MENTION_SCHEME = "agent://"; +export const USER_MENTION_SCHEME = "user://"; export const SKILL_MENTION_SCHEME = "skill://"; const HEX_COLOR_RE = /^[0-9a-f]{6}$/i; @@ -8,6 +9,7 @@ const HEX_COLOR_WITH_HASH_RE = /^#[0-9a-f]{6}$/i; const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i; const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi; const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi; +const USER_MENTION_LINK_RE = /\[[^\]]*]\((user:\/\/[^)\s]+)\)/gi; const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi; const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i; const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i; @@ -22,6 +24,10 @@ export interface ParsedAgentMention { icon: string | null; } +export interface ParsedUserMention { + userId: string; +} + export interface ParsedSkillMention { skillId: string; slug: string | null; @@ -111,6 +117,28 @@ export function parseAgentMentionHref(href: string): ParsedAgentMention | null { }; } +export function buildUserMentionHref(userId: string): string { + return `${USER_MENTION_SCHEME}${userId.trim()}`; +} + +export function parseUserMentionHref(href: string): ParsedUserMention | null { + if (!href.startsWith(USER_MENTION_SCHEME)) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + + if (url.protocol !== "user:") return null; + + const userId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim(); + if (!userId) return null; + + return { userId }; +} + export function buildSkillMentionHref(skillId: string, slug?: string | null): string { const trimmedSkillId = skillId.trim(); const normalizedSlug = normalizeSkillSlug(slug ?? null); @@ -165,6 +193,18 @@ export function extractAgentMentionIds(markdown: string): string[] { return [...ids]; } +export function extractUserMentionIds(markdown: string): string[] { + if (!markdown) return []; + const ids = new Set(); + const re = new RegExp(USER_MENTION_LINK_RE); + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + const parsed = parseUserMentionHref(match[1]); + if (parsed) ids.add(parsed.userId); + } + return [...ids]; +} + export function extractSkillMentionIds(markdown: string): string[] { if (!markdown) return []; const ids = new Set(); diff --git a/packages/shared/src/types/access.ts b/packages/shared/src/types/access.ts index db6554e..015d517 100644 --- a/packages/shared/src/types/access.ts +++ b/packages/shared/src/types/access.ts @@ -1,5 +1,7 @@ import type { AgentAdapterType, + CompanyStatus, + HumanCompanyMembershipRole, InstanceUserRole, InviteJoinType, InviteType, @@ -33,6 +35,39 @@ export interface PrincipalPermissionGrant { updatedAt: Date; } +export interface AccessUserProfile { + id: string; + email: string | null; + name: string | null; + image: string | null; +} + +export interface CompanyMemberRecord extends CompanyMembership { + principalType: "user"; + membershipRole: HumanCompanyMembershipRole | null; + user: AccessUserProfile | null; + grants: PrincipalPermissionGrant[]; + removal?: { + canArchive: boolean; + reason: string | null; + }; +} + +export interface CompanyMembersResponse { + members: CompanyMemberRecord[]; + access: { + currentUserRole: HumanCompanyMembershipRole | null; + canManageMembers: boolean; + canInviteUsers: boolean; + canApproveJoinRequests: boolean; + }; +} + +export interface ArchiveCompanyMemberResponse { + member: CompanyMemberRecord; + reassignedIssueCount: number; +} + export interface Invite { id: string; companyId: string | null; @@ -48,6 +83,22 @@ export interface Invite { updatedAt: Date; } +export type InviteState = "active" | "revoked" | "accepted" | "expired"; + +export interface CompanyInviteRecord extends Invite { + companyName: string | null; + humanRole: HumanCompanyMembershipRole | null; + inviteMessage: string | null; + state: InviteState; + invitedByUser: AccessUserProfile | null; + relatedJoinRequestId: string | null; +} + +export interface CompanyInviteListResponse { + invites: CompanyInviteRecord[]; + nextOffset: number | null; +} + export interface JoinRequest { id: string; inviteId: string; @@ -72,6 +123,26 @@ export interface JoinRequest { updatedAt: Date; } +export interface JoinRequestInviteSummary { + id: string; + inviteType: InviteType; + allowedJoinTypes: InviteJoinType; + humanRole: HumanCompanyMembershipRole | null; + inviteMessage: string | null; + createdAt: Date; + expiresAt: Date; + revokedAt: Date | null; + acceptedAt: Date | null; + invitedByUser: AccessUserProfile | null; +} + +export interface JoinRequestRecord extends JoinRequest { + requesterUser: AccessUserProfile | null; + approvedByUser: AccessUserProfile | null; + rejectedByUser: AccessUserProfile | null; + invite: JoinRequestInviteSummary | null; +} + export interface InstanceUserRoleGrant { id: string; userId: string; @@ -79,3 +150,21 @@ export interface InstanceUserRoleGrant { createdAt: Date; updatedAt: Date; } + +export interface AdminUserDirectoryEntry extends AccessUserProfile { + isInstanceAdmin: boolean; + activeCompanyMembershipCount: number; +} + +export interface UserCompanyAccessEntry extends CompanyMembership { + principalType: "user"; + companyName: string | null; + companyStatus: CompanyStatus | null; +} + +export interface UserCompanyAccessResponse { + user: (AccessUserProfile & { + isInstanceAdmin: boolean; + }) | null; + companyAccess: UserCompanyAccessEntry[]; +} diff --git a/packages/shared/src/types/activity.ts b/packages/shared/src/types/activity.ts index d0232e0..3bc098b 100644 --- a/packages/shared/src/types/activity.ts +++ b/packages/shared/src/types/activity.ts @@ -1,7 +1,7 @@ export interface ActivityEvent { id: string; companyId: string; - actorType: "agent" | "user" | "system"; + actorType: "agent" | "user" | "system" | "plugin"; actorId: string; action: string; entityType: string; diff --git a/packages/shared/src/types/budget.ts b/packages/shared/src/types/budget.ts index cf43f40..6907796 100644 --- a/packages/shared/src/types/budget.ts +++ b/packages/shared/src/types/budget.ts @@ -20,11 +20,6 @@ export interface BudgetPolicy { hardStopEnabled: boolean; notifyEnabled: boolean; isActive: boolean; - circuitBreaker?: { - failureThreshold: number; - windowMs: number; - autoPause: boolean; - } | null; createdByUserId: string | null; updatedByUserId: string | null; createdAt: Date; diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index ab04be4..c9b8b87 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -23,10 +23,10 @@ export interface CompanyPortabilityEnvInput { export type CompanyPortabilityFileEntry = | string | { - encoding: "base64"; - data: string; - contentType?: string | null; - }; + encoding: "base64"; + data: string; + contentType?: string | null; + }; export interface CompanyPortabilityCompanyManifestEntry { path: string; @@ -198,24 +198,24 @@ export interface CompanyPortabilityExportPreviewResult { export type CompanyPortabilitySource = | { - type: "inline"; - rootPath?: string | null; - files: Record; - } + type: "inline"; + rootPath?: string | null; + files: Record; + } | { - type: "github"; - url: string; - }; + type: "github"; + url: string; + }; export type CompanyPortabilityImportTarget = | { - mode: "new_company"; - newCompanyName?: string | null; - } + mode: "new_company"; + newCompanyName?: string | null; + } | { - mode: "existing_company"; - companyId: string; - }; + mode: "existing_company"; + companyId: string; + }; export type CompanyPortabilityAgentSelection = "all" | string[]; diff --git a/packages/shared/src/types/dashboard.ts b/packages/shared/src/types/dashboard.ts index 0127a4f..e4225f4 100644 --- a/packages/shared/src/types/dashboard.ts +++ b/packages/shared/src/types/dashboard.ts @@ -1,3 +1,11 @@ +export interface DashboardRunActivityDay { + date: string; + succeeded: number; + failed: number; + other: number; + total: number; +} + export interface DashboardSummary { companyId: string; agents: { @@ -24,4 +32,5 @@ export interface DashboardSummary { pausedAgents: number; pausedProjects: number; }; + runActivity: DashboardRunActivityDay[]; } diff --git a/packages/shared/src/types/feedback.ts b/packages/shared/src/types/feedback.ts index a217b00..67550e3 100644 --- a/packages/shared/src/types/feedback.ts +++ b/packages/shared/src/types/feedback.ts @@ -82,21 +82,21 @@ export interface FeedbackTraceBundleFile { byteLength: number; sha256: string; source: - | "taskcore_run" - | "taskcore_run_events" - | "taskcore_run_log" - | "codex_session" - | "claude_stream_json" - | "claude_project_session" - | "claude_project_artifact" - | "claude_debug_log" - | "claude_task_metadata" - | "opencode_session" - | "opencode_session_diff" - | "opencode_message" - | "opencode_message_part" - | "opencode_project" - | "opencode_todo"; + | "taskcore_run" + | "taskcore_run_events" + | "taskcore_run_log" + | "codex_session" + | "claude_stream_json" + | "claude_project_session" + | "claude_project_artifact" + | "claude_debug_log" + | "claude_task_metadata" + | "opencode_session" + | "opencode_session_diff" + | "opencode_message" + | "opencode_message_part" + | "opencode_project" + | "opencode_todo"; contents: string; } diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts index ba24ea5..98073b5 100644 --- a/packages/shared/src/types/heartbeat.ts +++ b/packages/shared/src/types/heartbeat.ts @@ -3,13 +3,11 @@ import type { AgentStatus, HeartbeatInvocationSource, HeartbeatRunStatus, + RunLivenessState, WakeupTriggerDetail, WakeupRequestStatus, - WakeReason, } from "../constants.js"; -export type { WakeReason }; - export interface HeartbeatRun { id: string; companyId: string; @@ -41,6 +39,15 @@ export interface HeartbeatRun { processStartedAt: Date | null; retryOfRunId: string | null; processLossRetryCount: number; + scheduledRetryAt?: Date | null; + scheduledRetryAttempt?: number; + scheduledRetryReason?: string | null; + retryExhaustedReason?: string | null; + livenessState: RunLivenessState | null; + livenessReason: string | null; + continuationAttempt: number; + lastUsefulActionAt: Date | null; + nextAction: string | null; contextSnapshot: Record | null; createdAt: Date; updatedAt: Date; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 62129cd..881cdc4 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -63,6 +63,7 @@ export type { AssetImage } from "./asset.js"; export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js"; export type { ExecutionWorkspace, + ExecutionWorkspaceSummary, ExecutionWorkspaceConfig, ExecutionWorkspaceCloseAction, ExecutionWorkspaceCloseActionKind, @@ -101,6 +102,9 @@ export type { export type { Issue, IssueAssigneeAdapterOverrides, + IssueReferenceSource, + IssueRelatedWorkItem, + IssueRelatedWorkSummary, IssueRelation, IssueRelationIssueSummary, IssueExecutionPolicy, @@ -110,6 +114,28 @@ export type { IssueExecutionStagePrincipal, IssueExecutionDecision, IssueComment, + IssueThreadInteractionActorFields, + SuggestedTaskDraft, + SuggestTasksPayload, + SuggestTasksResultCreatedTask, + SuggestTasksResult, + AskUserQuestionsQuestionOption, + AskUserQuestionsQuestion, + AskUserQuestionsPayload, + AskUserQuestionsAnswer, + AskUserQuestionsResult, + RequestConfirmationIssueDocumentTarget, + RequestConfirmationCustomTarget, + RequestConfirmationTarget, + RequestConfirmationPayload, + RequestConfirmationResult, + IssueThreadInteractionBase, + SuggestTasksInteraction, + AskUserQuestionsInteraction, + RequestConfirmationInteraction, + IssueThreadInteraction, + IssueThreadInteractionPayload, + IssueThreadInteractionResult, IssueDocument, IssueDocumentSummary, DocumentRevision, @@ -119,7 +145,6 @@ export type { IssueAncestorProject, IssueAncestorGoal, IssueAttachment, - IssueArtifact, IssueLabel, } from "./issue.js"; export type { Goal } from "./goal.js"; @@ -164,21 +189,41 @@ export type { AgentRuntimeState, AgentTaskSession, AgentWakeupRequest, - WakeReason, InstanceSchedulerHeartbeatAgent, } from "./heartbeat.js"; export type { LiveEvent } from "./live.js"; -export type { DashboardSummary } from "./dashboard.js"; +export type { DashboardRunActivityDay, DashboardSummary } from "./dashboard.js"; export type { ActivityEvent } from "./activity.js"; +export type { + UserProfileActivitySummary, + UserProfileAgentUsage, + UserProfileDailyPoint, + UserProfileIdentity, + UserProfileIssueSummary, + UserProfileProviderUsage, + UserProfileResponse, + UserProfileWindowStats, +} from "./user-profile.js"; export type { SidebarBadges } from "./sidebar-badges.js"; export type { SidebarOrderPreference } from "./sidebar-preferences.js"; export type { InboxDismissal } from "./inbox-dismissal.js"; export type { + AccessUserProfile, + CompanyMemberRecord, + CompanyMembersResponse, + ArchiveCompanyMemberResponse, CompanyMembership, + CompanyInviteListResponse, + CompanyInviteRecord, PrincipalPermissionGrant, Invite, JoinRequest, + JoinRequestInviteSummary, + JoinRequestRecord, InstanceUserRoleGrant, + AdminUserDirectoryEntry, + UserCompanyAccessEntry, + UserCompanyAccessResponse, } from "./access.js"; export type { QuotaWindow, ProviderQuotaResult } from "./quota.js"; export type { @@ -224,8 +269,13 @@ export type { PluginLauncherDeclaration, PluginMinimumHostVersion, PluginUiDeclaration, + PluginDatabaseDeclaration, + PluginApiRouteCompanyResolution, + PluginApiRouteDeclaration, TaskcorePluginManifestV1, PluginRecord, + PluginDatabaseNamespaceRecord, + PluginMigrationRecord, PluginStateRecord, PluginConfig, PluginEntityRecord, @@ -233,4 +283,8 @@ export type { PluginJobRecord, PluginJobRunRecord, PluginWebhookDeliveryRecord, + PluginDatabaseCoreReadTable, + PluginDatabaseMigrationStatus, + PluginDatabaseNamespaceMode, + PluginDatabaseNamespaceStatus, } from "./plugin.js"; diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index b764bd5..f105ab5 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1,10 +1,14 @@ import type { IssueExecutionDecisionOutcome, IssueExecutionPolicyMode, + IssueReferenceSourceKind, IssueExecutionStageType, IssueExecutionStateStatus, IssueOriginKind, IssuePriority, + IssueThreadInteractionContinuationPolicy, + IssueThreadInteractionKind, + IssueThreadInteractionStatus, IssueStatus, } from "../constants.js"; import type { Goal } from "./goal.js"; @@ -123,6 +127,24 @@ export interface IssueRelation { relatedIssue: IssueRelationIssueSummary; } +export interface IssueReferenceSource { + kind: IssueReferenceSourceKind; + sourceRecordId: string | null; + label: string; + matchedText: string | null; +} + +export interface IssueRelatedWorkItem { + issue: IssueRelationIssueSummary; + mentionCount: number; + sources: IssueReferenceSource[]; +} + +export interface IssueRelatedWorkSummary { + outbound: IssueRelatedWorkItem[]; + inbound: IssueRelatedWorkItem[]; +} + export interface IssueExecutionStagePrincipal { type: "agent" | "user"; agentId?: string | null; @@ -198,6 +220,7 @@ export interface Issue { originKind?: IssueOriginKind; originId?: string | null; originRunId?: string | null; + originFingerprint?: string | null; requestDepth: number; billingCode: string | null; assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null; @@ -214,6 +237,8 @@ export interface Issue { labels?: IssueLabel[]; blockedBy?: IssueRelationIssueSummary[]; blocks?: IssueRelationIssueSummary[]; + relatedWork?: IssueRelatedWorkSummary; + referencedIssueIdentifiers?: string[]; planDocument?: IssueDocument | null; documentSummaries?: IssueDocumentSummary[]; legacyPlanDocument?: LegacyPlanDocument | null; @@ -241,6 +266,180 @@ export interface IssueComment { updatedAt: Date; } +export interface IssueThreadInteractionActorFields { + createdByAgentId?: string | null; + createdByUserId?: string | null; + resolvedByAgentId?: string | null; + resolvedByUserId?: string | null; +} + +export interface SuggestedTaskDraft { + clientKey: string; + parentClientKey?: string | null; + parentId?: string | null; + title: string; + description?: string | null; + priority?: IssuePriority | null; + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + projectId?: string | null; + goalId?: string | null; + billingCode?: string | null; + labels?: string[]; + hiddenInPreview?: boolean; +} + +export interface SuggestTasksPayload { + version: 1; + defaultParentId?: string | null; + tasks: SuggestedTaskDraft[]; +} + +export interface SuggestTasksResultCreatedTask { + clientKey: string; + issueId: string; + identifier?: string | null; + title?: string | null; + parentIssueId?: string | null; + parentIdentifier?: string | null; +} + +export interface SuggestTasksResult { + version: 1; + createdTasks?: SuggestTasksResultCreatedTask[]; + skippedClientKeys?: string[]; + rejectionReason?: string | null; +} + +export interface AskUserQuestionsQuestionOption { + id: string; + label: string; + description?: string | null; +} + +export interface AskUserQuestionsQuestion { + id: string; + prompt: string; + helpText?: string | null; + selectionMode: "single" | "multi"; + required?: boolean; + options: AskUserQuestionsQuestionOption[]; +} + +export interface AskUserQuestionsPayload { + version: 1; + title?: string | null; + submitLabel?: string | null; + questions: AskUserQuestionsQuestion[]; +} + +export interface AskUserQuestionsAnswer { + questionId: string; + optionIds: string[]; +} + +export interface AskUserQuestionsResult { + version: 1; + answers: AskUserQuestionsAnswer[]; + summaryMarkdown?: string | null; +} + +export interface RequestConfirmationIssueDocumentTarget { + type: "issue_document"; + issueId?: string | null; + documentId?: string | null; + key: string; + revisionId: string; + revisionNumber?: number | null; + label?: string | null; + href?: string | null; +} + +export interface RequestConfirmationCustomTarget { + type: "custom"; + key: string; + revisionId?: string | null; + revisionNumber?: number | null; + label?: string | null; + href?: string | null; +} + +export type RequestConfirmationTarget = + | RequestConfirmationIssueDocumentTarget + | RequestConfirmationCustomTarget; + +export interface RequestConfirmationPayload { + version: 1; + prompt: string; + acceptLabel?: string | null; + rejectLabel?: string | null; + rejectRequiresReason?: boolean; + rejectReasonLabel?: string | null; + allowDeclineReason?: boolean; + declineReasonPlaceholder?: string | null; + detailsMarkdown?: string | null; + supersedeOnUserComment?: boolean; + target?: RequestConfirmationTarget | null; +} + +export interface RequestConfirmationResult { + version: 1; + outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target"; + reason?: string | null; + commentId?: string | null; + staleTarget?: RequestConfirmationTarget | null; +} + +export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields { + id: string; + companyId: string; + issueId: string; + kind: IssueThreadInteractionKind; + idempotencyKey?: string | null; + sourceCommentId?: string | null; + sourceRunId?: string | null; + title?: string | null; + summary?: string | null; + status: IssueThreadInteractionStatus; + continuationPolicy: IssueThreadInteractionContinuationPolicy; + createdAt: Date | string; + updatedAt: Date | string; + resolvedAt?: Date | string | null; +} + +export interface SuggestTasksInteraction extends IssueThreadInteractionBase { + kind: "suggest_tasks"; + payload: SuggestTasksPayload; + result?: SuggestTasksResult | null; +} + +export interface AskUserQuestionsInteraction extends IssueThreadInteractionBase { + kind: "ask_user_questions"; + payload: AskUserQuestionsPayload; + result?: AskUserQuestionsResult | null; +} + +export interface RequestConfirmationInteraction extends IssueThreadInteractionBase { + kind: "request_confirmation"; + payload: RequestConfirmationPayload; + result?: RequestConfirmationResult | null; +} + +export type IssueThreadInteraction = + | SuggestTasksInteraction + | AskUserQuestionsInteraction + | RequestConfirmationInteraction; + +export type IssueThreadInteractionPayload = + | SuggestTasksPayload + | AskUserQuestionsPayload + | RequestConfirmationPayload; + +export type IssueThreadInteractionResult = + | SuggestTasksResult + | AskUserQuestionsResult + | RequestConfirmationResult; + export interface IssueAttachment { id: string; companyId: string; @@ -259,19 +458,3 @@ export interface IssueAttachment { updatedAt: Date; contentPath: string; } - -export interface IssueArtifact { - id: string; - companyId: string; - issueId: string; - artifactId: string; - version: number; - title: string; - mimeType: string; - sizeBytes: number; - sha256: string; - createdByAgentId: string | null; - createdByUserId: string | null; - createdAt: Date; - updatedAt: Date; -} diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 6a11d2a..872e4dc 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -9,6 +9,13 @@ import type { PluginLauncherAction, PluginLauncherBounds, PluginLauncherRenderEnvironment, + PluginApiRouteAuthMode, + PluginApiRouteCheckoutPolicy, + PluginApiRouteMethod, + PluginDatabaseCoreReadTable, + PluginDatabaseMigrationStatus, + PluginDatabaseNamespaceMode, + PluginDatabaseNamespaceStatus, } from "../constants.js"; // --------------------------------------------------------------------------- @@ -21,6 +28,13 @@ import type { */ export type JsonSchema = Record; +export type { + PluginDatabaseCoreReadTable, + PluginDatabaseMigrationStatus, + PluginDatabaseNamespaceMode, + PluginDatabaseNamespaceStatus, +} from "../constants.js"; + // --------------------------------------------------------------------------- // Manifest sub-types — nested declarations within TaskcorePluginManifestV1 // --------------------------------------------------------------------------- @@ -190,6 +204,44 @@ export interface PluginUiDeclaration { launchers?: PluginLauncherDeclaration[]; } +/** + * Declares restricted database access for trusted orchestration plugins. + * + * The host derives the final namespace from the plugin key and optional slug, + * applies SQL migrations before worker startup, and gates runtime SQL through + * the `database.namespace.*` capabilities. + */ +export interface PluginDatabaseDeclaration { + /** Optional stable human-readable slug included in the host-derived namespace. */ + namespaceSlug?: string; + /** SQL migration directory relative to the plugin package root. */ + migrationsDir: string; + /** Public core tables this plugin may read or join at runtime. */ + coreReadTables?: PluginDatabaseCoreReadTable[]; +} + +export type PluginApiRouteCompanyResolution = + | { from: "body"; key: string } + | { from: "query"; key: string } + | { from: "issue"; param: string }; + +export interface PluginApiRouteDeclaration { + /** Stable plugin-defined route key passed to the worker. */ + routeKey: string; + /** HTTP method accepted by this route. */ + method: PluginApiRouteMethod; + /** Plugin-local path under `/api/plugins/:pluginId/api`, e.g. `/issues/:issueId/smoke`. */ + path: string; + /** Actor class allowed to call the route. */ + auth: PluginApiRouteAuthMode; + /** Capability required to expose the route. Currently `api.routes.register`. */ + capability: "api.routes.register"; + /** Optional checkout policy enforced by the host before worker dispatch. */ + checkoutPolicy?: PluginApiRouteCheckoutPolicy; + /** How the host resolves company access for this route. */ + companyResolution?: PluginApiRouteCompanyResolution; +} + // --------------------------------------------------------------------------- // Plugin Manifest V1 // --------------------------------------------------------------------------- @@ -240,6 +292,10 @@ export interface TaskcorePluginManifestV1 { webhooks?: PluginWebhookDeclaration[]; /** Agent tools this plugin contributes. Requires `agent.tools.register` capability. */ tools?: PluginToolDeclaration[]; + /** Restricted plugin-owned database namespace declaration. */ + database?: PluginDatabaseDeclaration; + /** Scoped JSON API routes mounted under `/api/plugins/:pluginId/api/*`. */ + apiRoutes?: PluginApiRouteDeclaration[]; /** * Legacy top-level launcher declarations. * Prefer `ui.launchers` for new manifests. @@ -286,6 +342,31 @@ export interface PluginRecord { updatedAt: Date; } +export interface PluginDatabaseNamespaceRecord { + id: string; + pluginId: string; + pluginKey: string; + namespaceName: string; + namespaceMode: PluginDatabaseNamespaceMode; + status: PluginDatabaseNamespaceStatus; + createdAt: Date; + updatedAt: Date; +} + +export interface PluginMigrationRecord { + id: string; + pluginId: string; + pluginKey: string; + namespaceName: string; + migrationKey: string; + checksum: string; + pluginVersion: string; + status: PluginDatabaseMigrationStatus; + startedAt: Date; + appliedAt: Date | null; + errorMessage: string | null; +} + // --------------------------------------------------------------------------- // Plugin State – represents a row in the `plugin_state` table // --------------------------------------------------------------------------- diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 6bd7da1..2aa5036 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -1,10 +1,10 @@ import type { PauseReason, ProjectStatus } from "../constants.js"; -import type { AgentEnvConfig } from "./secrets.js"; import type { ProjectExecutionWorkspacePolicy, ProjectWorkspaceRuntimeConfig, WorkspaceRuntimeService, } from "./workspace-runtime.js"; +import type { AgentEnvConfig } from "./secrets.js"; export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path"; export type ProjectWorkspaceVisibility = "default" | "advanced"; diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index 4efdc74..aea256b 100644 --- a/packages/shared/src/types/routine.ts +++ b/packages/shared/src/types/routine.ts @@ -95,6 +95,7 @@ export interface RoutineRun { triggeredAt: Date; idempotencyKey: string | null; triggerPayload: Record | null; + dispatchFingerprint: string | null; linkedIssueId: string | null; coalescedIntoRunId: string | null; failureReason: string | null; @@ -129,7 +130,7 @@ export interface RoutineExecutionIssueOrigin { } export interface RoutineListItem extends Routine { - triggers: Pick[]; + triggers: Pick[]; lastRun: RoutineRunSummary | null; activeIssue: RoutineIssueSummary | null; } diff --git a/packages/shared/src/types/user-profile.ts b/packages/shared/src/types/user-profile.ts new file mode 100644 index 0000000..2ed483d --- /dev/null +++ b/packages/shared/src/types/user-profile.ts @@ -0,0 +1,88 @@ +import type { IssuePriority, IssueStatus } from "../constants.js"; + +export interface UserProfileIdentity { + id: string; + slug: string; + name: string | null; + email: string | null; + image: string | null; + membershipRole: string | null; + membershipStatus: string; + joinedAt: Date; +} + +export interface UserProfileWindowStats { + key: "last7" | "last30" | "all"; + label: string; + touchedIssues: number; + createdIssues: number; + completedIssues: number; + assignedOpenIssues: number; + commentCount: number; + activityCount: number; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + costEventCount: number; +} + +export interface UserProfileDailyPoint { + date: string; + activityCount: number; + completedIssues: number; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +} + +export interface UserProfileIssueSummary { + id: string; + identifier: string | null; + title: string; + status: IssueStatus; + priority: IssuePriority; + assigneeAgentId: string | null; + assigneeUserId: string | null; + updatedAt: Date; + completedAt: Date | null; +} + +export interface UserProfileActivitySummary { + id: string; + action: string; + entityType: string; + entityId: string; + details: Record | null; + createdAt: Date; +} + +export interface UserProfileAgentUsage { + agentId: string; + agentName: string | null; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +} + +export interface UserProfileProviderUsage { + provider: string; + biller: string; + model: string; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +} + +export interface UserProfileResponse { + user: UserProfileIdentity; + stats: UserProfileWindowStats[]; + daily: UserProfileDailyPoint[]; + recentIssues: UserProfileIssueSummary[]; + recentActivity: UserProfileActivitySummary[]; + topAgents: UserProfileAgentUsage[]; + topProviders: UserProfileProviderUsage[]; +} diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 1113479..40cd6fe 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -45,7 +45,7 @@ export type ExecutionWorkspaceCloseActionKind = | "git_branch_delete" | "remove_local_directory"; -export type WorkspaceRuntimeDesiredState = "running" | "stopped"; +export type WorkspaceRuntimeDesiredState = "running" | "stopped" | "manual"; export type WorkspaceRuntimeServiceStateMap = Record; export type WorkspaceCommandKind = "service" | "job"; @@ -161,6 +161,13 @@ export interface IssueExecutionWorkspaceSettings { workspaceRuntime?: Record | null; } +export interface ExecutionWorkspaceSummary { + id: string; + name: string; + mode: Exclude | "adapter_managed" | "cloud_sandbox"; + projectWorkspaceId: string | null; +} + export interface ExecutionWorkspace { id: string; companyId: string; diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index a77b141..14fe96d 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -1,5 +1,7 @@ import { z } from "zod"; import { + AGENT_ADAPTER_TYPES, + HUMAN_COMPANY_MEMBERSHIP_ROLES, INVITE_JOIN_TYPES, JOIN_REQUEST_STATUSES, JOIN_REQUEST_TYPES, @@ -9,6 +11,7 @@ import { optionalAgentAdapterTypeSchema } from "../adapter-type.js"; export const createCompanyInviteSchema = z.object({ allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"), + humanRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(), defaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(), agentMessage: z.string().max(4000).optional().nullable(), }); @@ -46,6 +49,14 @@ export const listJoinRequestsQuerySchema = z.object({ export type ListJoinRequestsQuery = z.infer; +export const listCompanyInvitesQuerySchema = z.object({ + state: z.enum(["active", "revoked", "accepted", "expired"]).optional(), + limit: z.coerce.number().int().min(1).max(100).optional().default(20), + offset: z.coerce.number().int().min(0).optional().default(0), +}); + +export type ListCompanyInvitesQuery = z.infer; + export const claimJoinRequestApiKeySchema = z.object({ claimSecret: z.string().min(16).max(256), }); @@ -85,8 +96,104 @@ export const updateMemberPermissionsSchema = z.object({ export type UpdateMemberPermissions = z.infer; +const editableMembershipStatuses = ["pending", "active", "suspended"] as const; + +export const updateCompanyMemberSchema = z.object({ + membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(), + status: z.enum(editableMembershipStatuses).optional(), +}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, { + message: "membershipRole or status is required", +}); + +export type UpdateCompanyMember = z.infer; + +export const updateCompanyMemberWithPermissionsSchema = z.object({ + membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(), + status: z.enum(editableMembershipStatuses).optional(), + grants: updateMemberPermissionsSchema.shape.grants.default([]), +}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, { + message: "membershipRole or status is required", +}); + +export type UpdateCompanyMemberWithPermissions = z.infer; + +export const archiveCompanyMemberSchema = z.object({ + reassignment: z + .object({ + assigneeAgentId: z.string().uuid().optional().nullable(), + assigneeUserId: z.string().uuid().optional().nullable(), + }) + .optional() + .nullable(), +}).superRefine((value, ctx) => { + if (value.reassignment?.assigneeAgentId && value.reassignment.assigneeUserId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Choose either an agent or user reassignment target", + path: ["reassignment"], + }); + } +}); + +export type ArchiveCompanyMember = z.infer; + export const updateUserCompanyAccessSchema = z.object({ companyIds: z.array(z.string().uuid()).default([]), }); export type UpdateUserCompanyAccess = z.infer; + +export const searchAdminUsersQuerySchema = z.object({ + query: z.string().trim().max(120).optional().default(""), +}); + +export type SearchAdminUsersQuery = z.infer; + +const profileImageAssetPathPattern = /^\/api\/assets\/[^/?#]+\/content(?:\?[^#]*)?(?:#.*)?$/; + +function isValidProfileImage(value: string): boolean { + if (profileImageAssetPathPattern.test(value)) return true; + + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:"; + } catch { + return false; + } +} + +const profileImageSchema = z + .string() + .trim() + .min(1) + .max(4000) + .refine(isValidProfileImage, { message: "Invalid profile image URL" }); + +export const currentUserProfileSchema = z.object({ + id: z.string().min(1), + email: z.string().email().nullable(), + name: z.string().min(1).max(120).nullable(), + image: profileImageSchema.nullable(), +}); + +export type CurrentUserProfile = z.infer; + +export const authSessionSchema = z.object({ + session: z.object({ + id: z.string().min(1), + userId: z.string().min(1), + }), + user: currentUserProfileSchema, +}); + +export type AuthSession = z.infer; + +export const updateCurrentUserProfileSchema = z.object({ + name: z.string().trim().min(1).max(120), + image: z + .union([profileImageSchema, z.literal(""), z.null()]) + .optional() + .transform((value) => value === "" ? null : value), +}); + +export type UpdateCurrentUserProfile = z.infer; diff --git a/packages/shared/src/validators/approval.ts b/packages/shared/src/validators/approval.ts index d6a6cf0..ae6b2eb 100644 --- a/packages/shared/src/validators/approval.ts +++ b/packages/shared/src/validators/approval.ts @@ -12,14 +12,12 @@ export type CreateApproval = z.infer; export const resolveApprovalSchema = z.object({ decisionNote: z.string().optional().nullable(), - decidedByUserId: z.string().optional().default("board"), }); export type ResolveApproval = z.infer; export const requestApprovalRevisionSchema = z.object({ decisionNote: z.string().optional().nullable(), - decidedByUserId: z.string().optional().default("board"), }); export type RequestApprovalRevision = z.infer; diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index fad6382..4a25ba9 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -13,8 +13,8 @@ export const executionWorkspaceConfigSchema = z.object({ teardownCommand: z.string().optional().nullable(), cleanupCommand: z.string().optional().nullable(), workspaceRuntime: z.record(z.unknown()).optional().nullable(), - desiredState: z.enum(["running", "stopped"]).optional().nullable(), - serviceStates: z.record(z.enum(["running", "stopped"])).optional().nullable(), + desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(), + serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(), }).strict(); export const workspaceRuntimeControlTargetSchema = z.object({ @@ -99,7 +99,6 @@ export const workspaceRuntimeServiceSchema = z.object({ createdAt: z.coerce.date(), updatedAt: z.coerce.date(), }).strict(); - export const executionWorkspaceCloseReadinessSchema = z.object({ workspaceId: z.string().uuid(), state: executionWorkspaceCloseReadinessStateSchema, diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 4f8e659..b7ae92f 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -134,6 +134,7 @@ export { export { createIssueSchema, + createChildIssueSchema, createIssueLabelSchema, updateIssueSchema, issueExecutionPolicySchema, @@ -141,6 +142,27 @@ export { issueExecutionWorkspaceSettingsSchema, checkoutIssueSchema, addIssueCommentSchema, + issueThreadInteractionStatusSchema, + issueThreadInteractionKindSchema, + issueThreadInteractionContinuationPolicySchema, + suggestedTaskDraftSchema, + suggestTasksPayloadSchema, + suggestTasksResultCreatedTaskSchema, + suggestTasksResultSchema, + askUserQuestionsQuestionOptionSchema, + askUserQuestionsQuestionSchema, + askUserQuestionsPayloadSchema, + askUserQuestionsAnswerSchema, + askUserQuestionsResultSchema, + requestConfirmationIssueDocumentTargetSchema, + requestConfirmationCustomTargetSchema, + requestConfirmationTargetSchema, + requestConfirmationPayloadSchema, + requestConfirmationResultSchema, + createIssueThreadInteractionSchema, + acceptIssueThreadInteractionSchema, + rejectIssueThreadInteractionSchema, + respondIssueThreadInteractionSchema, linkIssueApprovalSchema, createIssueAttachmentMetadataSchema, issueDocumentFormatSchema, @@ -148,11 +170,16 @@ export { upsertIssueDocumentSchema, restoreIssueDocumentRevisionSchema, type CreateIssue, + type CreateChildIssue, type CreateIssueLabel, type UpdateIssue, type IssueExecutionWorkspaceSettings, type CheckoutIssue, type AddIssueComment, + type CreateIssueThreadInteraction, + type AcceptIssueThreadInteraction, + type RejectIssueThreadInteraction, + type RespondIssueThreadInteraction, type LinkIssueApproval, type CreateIssueAttachmentMetadata, type IssueDocumentFormat, @@ -253,22 +280,38 @@ export { createCompanyInviteSchema, createOpenClawInvitePromptSchema, acceptInviteSchema, + listCompanyInvitesQuerySchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, boardCliAuthAccessLevelSchema, createCliAuthChallengeSchema, resolveCliAuthChallengeSchema, + currentUserProfileSchema, + authSessionSchema, + updateCurrentUserProfileSchema, + updateCompanyMemberSchema, + updateCompanyMemberWithPermissionsSchema, + archiveCompanyMemberSchema, updateMemberPermissionsSchema, + searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, type CreateCompanyInvite, type CreateOpenClawInvitePrompt, type AcceptInvite, + type ListCompanyInvitesQuery, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, type BoardCliAuthAccessLevel, type CreateCliAuthChallenge, type ResolveCliAuthChallenge, + type CurrentUserProfile, + type AuthSession, + type UpdateCurrentUserProfile, + type UpdateCompanyMember, + type UpdateCompanyMemberWithPermissions, + type ArchiveCompanyMember, type UpdateMemberPermissions, + type SearchAdminUsersQuery, type UpdateUserCompanyAccess, } from "./access.js"; @@ -281,6 +324,8 @@ export { pluginLauncherActionDeclarationSchema, pluginLauncherRenderDeclarationSchema, pluginLauncherDeclarationSchema, + pluginDatabaseDeclarationSchema, + pluginApiRouteDeclarationSchema, pluginManifestV1Schema, installPluginSchema, upsertPluginConfigSchema, @@ -297,6 +342,8 @@ export { type PluginLauncherActionDeclarationInput, type PluginLauncherRenderDeclarationInput, type PluginLauncherDeclarationInput, + type PluginDatabaseDeclarationInput, + type PluginApiRouteDeclarationInput, type PluginManifestV1Input, type InstallPlugin, type UpsertPluginConfig, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index d1d1e33..6fed23d 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -6,6 +6,9 @@ import { ISSUE_EXECUTION_STATE_STATUSES, ISSUE_PRIORITIES, ISSUE_STATUSES, + ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, + ISSUE_THREAD_INTERACTION_KINDS, + ISSUE_THREAD_INTERACTION_STATUSES, } from "../constants.js"; export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [ @@ -138,6 +141,18 @@ export const createIssueSchema = z.object({ export type CreateIssue = z.infer; +export const createChildIssueSchema = createIssueSchema + .omit({ + parentId: true, + inheritExecutionWorkspaceFromIssueId: true, + }) + .extend({ + acceptanceCriteria: z.array(z.string().trim().min(1).max(500)).max(20).optional(), + blockParentUntilDone: z.boolean().optional().default(false), + }); + +export type CreateChildIssue = z.infer; + export const createIssueLabelSchema = z.object({ name: z.string().trim().min(1).max(48), color: z.string().regex(/^#(?:[0-9a-fA-F]{6})$/, "Color must be a 6-digit hex value"), @@ -171,6 +186,254 @@ export const addIssueCommentSchema = z.object({ export type AddIssueComment = z.infer; +export const issueThreadInteractionStatusSchema = z.enum(ISSUE_THREAD_INTERACTION_STATUSES); +export const issueThreadInteractionKindSchema = z.enum(ISSUE_THREAD_INTERACTION_KINDS); +export const issueThreadInteractionContinuationPolicySchema = z.enum( + ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, +); + +export const issueDocumentKeySchema = z + .string() + .trim() + .min(1) + .max(64) + .regex(/^[a-z0-9][a-z0-9_-]*$/, "Document key must be lowercase letters, numbers, _ or -"); + +export const suggestedTaskDraftSchema = z.object({ + clientKey: z.string().trim().min(1).max(120), + parentClientKey: z.string().trim().min(1).max(120).nullable().optional(), + parentId: z.string().uuid().nullable().optional(), + title: z.string().trim().min(1).max(240), + description: z.string().trim().max(20000).nullable().optional(), + priority: z.enum(ISSUE_PRIORITIES).nullable().optional(), + assigneeAgentId: z.string().uuid().nullable().optional(), + assigneeUserId: z.string().trim().min(1).nullable().optional(), + projectId: z.string().uuid().nullable().optional(), + goalId: z.string().uuid().nullable().optional(), + billingCode: z.string().trim().max(120).nullable().optional(), + labels: z.array(z.string().trim().min(1).max(48)).max(20).optional(), + hiddenInPreview: z.boolean().optional(), +}).superRefine((value, ctx) => { + if (value.assigneeAgentId && value.assigneeUserId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Suggested tasks can only target one assignee", + path: ["assigneeAgentId"], + }); + } +}); + +export const suggestTasksPayloadSchema = z.object({ + version: z.literal(1), + defaultParentId: z.string().uuid().nullable().optional(), + tasks: z.array(suggestedTaskDraftSchema).min(1).max(50), +}).superRefine((value, ctx) => { + const seenClientKeys = new Set(); + for (const [index, task] of value.tasks.entries()) { + if (seenClientKeys.has(task.clientKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "clientKey must be unique within one interaction", + path: ["tasks", index, "clientKey"], + }); + continue; + } + seenClientKeys.add(task.clientKey); + } +}); + +export const suggestTasksResultCreatedTaskSchema = z.object({ + clientKey: z.string().trim().min(1).max(120), + issueId: z.string().uuid(), + identifier: z.string().trim().min(1).nullable().optional(), + title: z.string().trim().min(1).nullable().optional(), + parentIssueId: z.string().uuid().nullable().optional(), + parentIdentifier: z.string().trim().min(1).nullable().optional(), +}); + +export const suggestTasksResultSchema = z.object({ + version: z.literal(1), + createdTasks: z.array(suggestTasksResultCreatedTaskSchema).max(50).optional(), + skippedClientKeys: z.array(z.string().trim().min(1).max(120)).max(50).optional(), + rejectionReason: z.string().trim().max(4000).nullable().optional(), +}); + +export const askUserQuestionsQuestionOptionSchema = z.object({ + id: z.string().trim().min(1).max(120), + label: z.string().trim().min(1).max(120), + description: z.string().trim().max(500).nullable().optional(), +}); + +export const askUserQuestionsQuestionSchema = z.object({ + id: z.string().trim().min(1).max(120), + prompt: z.string().trim().min(1).max(500), + helpText: z.string().trim().max(1000).nullable().optional(), + selectionMode: z.enum(["single", "multi"]), + required: z.boolean().optional(), + options: z.array(askUserQuestionsQuestionOptionSchema).min(1).max(10), +}); + +export const askUserQuestionsPayloadSchema = z.object({ + version: z.literal(1), + title: z.string().trim().max(240).nullable().optional(), + submitLabel: z.string().trim().max(120).nullable().optional(), + questions: z.array(askUserQuestionsQuestionSchema).min(1).max(10), +}).superRefine((value, ctx) => { + const seenQuestionIds = new Set(); + for (const [questionIndex, question] of value.questions.entries()) { + if (seenQuestionIds.has(question.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Question ids must be unique within one interaction", + path: ["questions", questionIndex, "id"], + }); + } + seenQuestionIds.add(question.id); + + const seenOptionIds = new Set(); + for (const [optionIndex, option] of question.options.entries()) { + if (seenOptionIds.has(option.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Option ids must be unique within one question", + path: ["questions", questionIndex, "options", optionIndex, "id"], + }); + } + seenOptionIds.add(option.id); + } + } +}); + +export const askUserQuestionsAnswerSchema = z.object({ + questionId: z.string().trim().min(1).max(120), + optionIds: z.array(z.string().trim().min(1).max(120)).max(20), +}); + +export const askUserQuestionsResultSchema = z.object({ + version: z.literal(1), + answers: z.array(askUserQuestionsAnswerSchema).max(20), + summaryMarkdown: z.string().max(20000).nullable().optional(), +}); + +const requestConfirmationHrefSchema = z.string().trim().min(1).max(2000).refine((value) => { + const lower = value.toLowerCase(); + return !lower.startsWith("javascript:") + && !lower.startsWith("data:") + && !value.startsWith("//"); +}, "href must not use javascript:, data:, or protocol-relative URLs"); + +const requestConfirmationTargetBaseSchema = z.object({ + label: z.string().trim().min(1).max(120).nullable().optional(), + href: requestConfirmationHrefSchema.nullable().optional(), +}); + +export const requestConfirmationIssueDocumentTargetSchema = requestConfirmationTargetBaseSchema.extend({ + type: z.literal("issue_document"), + issueId: z.string().uuid().nullable().optional(), + documentId: z.string().uuid().nullable().optional(), + key: issueDocumentKeySchema, + revisionId: z.string().uuid(), + revisionNumber: z.number().int().positive().nullable().optional(), +}); + +export const requestConfirmationCustomTargetSchema = requestConfirmationTargetBaseSchema.extend({ + type: z.literal("custom"), + key: z.string().trim().min(1).max(120), + revisionId: z.string().trim().min(1).max(255).nullable().optional(), + revisionNumber: z.number().int().positive().nullable().optional(), +}); + +export const requestConfirmationTargetSchema = z.discriminatedUnion("type", [ + requestConfirmationIssueDocumentTargetSchema, + requestConfirmationCustomTargetSchema, +]); + +export const requestConfirmationPayloadSchema = z.object({ + version: z.literal(1), + prompt: z.string().trim().min(1).max(1000), + acceptLabel: z.string().trim().min(1).max(80).nullable().optional(), + rejectLabel: z.string().trim().min(1).max(80).nullable().optional(), + rejectRequiresReason: z.boolean().optional(), + rejectReasonLabel: z.string().trim().min(1).max(160).nullable().optional(), + allowDeclineReason: z.boolean().optional().default(true), + declineReasonPlaceholder: z.string().trim().min(1).max(240).nullable().optional(), + detailsMarkdown: z.string().max(20000).nullable().optional(), + supersedeOnUserComment: z.boolean().optional(), + target: requestConfirmationTargetSchema.nullable().optional(), +}); + +export const requestConfirmationResultSchema = z.object({ + version: z.literal(1), + outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target"]), + reason: z.string().trim().max(4000).nullable().optional(), + commentId: z.string().uuid().nullable().optional(), + staleTarget: requestConfirmationTargetSchema.nullable().optional(), +}); + +export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("suggest_tasks"), + idempotencyKey: z.string().trim().max(255).nullable().optional(), + sourceCommentId: z.string().uuid().nullable().optional(), + sourceRunId: z.string().uuid().nullable().optional(), + title: z.string().trim().max(240).nullable().optional(), + summary: z.string().trim().max(1000).nullable().optional(), + continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"), + payload: suggestTasksPayloadSchema, + }), + z.object({ + kind: z.literal("ask_user_questions"), + idempotencyKey: z.string().trim().max(255).nullable().optional(), + sourceCommentId: z.string().uuid().nullable().optional(), + sourceRunId: z.string().uuid().nullable().optional(), + title: z.string().trim().max(240).nullable().optional(), + summary: z.string().trim().max(1000).nullable().optional(), + continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"), + payload: askUserQuestionsPayloadSchema, + }), + z.object({ + kind: z.literal("request_confirmation"), + idempotencyKey: z.string().trim().max(255).nullable().optional(), + sourceCommentId: z.string().uuid().nullable().optional(), + sourceRunId: z.string().uuid().nullable().optional(), + title: z.string().trim().max(240).nullable().optional(), + summary: z.string().trim().max(1000).nullable().optional(), + continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"), + payload: requestConfirmationPayloadSchema, + }), +]); + +export type CreateIssueThreadInteraction = z.infer; + +export const acceptIssueThreadInteractionSchema = z.object({ + selectedClientKeys: z.array(z.string().trim().min(1).max(120)).min(1).max(50).optional(), +}).superRefine((value, ctx) => { + const seenClientKeys = new Set(); + for (const [index, clientKey] of (value.selectedClientKeys ?? []).entries()) { + if (seenClientKeys.has(clientKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "selectedClientKeys must be unique", + path: ["selectedClientKeys", index], + }); + continue; + } + seenClientKeys.add(clientKey); + } +}); +export type AcceptIssueThreadInteraction = z.infer; + +export const rejectIssueThreadInteractionSchema = z.object({ + reason: z.string().trim().max(4000).optional(), +}); +export type RejectIssueThreadInteraction = z.infer; + +export const respondIssueThreadInteractionSchema = z.object({ + answers: z.array(askUserQuestionsAnswerSchema).max(20), + summaryMarkdown: z.string().max(20000).nullable().optional(), +}); +export type RespondIssueThreadInteraction = z.infer; + export const linkIssueApprovalSchema = z.object({ approvalId: z.string().uuid(), }); @@ -187,13 +450,6 @@ export const ISSUE_DOCUMENT_FORMATS = ["markdown"] as const; export const issueDocumentFormatSchema = z.enum(ISSUE_DOCUMENT_FORMATS); -export const issueDocumentKeySchema = z - .string() - .trim() - .min(1) - .max(64) - .regex(/^[a-z0-9][a-z0-9_-]*$/, "Document key must be lowercase letters, numbers, _ or -"); - export const upsertIssueDocumentSchema = z.object({ title: z.string().trim().max(200).nullable().optional(), format: issueDocumentFormatSchema, diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 60de440..768db9a 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -11,6 +11,10 @@ import { PLUGIN_LAUNCHER_BOUNDS, PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS, PLUGIN_STATE_SCOPE_KINDS, + PLUGIN_DATABASE_CORE_READ_TABLES, + PLUGIN_API_ROUTE_AUTH_MODES, + PLUGIN_API_ROUTE_CHECKOUT_POLICIES, + PLUGIN_API_ROUTE_METHODS, } from "../constants.js"; // --------------------------------------------------------------------------- @@ -336,6 +340,48 @@ export const pluginLauncherDeclarationSchema = z.object({ export type PluginLauncherDeclarationInput = z.infer; +export const pluginDatabaseDeclarationSchema = z.object({ + namespaceSlug: z.string().regex(/^[a-z0-9][a-z0-9_]*$/, { + message: "namespaceSlug must be lowercase letters, digits, or underscores and start with a letter or digit", + }).max(40).optional(), + migrationsDir: z.string().min(1).refine( + (value) => !value.startsWith("/") && !value.includes("..") && !/[\\]/.test(value), + { message: "migrationsDir must be a relative package path without '..' or backslashes" }, + ), + coreReadTables: z.array(z.enum(PLUGIN_DATABASE_CORE_READ_TABLES)).optional(), +}); + +export type PluginDatabaseDeclarationInput = z.infer; + +export const pluginApiRouteDeclarationSchema = z.object({ + routeKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, { + message: "routeKey must be lowercase letters, digits, dots, colons, underscores, or hyphens", + }), + method: z.enum(PLUGIN_API_ROUTE_METHODS), + path: z.string().min(1).regex(/^\/[a-zA-Z0-9:_./-]*$/, { + message: "path must start with / and contain only path-safe literal or :param segments", + }).refine( + (value) => + !value.includes("..") && + !value.includes("//") && + value !== "/api" && + !value.startsWith("/api/") && + value !== "/plugins" && + !value.startsWith("/plugins/"), + { message: "path must stay inside the plugin api namespace" }, + ), + auth: z.enum(PLUGIN_API_ROUTE_AUTH_MODES), + capability: z.literal("api.routes.register"), + checkoutPolicy: z.enum(PLUGIN_API_ROUTE_CHECKOUT_POLICIES).optional(), + companyResolution: z.discriminatedUnion("from", [ + z.object({ from: z.literal("body"), key: z.string().min(1) }), + z.object({ from: z.literal("query"), key: z.string().min(1) }), + z.object({ from: z.literal("issue"), param: z.string().min(1) }), + ]).optional(), +}); + +export type PluginApiRouteDeclarationInput = z.infer; + // --------------------------------------------------------------------------- // Plugin Manifest V1 schema // --------------------------------------------------------------------------- @@ -405,6 +451,8 @@ export const pluginManifestV1Schema = z.object({ jobs: z.array(pluginJobDeclarationSchema).optional(), webhooks: z.array(pluginWebhookDeclarationSchema).optional(), tools: z.array(pluginToolDeclarationSchema).optional(), + database: pluginDatabaseDeclarationSchema.optional(), + apiRoutes: z.array(pluginApiRouteDeclarationSchema).optional(), launchers: z.array(pluginLauncherDeclarationSchema).optional(), ui: z.object({ slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(), @@ -474,6 +522,42 @@ export const pluginManifestV1Schema = z.object({ } } + if (manifest.apiRoutes && manifest.apiRoutes.length > 0) { + if (!manifest.capabilities.includes("api.routes.register")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Capability 'api.routes.register' is required when apiRoutes are declared", + path: ["capabilities"], + }); + } + } + + if (manifest.database) { + const requiredCapabilities = [ + "database.namespace.migrate", + "database.namespace.read", + ] as const; + for (const capability of requiredCapabilities) { + if (!manifest.capabilities.includes(capability)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Capability '${capability}' is required when database migrations are declared`, + path: ["capabilities"], + }); + } + } + + const coreReadTables = manifest.database.coreReadTables ?? []; + const duplicates = coreReadTables.filter((table, i) => coreReadTables.indexOf(table) !== i); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate database coreReadTables: ${[...new Set(duplicates)].join(", ")}`, + path: ["database", "coreReadTables"], + }); + } + } + // ── Uniqueness checks ────────────────────────────────────────────────── // Duplicate keys within a plugin's own manifest are always a bug. The host // would not know which declaration takes precedence, so we reject early. @@ -504,6 +588,27 @@ export const pluginManifestV1Schema = z.object({ } } + if (manifest.apiRoutes) { + const routeKeys = manifest.apiRoutes.map((route) => route.routeKey); + const duplicateKeys = routeKeys.filter((key, i) => routeKeys.indexOf(key) !== i); + if (duplicateKeys.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate api route keys: ${[...new Set(duplicateKeys)].join(", ")}`, + path: ["apiRoutes"], + }); + } + const routeSignatures = manifest.apiRoutes.map((route) => `${route.method} ${route.path}`); + const duplicateRoutes = routeSignatures.filter((sig, i) => routeSignatures.indexOf(sig) !== i); + if (duplicateRoutes.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate api routes: ${[...new Set(duplicateRoutes)].join(", ")}`, + path: ["apiRoutes"], + }); + } + } + // tool names must be unique within the plugin (namespaced at runtime) if (manifest.tools) { const toolNames = manifest.tools.map((t) => t.name); diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index 8e8549c..4f815db 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -30,8 +30,8 @@ export const projectExecutionWorkspacePolicySchema = z export const projectWorkspaceRuntimeConfigSchema = z.object({ workspaceRuntime: z.record(z.unknown()).optional().nullable(), - desiredState: z.enum(["running", "stopped"]).optional().nullable(), - serviceStates: z.record(z.enum(["running", "stopped"])).optional().nullable(), + desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(), + serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(), }).strict(); const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]); diff --git a/packages/shared/src/workspace-commands.test.ts b/packages/shared/src/workspace-commands.test.ts index 1fc7447..9a5c610 100644 --- a/packages/shared/src/workspace-commands.test.ts +++ b/packages/shared/src/workspace-commands.test.ts @@ -53,4 +53,26 @@ describe("workspace command helpers", () => { expect(match).toEqual(expect.objectContaining({ id: "runtime-web" })); }); + + it("does not match a stale runtime service after the configured command changes", () => { + const workspaceRuntime = { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev:once --tailscale-auth", cwd: "." }, + ], + }; + const command = findWorkspaceCommandDefinition(workspaceRuntime, "web"); + expect(command).not.toBeNull(); + + const match = matchWorkspaceRuntimeServiceToCommand(command!, [ + { + id: "runtime-web", + serviceName: "web", + command: "pnpm dev", + cwd: "/repo", + configIndex: null, + }, + ]); + + expect(match).toBeNull(); + }); }); diff --git a/packages/shared/src/workspace-commands.ts b/packages/shared/src/workspace-commands.ts index 743960c..49e6e05 100644 --- a/packages/shared/src/workspace-commands.ts +++ b/packages/shared/src/workspace-commands.ts @@ -166,6 +166,10 @@ export function scoreWorkspaceRuntimeServiceMatch( command: Pick, runtimeService: Pick, ) { + if (command.command && runtimeService.command && runtimeService.command !== command.command) { + return -1; + } + if (command.serviceIndex !== null && runtimeService.configIndex !== null && runtimeService.configIndex !== undefined) { return runtimeService.configIndex === command.serviceIndex ? 100 : -1; } diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 0000000..ae847ff --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); From b57d1818f14af244b2684500b036cc9f23acc5f6 Mon Sep 17 00:00:00 2001 From: NeoPilot <221231603+neopilotai@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:08:41 +0000 Subject: [PATCH 03/13] =?UTF-8?q?=F0=9F=93=A6=20adapters:=20update=20adapt?= =?UTF-8?q?er=20packages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/adapter-utils/CHANGELOG.md | 4 +- packages/adapter-utils/package.json | 2 +- .../adapter-utils/src/server-utils.test.ts | 321 +++++++++++++- packages/adapter-utils/src/server-utils.ts | 390 +++++++++++++++--- packages/adapter-utils/src/types.ts | 31 +- packages/adapters/claude-local/CHANGELOG.md | 6 +- packages/adapters/claude-local/package.json | 2 +- packages/adapters/claude-local/src/index.ts | 1 + .../claude-local/src/server/execute.ts | 29 +- .../claude-local/src/server/prompt-cache.ts | 2 +- .../adapters/claude-local/src/server/quota.ts | 8 +- .../adapters/claude-local/src/server/test.ts | 10 +- packages/adapters/codex-local/CHANGELOG.md | 6 +- packages/adapters/codex-local/package.json | 2 +- .../codex-local/src/server/execute.ts | 147 ++++++- .../adapters/codex-local/src/server/index.ts | 2 +- .../codex-local/src/server/parse.test.ts | 39 +- .../adapters/codex-local/src/server/parse.ts | 26 ++ .../src/server/quota-spawn-error.test.ts | 4 +- .../adapters/codex-local/src/server/quota.ts | 8 +- .../adapters/codex-local/src/server/test.ts | 6 +- packages/adapters/cursor-local/CHANGELOG.md | 6 +- packages/adapters/cursor-local/package.json | 2 +- .../cursor-local/src/server/execute.ts | 31 +- .../cursor-local/src/server/skills.ts | 2 +- .../adapters/cursor-local/src/server/test.ts | 6 +- packages/adapters/gemini-local/package.json | 2 +- .../gemini-local/src/server/execute.ts | 3 +- .../gemini-local/src/server/skills.ts | 2 +- .../adapters/openclaw-gateway/CHANGELOG.md | 6 +- .../adapters/openclaw-gateway/package.json | 2 +- .../openclaw-gateway/src/server/execute.ts | 42 +- .../openclaw-gateway/src/server/test.ts | 8 +- packages/adapters/opencode-local/CHANGELOG.md | 6 +- packages/adapters/opencode-local/package.json | 2 +- .../opencode-local/src/server/execute.ts | 19 +- .../opencode-local/src/server/models.ts | 4 +- .../src/server/runtime-config.ts | 2 +- .../opencode-local/src/server/skills.ts | 2 +- .../opencode-local/src/server/test.ts | 6 +- packages/adapters/pi-local/CHANGELOG.md | 6 +- packages/adapters/pi-local/package.json | 2 +- .../adapters/pi-local/src/server/execute.ts | 41 +- .../adapters/pi-local/src/server/models.ts | 18 +- .../adapters/pi-local/src/server/parse.ts | 12 +- .../adapters/pi-local/src/server/skills.ts | 2 +- packages/adapters/pi-local/src/server/test.ts | 10 +- .../adapters/pi-local/src/ui/build-config.ts | 4 +- .../adapters/pi-local/src/ui/parse-stdout.ts | 62 +-- 49 files changed, 1097 insertions(+), 259 deletions(-) diff --git a/packages/adapter-utils/CHANGELOG.md b/packages/adapter-utils/CHANGELOG.md index fa63984..cd4f8ff 100644 --- a/packages/adapter-utils/CHANGELOG.md +++ b/packages/adapter-utils/CHANGELOG.md @@ -1,10 +1,10 @@ # @taskcore/adapter-utils -## 0.2.1 +## 0.3.1 ### Patch Changes -- Stable release preparation for 0.2.1 +- Stable release preparation for 0.3.1 ## 0.3.0 diff --git a/packages/adapter-utils/package.json b/packages/adapter-utils/package.json index ca3424f..246eeba 100644 --- a/packages/adapter-utils/package.json +++ b/packages/adapter-utils/package.json @@ -43,4 +43,4 @@ "@types/node": "^24.6.0", "typescript": "^5.7.3" } -} \ No newline at end of file +} diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index bcdc4ba..a416dc2 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -1,6 +1,13 @@ import { randomUUID } from "node:crypto"; import { describe, expect, it } from "vitest"; -import { runChildProcess } from "./server-utils.js"; +import { + appendWithByteCap, + DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE, + renderTaskcoreWakePrompt, + runningProcesses, + runChildProcess, + stringifyTaskcoreWakePayload, +} from "./server-utils.js"; function isPidAlive(pid: number) { try { @@ -20,7 +27,37 @@ async function waitForPidExit(pid: number, timeoutMs = 2_000) { return !isPidAlive(pid); } +async function waitForTextMatch(read: () => string, pattern: RegExp, timeoutMs = 1_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = read(); + const match = value.match(pattern); + if (match) return match; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return read().match(pattern); +} + describe("runChildProcess", () => { + it("does not arm a timeout when timeoutSec is 0", async () => { + const result = await runChildProcess( + randomUUID(), + process.execPath, + ["-e", "setTimeout(() => process.stdout.write('done'), 150);"], + { + cwd: process.cwd(), + env: {}, + timeoutSec: 0, + graceSec: 1, + onLog: async () => {}, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.timedOut).toBe(false); + expect(result.stdout).toBe("done"); + }); + it("waits for onSpawn before sending stdin to the child", async () => { const spawnDelayMs = 150; const startedAt = Date.now(); @@ -85,4 +122,286 @@ describe("runChildProcess", () => { expect(await waitForPidExit(descendantPid!, 2_000)).toBe(true); }); + + it.skipIf(process.platform === "win32")("cleans up a lingering process group after terminal output and child exit", async () => { + const result = await runChildProcess( + randomUUID(), + process.execPath, + [ + "-e", + [ + "const { spawn } = require('node:child_process');", + "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'inherit', 'ignore'] });", + "process.stdout.write(`descendant:${child.pid}\\n`);", + "process.stdout.write(`${JSON.stringify({ type: 'result', result: 'done' })}\\n`);", + "setTimeout(() => process.exit(0), 25);", + ].join(" "), + ], + { + cwd: process.cwd(), + env: {}, + timeoutSec: 0, + graceSec: 1, + onLog: async () => {}, + terminalResultCleanup: { + graceMs: 100, + hasTerminalResult: ({ stdout }) => stdout.includes('"type":"result"'), + }, + }, + ); + + const descendantPid = Number.parseInt(result.stdout.match(/descendant:(\d+)/)?.[1] ?? "", 10); + expect(result.timedOut).toBe(false); + expect(result.exitCode).toBe(0); + expect(Number.isInteger(descendantPid) && descendantPid > 0).toBe(true); + expect(await waitForPidExit(descendantPid, 2_000)).toBe(true); + }); + + it.skipIf(process.platform === "win32")("cleans up a still-running child after terminal output", async () => { + const result = await runChildProcess( + randomUUID(), + process.execPath, + [ + "-e", + [ + "process.stdout.write(`${JSON.stringify({ type: 'result', result: 'done' })}\\n`);", + "setInterval(() => {}, 1000);", + ].join(" "), + ], + { + cwd: process.cwd(), + env: {}, + timeoutSec: 0, + graceSec: 1, + onLog: async () => {}, + terminalResultCleanup: { + graceMs: 100, + hasTerminalResult: ({ stdout }) => stdout.includes('"type":"result"'), + }, + }, + ); + + expect(result.timedOut).toBe(false); + expect(result.signal).toBe("SIGTERM"); + expect(result.stdout).toContain('"type":"result"'); + }); + + it.skipIf(process.platform === "win32")("does not clean up noisy runs that have no terminal output", async () => { + const runId = randomUUID(); + let observed = ""; + const resultPromise = runChildProcess( + runId, + process.execPath, + [ + "-e", + [ + "const { spawn } = require('node:child_process');", + "const child = spawn(process.execPath, ['-e', \"setInterval(() => process.stdout.write('noise\\\\n'), 50)\"], { stdio: ['ignore', 'inherit', 'ignore'] });", + "process.stdout.write(`descendant:${child.pid}\\n`);", + "setTimeout(() => process.exit(0), 25);", + ].join(" "), + ], + { + cwd: process.cwd(), + env: {}, + timeoutSec: 0, + graceSec: 1, + onLog: async (_stream, chunk) => { + observed += chunk; + }, + terminalResultCleanup: { + graceMs: 50, + hasTerminalResult: ({ stdout }) => stdout.includes('"type":"result"'), + }, + }, + ); + + const pidMatch = await waitForTextMatch(() => observed, /descendant:(\d+)/); + const descendantPid = Number.parseInt(pidMatch?.[1] ?? "", 10); + expect(Number.isInteger(descendantPid) && descendantPid > 0).toBe(true); + + const race = await Promise.race([ + resultPromise.then(() => "settled" as const), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 300)), + ]); + expect(race).toBe("pending"); + expect(isPidAlive(descendantPid)).toBe(true); + + const running = runningProcesses.get(runId) as + | { child: { kill(signal: NodeJS.Signals): boolean }; processGroupId: number | null } + | undefined; + try { + if (running?.processGroupId) { + process.kill(-running.processGroupId, "SIGKILL"); + } else { + running?.child.kill("SIGKILL"); + } + await resultPromise; + } finally { + runningProcesses.delete(runId); + if (isPidAlive(descendantPid)) { + try { + process.kill(descendantPid, "SIGKILL"); + } catch { + // Ignore cleanup races. + } + } + } + }); +}); + +describe("renderTaskcoreWakePrompt", () => { + it("keeps the default local-agent prompt action-oriented", () => { + expect(DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE).toContain("Start actionable work in this heartbeat"); + expect(DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE).toContain("do not stop at a plan"); + expect(DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE).toContain("Use child issues"); + expect(DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE).toContain("instead of polling agents, sessions, or processes"); + expect(DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE).toContain("Create child issues directly when you know what needs to be done"); + expect(DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE).toContain("POST /api/issues/{issueId}/interactions"); + expect(DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE).toContain("kind suggest_tasks, ask_user_questions, or request_confirmation"); + expect(DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE).toContain("confirmation:{issueId}:plan:{revisionId}"); + expect(DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE).toContain("Wait for acceptance before creating implementation subtasks"); + expect(DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE).toContain( + "Respect budget, pause/cancel, approval gates, and company boundaries", + ); + }); + + it("adds the execution contract to scoped wake prompts", () => { + const prompt = renderTaskcoreWakePrompt({ + reason: "issue_assigned", + issue: { + id: "issue-1", + identifier: "PAP-1580", + title: "Update prompts", + status: "in_progress", + }, + commentWindow: { + requestedCount: 0, + includedCount: 0, + missingCount: 0, + }, + comments: [], + fallbackFetchNeeded: false, + }); + + expect(prompt).toContain("## Taskcore Wake Payload"); + expect(prompt).toContain("Execution contract: take concrete action in this heartbeat"); + expect(prompt).toContain("use child issues instead of polling"); + expect(prompt).toContain("mark blocked work with the unblock owner/action"); + }); + + it("renders dependency-blocked interaction guidance", () => { + const prompt = renderTaskcoreWakePrompt({ + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-1703", + title: "Blocked parent", + status: "todo", + }, + dependencyBlockedInteraction: true, + unresolvedBlockerIssueIds: ["blocker-1"], + unresolvedBlockerSummaries: [ + { + id: "blocker-1", + identifier: "PAP-1723", + title: "Finish blocker", + status: "todo", + priority: "medium", + }, + ], + commentWindow: { + requestedCount: 1, + includedCount: 1, + missingCount: 0, + }, + commentIds: ["comment-1"], + latestCommentId: "comment-1", + comments: [{ id: "comment-1", body: "hello" }], + fallbackFetchNeeded: false, + }); + + expect(prompt).toContain("dependency-blocked interaction: yes"); + expect(prompt).toContain("respond or triage the human comment"); + expect(prompt).toContain("PAP-1723 Finish blocker (todo)"); + }); + + it("includes continuation and child issue summaries in structured wake context", () => { + const payload = { + reason: "issue_children_completed", + issue: { + id: "parent-1", + identifier: "PAP-100", + title: "Integrate child work", + status: "in_progress", + priority: "medium", + }, + continuationSummary: { + key: "continuation-summary", + title: "Continuation Summary", + body: "# Continuation Summary\n\n## Next Action\n\n- Integrate child outputs.", + updatedAt: "2026-04-18T12:00:00.000Z", + }, + livenessContinuation: { + attempt: 2, + maxAttempts: 2, + sourceRunId: "run-1", + state: "plan_only", + reason: "Run described future work without concrete action evidence", + instruction: "Take the first concrete action now.", + }, + childIssueSummaries: [ + { + id: "child-1", + identifier: "PAP-101", + title: "Implement helper", + status: "done", + priority: "medium", + summary: "Added the helper route and tests.", + }, + ], + }; + + expect(JSON.parse(stringifyTaskcoreWakePayload(payload) ?? "{}")).toMatchObject({ + continuationSummary: { + body: expect.stringContaining("Continuation Summary"), + }, + livenessContinuation: { + attempt: 2, + maxAttempts: 2, + sourceRunId: "run-1", + state: "plan_only", + instruction: "Take the first concrete action now.", + }, + childIssueSummaries: [ + { + identifier: "PAP-101", + summary: "Added the helper route and tests.", + }, + ], + }); + + const prompt = renderTaskcoreWakePrompt(payload); + expect(prompt).toContain("Issue continuation summary:"); + expect(prompt).toContain("Integrate child outputs."); + expect(prompt).toContain("Run liveness continuation:"); + expect(prompt).toContain("- attempt: 2/2"); + expect(prompt).toContain("- source run: run-1"); + expect(prompt).toContain("- liveness state: plan_only"); + expect(prompt).toContain("- reason: Run described future work without concrete action evidence"); + expect(prompt).toContain("- instruction: Take the first concrete action now."); + expect(prompt).toContain("Direct child issue summaries:"); + expect(prompt).toContain("PAP-101 Implement helper (done)"); + expect(prompt).toContain("Added the helper route and tests."); + }); +}); + +describe("appendWithByteCap", () => { + it("keeps valid UTF-8 when trimming through multibyte text", () => { + const output = appendWithByteCap("prefix ", "hello — world", 7); + + expect(output).not.toContain("\uFFFD"); + expect(Buffer.from(output, "utf8").toString("utf8")).toBe(output); + expect(Buffer.byteLength(output, "utf8")).toBeLessThanOrEqual(7); + }); }); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 69e393b..8111c9c 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -16,6 +16,11 @@ export interface RunProcessResult { startedAt: string | null; } +export interface TerminalResultCleanupOptions { + hasTerminalResult: (output: { stdout: string; stderr: string }) => boolean; + graceMs?: number; +} + interface RunningProcess { child: ChildProcess; graceSec: number; @@ -29,6 +34,10 @@ interface SpawnTarget { type ChildProcessWithEvents = ChildProcess & { on(event: "error", listener: (err: Error) => void): ChildProcess; + on( + event: "exit", + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): ChildProcess; on( event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void, @@ -60,12 +69,28 @@ function signalRunningProcess( export const runningProcesses = new Map(); export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024; export const MAX_EXCERPT_BYTES = 32 * 1024; +const TERMINAL_RESULT_SCAN_OVERLAP_CHARS = 64 * 1024; const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i; const TASKCORE_SKILL_ROOT_RELATIVE_CANDIDATES = [ "../../skills", "../../../../../skills", ]; +export const DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE = [ + "You are agent {{agent.id}} ({{agent.name}}). Continue your Taskcore work.", + "", + "Execution contract:", + "- Start actionable work in this heartbeat; do not stop at a plan unless the issue asks for planning.", + "- Leave durable progress in comments, documents, or work products with a clear next action.", + "- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.", + "- If woken by a human comment on a dependency-blocked issue, respond or triage the comment without treating the blocked deliverable work as unblocked.", + "- Create child issues directly when you know what needs to be done; use issue-thread interactions when the board/user must choose suggested tasks, answer structured questions, or confirm a proposal.", + "- To ask for that input, create an interaction on the current issue with POST /api/issues/{issueId}/interactions using kind suggest_tasks, ask_user_questions, or request_confirmation. Use continuationPolicy wake_assignee when you need to resume after a response; for request_confirmation this resumes only after acceptance.", + "- For plan approval, update the plan document first, then create request_confirmation targeting the latest plan revision with idempotencyKey confirmation:{issueId}:plan:{revisionId}. Wait for acceptance before creating implementation subtasks, and create a fresh confirmation after superseding board/user comments if approval is still needed.", + "- If blocked, mark the issue blocked and name the unblock owner and action.", + "- Respect budget, pause/cancel, approval gates, and company boundaries.", +].join("\n"); + export interface TaskcoreSkillEntry { key: string; runtimeName: string; @@ -180,6 +205,22 @@ export function appendWithCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYT return combined.length > cap ? combined.slice(combined.length - cap) : combined; } +export function appendWithByteCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYTES) { + const combined = prev + chunk; + const bytes = Buffer.byteLength(combined, "utf8"); + if (bytes <= cap) return combined; + + const buffer = Buffer.from(combined, "utf8"); + let start = Math.max(0, bytes - cap); + while (start < buffer.length && (buffer[start]! & 0xc0) === 0x80) start += 1; + return buffer.subarray(start).toString("utf8"); +} + +function resumeReadable(readable: { resume: () => unknown; destroyed?: boolean } | null | undefined) { + if (!readable || readable.destroyed) return; + readable.resume(); +} + export function resolvePathValue(obj: Record, dottedPath: string) { const parts = dottedPath.split("."); let cursor: unknown = obj; @@ -250,11 +291,52 @@ type TaskcoreWakeComment = { authorId: string | null; }; +type TaskcoreWakeContinuationSummary = { + key: string | null; + title: string | null; + body: string; + bodyTruncated: boolean; + updatedAt: string | null; +}; + +type TaskcoreWakeLivenessContinuation = { + attempt: number | null; + maxAttempts: number | null; + sourceRunId: string | null; + state: string | null; + reason: string | null; + instruction: string | null; +}; + +type TaskcoreWakeChildIssueSummary = { + id: string | null; + identifier: string | null; + title: string | null; + status: string | null; + priority: string | null; + summary: string | null; +}; + +type TaskcoreWakeBlockerSummary = { + id: string | null; + identifier: string | null; + title: string | null; + status: string | null; + priority: string | null; +}; + type TaskcoreWakePayload = { reason: string | null; issue: TaskcoreWakeIssue | null; checkedOutByHarness: boolean; + dependencyBlockedInteraction: boolean; + unresolvedBlockerIssueIds: string[]; + unresolvedBlockerSummaries: TaskcoreWakeBlockerSummary[]; executionStage: TaskcoreWakeExecutionStage | null; + continuationSummary: TaskcoreWakeContinuationSummary | null; + livenessContinuation: TaskcoreWakeLivenessContinuation | null; + childIssueSummaries: TaskcoreWakeChildIssueSummary[]; + childIssueSummaryTruncated: boolean; commentIds: string[]; latestCommentId: string | null; comments: TaskcoreWakeComment[]; @@ -298,6 +380,61 @@ function normalizeTaskcoreWakeComment(value: unknown): TaskcoreWakeComment | nul }; } +function normalizeTaskcoreWakeContinuationSummary(value: unknown): TaskcoreWakeContinuationSummary | null { + const summary = parseObject(value); + const body = asString(summary.body, "").trim(); + if (!body) return null; + return { + key: asString(summary.key, "").trim() || null, + title: asString(summary.title, "").trim() || null, + body, + bodyTruncated: asBoolean(summary.bodyTruncated, false), + updatedAt: asString(summary.updatedAt, "").trim() || null, + }; +} + +function normalizeTaskcoreWakeLivenessContinuation(value: unknown): TaskcoreWakeLivenessContinuation | null { + const continuation = parseObject(value); + const attempt = asNumber(continuation.attempt, 0); + const maxAttempts = asNumber(continuation.maxAttempts, 0); + const sourceRunId = asString(continuation.sourceRunId, "").trim() || null; + const state = asString(continuation.state, "").trim() || null; + const reason = asString(continuation.reason, "").trim() || null; + const instruction = asString(continuation.instruction, "").trim() || null; + if (!attempt && !maxAttempts && !sourceRunId && !state && !reason && !instruction) return null; + return { + attempt: attempt > 0 ? attempt : null, + maxAttempts: maxAttempts > 0 ? maxAttempts : null, + sourceRunId, + state, + reason, + instruction, + }; +} + +function normalizeTaskcoreWakeChildIssueSummary(value: unknown): TaskcoreWakeChildIssueSummary | null { + const child = parseObject(value); + const id = asString(child.id, "").trim() || null; + const identifier = asString(child.identifier, "").trim() || null; + const title = asString(child.title, "").trim() || null; + const status = asString(child.status, "").trim() || null; + const priority = asString(child.priority, "").trim() || null; + const summary = asString(child.summary, "").trim() || null; + if (!id && !identifier && !title && !status && !summary) return null; + return { id, identifier, title, status, priority, summary }; +} + +function normalizeTaskcoreWakeBlockerSummary(value: unknown): TaskcoreWakeBlockerSummary | null { + const blocker = parseObject(value); + const id = asString(blocker.id, "").trim() || null; + const identifier = asString(blocker.identifier, "").trim() || null; + const title = asString(blocker.title, "").trim() || null; + const status = asString(blocker.status, "").trim() || null; + const priority = asString(blocker.priority, "").trim() || null; + if (!id && !identifier && !title && !status) return null; + return { id, identifier, title, status, priority }; +} + function normalizeTaskcoreWakeExecutionPrincipal(value: unknown): TaskcoreWakeExecutionPrincipal | null { const principal = parseObject(value); const typeRaw = asString(principal.type, "").trim().toLowerCase(); @@ -318,8 +455,8 @@ function normalizeTaskcoreWakeExecutionStage(value: unknown): TaskcoreWakeExecut : null; const allowedActions = Array.isArray(stage.allowedActions) ? stage.allowedActions - .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) - .map((entry) => entry.trim()) + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => entry.trim()) : []; const currentParticipant = normalizeTaskcoreWakeExecutionPrincipal(stage.currentParticipant); const returnAssignee = normalizeTaskcoreWakeExecutionPrincipal(stage.returnAssignee); @@ -346,18 +483,35 @@ export function normalizeTaskcoreWakePayload(value: unknown): TaskcoreWakePayloa const payload = parseObject(value); const comments = Array.isArray(payload.comments) ? payload.comments - .map((entry) => normalizeTaskcoreWakeComment(entry)) - .filter((entry): entry is TaskcoreWakeComment => Boolean(entry)) + .map((entry) => normalizeTaskcoreWakeComment(entry)) + .filter((entry): entry is TaskcoreWakeComment => Boolean(entry)) : []; const commentWindow = parseObject(payload.commentWindow); const commentIds = Array.isArray(payload.commentIds) ? payload.commentIds - .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) - .map((entry) => entry.trim()) + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => entry.trim()) : []; const executionStage = normalizeTaskcoreWakeExecutionStage(payload.executionStage); + const continuationSummary = normalizeTaskcoreWakeContinuationSummary(payload.continuationSummary); + const livenessContinuation = normalizeTaskcoreWakeLivenessContinuation(payload.livenessContinuation); + const childIssueSummaries = Array.isArray(payload.childIssueSummaries) + ? payload.childIssueSummaries + .map((entry) => normalizeTaskcoreWakeChildIssueSummary(entry)) + .filter((entry): entry is TaskcoreWakeChildIssueSummary => Boolean(entry)) + : []; + const unresolvedBlockerIssueIds = Array.isArray(payload.unresolvedBlockerIssueIds) + ? payload.unresolvedBlockerIssueIds + .map((entry) => asString(entry, "").trim()) + .filter(Boolean) + : []; + const unresolvedBlockerSummaries = Array.isArray(payload.unresolvedBlockerSummaries) + ? payload.unresolvedBlockerSummaries + .map((entry) => normalizeTaskcoreWakeBlockerSummary(entry)) + .filter((entry): entry is TaskcoreWakeBlockerSummary => Boolean(entry)) + : []; - if (comments.length === 0 && commentIds.length === 0 && !executionStage && !normalizeTaskcoreWakeIssue(payload.issue)) { + if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !executionStage && !continuationSummary && !livenessContinuation && !normalizeTaskcoreWakeIssue(payload.issue)) { return null; } @@ -365,7 +519,14 @@ export function normalizeTaskcoreWakePayload(value: unknown): TaskcoreWakePayloa reason: asString(payload.reason, "").trim() || null, issue: normalizeTaskcoreWakeIssue(payload.issue), checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false), + dependencyBlockedInteraction: asBoolean(payload.dependencyBlockedInteraction, false), + unresolvedBlockerIssueIds, + unresolvedBlockerSummaries, executionStage, + continuationSummary, + livenessContinuation, + childIssueSummaries, + childIssueSummaryTruncated: asBoolean(payload.childIssueSummaryTruncated, false), commentIds, latestCommentId: asString(payload.latestCommentId, "").trim() || null, comments, @@ -398,35 +559,39 @@ export function renderTaskcoreWakePrompt( }; const lines = resumedSession - ? [ - "## Taskcore Resume Delta", - "", - "You are resuming an existing Taskcore session.", - "This heartbeat is scoped to the issue below. Do not switch to another issue until you have handled this wake.", - "Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.", - "Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.", - "", - `- reason: ${normalized.reason ?? "unknown"}`, - `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, - `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, - `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, - `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, - ] + ? [ + "## Taskcore Resume Delta", + "", + "You are resuming an existing Taskcore session.", + "This heartbeat is scoped to the issue below. Do not switch to another issue until you have handled this wake.", + "Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.", + "Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.", + "", + "Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.", + "", + `- reason: ${normalized.reason ?? "unknown"}`, + `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, + `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, + `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, + `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, + ] : [ - "## Taskcore Wake Payload", - "", - "Treat this wake payload as the highest-priority change for the current heartbeat.", - "This heartbeat is scoped to the issue below. Do not switch to another issue until you have handled this wake.", - "Before generic repo exploration or boilerplate heartbeat updates, acknowledge the latest comment and explain how it changes your next action.", - "Use this inline wake data first before refetching the issue thread.", - "Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.", - "", - `- reason: ${normalized.reason ?? "unknown"}`, - `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, - `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, - `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, - `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, - ]; + "## Taskcore Wake Payload", + "", + "Treat this wake payload as the highest-priority change for the current heartbeat.", + "This heartbeat is scoped to the issue below. Do not switch to another issue until you have handled this wake.", + "Before generic repo exploration or boilerplate heartbeat updates, acknowledge the latest comment and explain how it changes your next action.", + "Use this inline wake data first before refetching the issue thread.", + "Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.", + "", + "Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.", + "", + `- reason: ${normalized.reason ?? "unknown"}`, + `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, + `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, + `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, + `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, + ]; if (normalized.issue?.status) { lines.push(`- issue status: ${normalized.issue.status}`); @@ -437,6 +602,18 @@ export function renderTaskcoreWakePrompt( if (normalized.checkedOutByHarness) { lines.push("- checkout: already claimed by the harness for this run"); } + if (normalized.dependencyBlockedInteraction) { + lines.push("- dependency-blocked interaction: yes"); + lines.push("- execution scope: respond or triage the human comment; do not treat blocker-dependent deliverable work as unblocked"); + if (normalized.unresolvedBlockerSummaries.length > 0) { + const blockers = normalized.unresolvedBlockerSummaries + .map((blocker) => `${blocker.identifier ?? blocker.id ?? "unknown"}${blocker.title ? ` ${blocker.title}` : ""}${blocker.status ? ` (${blocker.status})` : ""}`) + .join("; "); + lines.push(`- unresolved blockers: ${blockers}`); + } else if (normalized.unresolvedBlockerIssueIds.length > 0) { + lines.push(`- unresolved blocker issue ids: ${normalized.unresolvedBlockerIssueIds.join(", ")}`); + } + } if (normalized.missingCount > 0) { lines.push(`- omitted comments: ${normalized.missingCount}`); } @@ -470,6 +647,55 @@ export function renderTaskcoreWakePrompt( } } + if (normalized.continuationSummary) { + lines.push( + "", + "Issue continuation summary:", + normalized.continuationSummary.body, + ); + if (normalized.continuationSummary.bodyTruncated) { + lines.push("[continuation summary truncated]"); + } + } + + if (normalized.livenessContinuation) { + const continuation = normalized.livenessContinuation; + lines.push("", "Run liveness continuation:"); + if (continuation.attempt) { + lines.push( + `- attempt: ${continuation.attempt}${continuation.maxAttempts ? `/${continuation.maxAttempts}` : ""}`, + ); + } + if (continuation.sourceRunId) { + lines.push(`- source run: ${continuation.sourceRunId}`); + } + if (continuation.state) { + lines.push(`- liveness state: ${continuation.state}`); + } + if (continuation.reason) { + lines.push(`- reason: ${continuation.reason}`); + } + if (continuation.instruction) { + lines.push(`- instruction: ${continuation.instruction}`); + } + } + + if (normalized.childIssueSummaries.length > 0) { + lines.push("", "Direct child issue summaries:"); + for (const child of normalized.childIssueSummaries) { + const label = child.identifier ?? child.id ?? "unknown"; + lines.push( + `- ${label}${child.title ? ` ${child.title}` : ""}${child.status ? ` (${child.status})` : ""}`, + ); + if (child.summary) { + lines.push(` ${child.summary}`); + } + } + if (normalized.childIssueSummaryTruncated) { + lines.push("[child issue summaries truncated]"); + } + } + if (normalized.checkedOutByHarness) { lines.push( "", @@ -907,9 +1133,9 @@ export function readTaskcoreSkillSyncPreference(config: Record) const desiredValues = syncConfig.desiredSkills; const desired = Array.isArray(desiredValues) ? desiredValues - .filter((value): value is string => typeof value === "string") - .map((value) => value.trim()) - .filter(Boolean) + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) : []; return { explicit: Object.prototype.hasOwnProperty.call(raw, "desiredSkills"), @@ -1072,6 +1298,7 @@ export async function runChildProcess( onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; onLogError?: (err: unknown, runId: string, message: string) => void; onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise; + terminalResultCleanup?: TerminalResultCleanupOptions; stdin?: string; }, ): Promise { @@ -1121,32 +1348,97 @@ export async function runChildProcess( let stdout = ""; let stderr = ""; let logChain: Promise = Promise.resolve(); + let terminalResultSeen = false; + let terminalCleanupStarted = false; + let terminalCleanupTimer: NodeJS.Timeout | null = null; + let terminalCleanupKillTimer: NodeJS.Timeout | null = null; + let terminalResultStdoutScanOffset = 0; + let terminalResultStderrScanOffset = 0; + + const clearTerminalCleanupTimers = () => { + if (terminalCleanupTimer) clearTimeout(terminalCleanupTimer); + if (terminalCleanupKillTimer) clearTimeout(terminalCleanupKillTimer); + terminalCleanupTimer = null; + terminalCleanupKillTimer = null; + }; + + const maybeArmTerminalResultCleanup = () => { + const terminalCleanup = opts.terminalResultCleanup; + if (!terminalCleanup || terminalCleanupStarted || timedOut) return; + if (!terminalResultSeen) { + const stdoutStart = Math.max(0, terminalResultStdoutScanOffset - TERMINAL_RESULT_SCAN_OVERLAP_CHARS); + const stderrStart = Math.max(0, terminalResultStderrScanOffset - TERMINAL_RESULT_SCAN_OVERLAP_CHARS); + const scanOutput = { + stdout: stdout.slice(stdoutStart), + stderr: stderr.slice(stderrStart), + }; + terminalResultStdoutScanOffset = stdout.length; + terminalResultStderrScanOffset = stderr.length; + if (scanOutput.stdout.length === 0 && scanOutput.stderr.length === 0) return; + try { + terminalResultSeen = terminalCleanup.hasTerminalResult(scanOutput); + } catch (err) { + onLogError(err, runId, "failed to inspect terminal adapter output"); + } + } + if (!terminalResultSeen) return; + + if (terminalCleanupTimer) return; + const graceMs = Math.max(0, terminalCleanup.graceMs ?? 5_000); + terminalCleanupTimer = setTimeout(() => { + terminalCleanupTimer = null; + if (terminalCleanupStarted || timedOut) return; + terminalCleanupStarted = true; + signalRunningProcess({ child, processGroupId }, "SIGTERM"); + terminalCleanupKillTimer = setTimeout(() => { + terminalCleanupKillTimer = null; + signalRunningProcess({ child, processGroupId }, "SIGKILL"); + }, Math.max(1, opts.graceSec) * 1000); + }, graceMs); + }; const timeout = opts.timeoutSec > 0 ? setTimeout(() => { - timedOut = true; - signalRunningProcess({ child, processGroupId }, "SIGTERM"); - setTimeout(() => { - signalRunningProcess({ child, processGroupId }, "SIGKILL"); - }, Math.max(1, opts.graceSec) * 1000); - }, opts.timeoutSec * 1000) + timedOut = true; + clearTerminalCleanupTimers(); + signalRunningProcess({ child, processGroupId }, "SIGTERM"); + setTimeout(() => { + signalRunningProcess({ child, processGroupId }, "SIGKILL"); + }, Math.max(1, opts.graceSec) * 1000); + }, opts.timeoutSec * 1000) : null; child.stdout?.on("data", (chunk: unknown) => { + const readable = child.stdout; + if (!readable) return; + readable.pause(); const text = String(chunk); stdout = appendWithCap(stdout, text); + maybeArmTerminalResultCleanup(); logChain = logChain .then(() => opts.onLog("stdout", text)) - .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")); + .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")) + .finally(() => { + maybeArmTerminalResultCleanup(); + resumeReadable(readable); + }); }); child.stderr?.on("data", (chunk: unknown) => { + const readable = child.stderr; + if (!readable) return; + readable.pause(); const text = String(chunk); stderr = appendWithCap(stderr, text); + maybeArmTerminalResultCleanup(); logChain = logChain .then(() => opts.onLog("stderr", text)) - .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")); + .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")) + .finally(() => { + maybeArmTerminalResultCleanup(); + resumeReadable(readable); + }); }); const stdin = child.stdin; @@ -1160,6 +1452,7 @@ export async function runChildProcess( child.on("error", (err: Error) => { if (timeout) clearTimeout(timeout); + clearTerminalCleanupTimers(); runningProcesses.delete(runId); const errno = (err as NodeJS.ErrnoException).code; const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? ""; @@ -1170,8 +1463,13 @@ export async function runChildProcess( reject(new Error(msg)); }); + child.on("exit", () => { + maybeArmTerminalResultCleanup(); + }); + child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { if (timeout) clearTimeout(timeout); + clearTerminalCleanupTimers(); runningProcesses.delete(runId); void logChain.finally(() => { resolve({ diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index aeeb91b..5eeaa05 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -161,7 +161,6 @@ export type AdapterSkillState = export type AdapterSkillOrigin = | "company_managed" | "taskcore_required" - | "paperclip_required" | "user_installed" | "external_unknown"; @@ -329,6 +328,36 @@ export interface ServerAdapterModule { * resolved inside this method — the caller receives a fully hydrated schema. */ getConfigSchema?: () => Promise | AdapterConfigSchema; + + // --------------------------------------------------------------------------- + // Adapter capability flags + // + // These allow adapter plugins to declare what "local" capabilities they + // support, replacing hardcoded type lists in the server and UI. + // All flags are optional — when undefined, the server falls back to + // legacy hardcoded lists for built-in adapters. + // --------------------------------------------------------------------------- + + /** + * Adapter supports managed instructions bundle (AGENTS.md files). + * When true, the server uses instructionsPathKey (default "instructionsFilePath") + * to resolve the instructions config key, and the UI shows the bundle editor. + * Built-in local adapters default to true; external plugins must opt in. + */ + supportsInstructionsBundle?: boolean; + + /** + * The adapterConfig key that holds the instructions file path. + * Defaults to "instructionsFilePath" when supportsInstructionsBundle is true. + */ + instructionsPathKey?: string; + + /** + * Adapter needs runtime skill entries materialized (written to disk) + * before being passed via config. Used by adapters that scan a directory + * rather than reading config.taskcoreRuntimeSkills. + */ + requiresMaterializedRuntimeSkills?: boolean; } // --------------------------------------------------------------------------- diff --git a/packages/adapters/claude-local/CHANGELOG.md b/packages/adapters/claude-local/CHANGELOG.md index 6bc673a..c08959f 100644 --- a/packages/adapters/claude-local/CHANGELOG.md +++ b/packages/adapters/claude-local/CHANGELOG.md @@ -1,12 +1,12 @@ # @taskcore/adapter-claude-local -## 0.2.1 +## 0.3.1 ### Patch Changes -- Stable release preparation for 0.2.1 +- Stable release preparation for 0.3.1 - Updated dependencies - - @taskcore/adapter-utils@0.2.1 + - @taskcore/adapter-utils@0.3.1 ## 0.3.0 diff --git a/packages/adapters/claude-local/package.json b/packages/adapters/claude-local/package.json index 02cc17b..9023e78 100644 --- a/packages/adapters/claude-local/package.json +++ b/packages/adapters/claude-local/package.json @@ -60,4 +60,4 @@ "@types/node": "^24.6.0", "typescript": "^5.7.3" } -} \ No newline at end of file +} diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index a7af708..65401f5 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -2,6 +2,7 @@ export const type = "claude_local"; export const label = "Claude Code (local)"; export const models = [ + { id: "claude-opus-4-7", label: "Claude Opus 4.7" }, { id: "claude-opus-4-6", label: "Claude Opus 4.6" }, { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, { id: "claude-haiku-4-6", label: "Claude Haiku 4.6" }, diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index c4be8e6..59b6606 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -21,6 +21,7 @@ import { renderTemplate, renderTaskcoreWakePrompt, stringifyTaskcoreWakePayload, + DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE, runChildProcess, } from "@taskcore/adapter-utils/server-utils"; import { @@ -106,18 +107,18 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise => typeof value === "object" && value !== null, - ) + (value): value is Record => typeof value === "object" && value !== null, + ) : []; const runtimeServiceIntents = Array.isArray(context.taskcoreRuntimeServiceIntents) ? context.taskcoreRuntimeServiceIntents.filter( - (value): value is Record => typeof value === "object" && value !== null, - ) + (value): value is Record => typeof value === "object" && value !== null, + ) : []; const runtimeServices = Array.isArray(context.taskcoreRuntimeServices) ? context.taskcoreRuntimeServices.filter( - (value): value is Record => typeof value === "object" && value !== null, - ) + (value): value is Record => typeof value === "object" && value !== null, + ) : []; const runtimePrimaryUrl = asString(context.taskcoreRuntimePrimaryUrl, ""); const configuredCwd = asString(config.cwd, ""); @@ -266,7 +267,7 @@ export async function runClaudeLogin(input: { authToken?: string; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; }) { - const onLog = input.onLog ?? (async () => { }); + const onLog = input.onLog ?? (async () => {}); const runtime = await buildClaudeRuntimeConfig({ runId: input.runId, agent: input.agent, @@ -300,7 +301,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", @@ -502,6 +507,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise parseClaudeStreamJson(stdout).resultJson !== null, + }, }); const parsedStream = parseClaudeStreamJson(proc.stdout); @@ -526,8 +535,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise { }); + await fs.rm(tempPath, { force: true }).catch(() => {}); } } diff --git a/packages/adapters/claude-local/src/server/quota.ts b/packages/adapters/claude-local/src/server/quota.ts index dd95e3a..6020a7c 100644 --- a/packages/adapters/claude-local/src/server/quota.ts +++ b/packages/adapters/claude-local/src/server/quota.ts @@ -187,13 +187,15 @@ function formatExtraUsageLabel(extraUsage: AnthropicExtraUsage): string | null { ) { return null; } - return `${formatCurrencyAmount(usedCredits, extraUsage.currency)} / ${formatCurrencyAmount(monthlyLimit, extraUsage.currency)}`; + // API returns values in cents — convert to dollars for display + return `${formatCurrencyAmount(usedCredits / 100, extraUsage.currency)} / ${formatCurrencyAmount(monthlyLimit / 100, extraUsage.currency)}`; } -/** Convert a 0-1 utilization fraction to a 0-100 integer percent. Returns null for null/undefined input. */ +/** Convert a utilization value to a 0-100 integer percent. Returns null for null/undefined input. + * Handles both 0-1 fractions (legacy) and 0-100 percentages (current API). */ export function toPercent(utilization: number | null | undefined): number | null { if (utilization == null) return null; - return Math.min(100, Math.round(utilization * 100)); + return Math.min(100, Math.round(utilization < 1 ? utilization * 100 : utilization)); } /** fetch with an abort-based timeout so a hanging provider api doesn't block the response indefinitely */ diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index a8f789e..b391d30 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -109,8 +109,8 @@ export async function testEnvironment( if (hasBedrock) { const source = env.CLAUDE_CODE_USE_BEDROCK === "1" || - env.CLAUDE_CODE_USE_BEDROCK === "true" || - isNonEmpty(env.ANTHROPIC_BEDROCK_BASE_URL) + env.CLAUDE_CODE_USE_BEDROCK === "true" || + isNonEmpty(env.ANTHROPIC_BEDROCK_BASE_URL) ? "adapter config env" : "server environment"; checks.push({ @@ -182,7 +182,7 @@ export async function testEnvironment( timeoutSec: 45, graceSec: 5, stdin: "Respond with hello.", - onLog: async () => { }, + onLog: async () => {}, }, ); @@ -225,8 +225,8 @@ export async function testEnvironment( ...(hasHello ? {} : { - hint: "Try the probe manually (`claude --print - --output-format stream-json --verbose`) and prompt `Respond with hello`.", - }), + hint: "Try the probe manually (`claude --print - --output-format stream-json --verbose`) and prompt `Respond with hello`.", + }), }); } else { checks.push({ diff --git a/packages/adapters/codex-local/CHANGELOG.md b/packages/adapters/codex-local/CHANGELOG.md index f1a4715..7d31518 100644 --- a/packages/adapters/codex-local/CHANGELOG.md +++ b/packages/adapters/codex-local/CHANGELOG.md @@ -1,12 +1,12 @@ # @taskcore/adapter-codex-local -## 0.2.1 +## 0.3.1 ### Patch Changes -- Stable release preparation for 0.2.1 +- Stable release preparation for 0.3.1 - Updated dependencies - - @taskcore/adapter-utils@0.2.1 + - @taskcore/adapter-utils@0.3.1 ## 0.3.0 diff --git a/packages/adapters/codex-local/package.json b/packages/adapters/codex-local/package.json index a2bc5ae..37766cb 100644 --- a/packages/adapters/codex-local/package.json +++ b/packages/adapters/codex-local/package.json @@ -59,4 +59,4 @@ "@types/node": "^24.6.0", "typescript": "^5.7.3" } -} \ No newline at end of file +} diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index bb8b97d..6436a4f 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -18,10 +18,15 @@ import { renderTemplate, renderTaskcoreWakePrompt, stringifyTaskcoreWakePayload, + DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE, joinPromptSections, runChildProcess, } from "@taskcore/adapter-utils/server-utils"; -import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; +import { + parseCodexJsonl, + isCodexTransientUpstreamError, + isCodexUnknownSessionError, +} from "./parse.js"; import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js"; import { resolveCodexDesiredSkillNames } from "./skills.js"; import { buildCodexExecArgs } from "./codex-args.js"; @@ -129,7 +134,7 @@ async function pruneBrokenUnavailableTaskcoreSkillSymlinks( continue; } - await fs.unlink(target).catch(() => { }); + await fs.unlink(target).catch(() => {}); await onLog( "stdout", `[taskcore] Removed stale Codex skill "${entry.name}" from ${skillsHome}\n`, @@ -148,6 +153,52 @@ type EnsureCodexSkillsInjectedOptions = { linkSkill?: (source: string, target: string) => Promise; }; +type CodexTransientFallbackMode = + | "same_session" + | "safer_invocation" + | "fresh_session" + | "fresh_session_safer_invocation"; + +function readCodexTransientFallbackMode(context: Record): CodexTransientFallbackMode | null { + const value = asString(context.codexTransientFallbackMode, "").trim(); + switch (value) { + case "same_session": + case "safer_invocation": + case "fresh_session": + case "fresh_session_safer_invocation": + return value; + default: + return null; + } +} + +function fallbackModeUsesSaferInvocation(mode: CodexTransientFallbackMode | null): boolean { + return mode === "safer_invocation" || mode === "fresh_session_safer_invocation"; +} + +function fallbackModeUsesFreshSession(mode: CodexTransientFallbackMode | null): boolean { + return mode === "fresh_session" || mode === "fresh_session_safer_invocation"; +} + +function buildCodexTransientHandoffNote(input: { + previousSessionId: string | null; + fallbackMode: CodexTransientFallbackMode; + continuationSummaryBody: string | null; +}): string { + return [ + "Taskcore session handoff:", + input.previousSessionId ? `- Previous session: ${input.previousSessionId}` : "", + "- Rotation reason: repeated Codex transient remote-compaction failures", + `- Fallback mode: ${input.fallbackMode}`, + input.continuationSummaryBody + ? `- Issue continuation summary: ${input.continuationSummaryBody.slice(0, 1_500)}` + : "", + "Continue from the current task state. Rebuild only the minimum context you need.", + ] + .filter(Boolean) + .join("\n"); +} + export async function ensureCodexSkillsInjected( onLog: AdapterExecutionContext["onLog"], options: EnsureCodexSkillsInjectedOptions = {}, @@ -218,7 +269,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise => typeof value === "object" && value !== null, - ) + (value): value is Record => typeof value === "object" && value !== null, + ) : []; const runtimeServiceIntents = Array.isArray(context.taskcoreRuntimeServiceIntents) ? context.taskcoreRuntimeServiceIntents.filter( - (value): value is Record => typeof value === "object" && value !== null, - ) + (value): value is Record => typeof value === "object" && value !== null, + ) : []; const runtimeServices = Array.isArray(context.taskcoreRuntimeServices) ? context.taskcoreRuntimeServices.filter( - (value): value is Record => typeof value === "object" && value !== null, - ) + (value): value is Record => typeof value === "object" && value !== null, + ) : []; const runtimePrimaryUrl = asString(context.taskcoreRuntimePrimaryUrl, ""); const configuredCwd = asString(config.cwd, ""); @@ -396,7 +447,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 && (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); - const sessionId = canResumeSession ? runtimeSessionId : null; + const codexTransientFallbackMode = readCodexTransientFallbackMode(context); + const forceSaferInvocation = fallbackModeUsesSaferInvocation(codexTransientFallbackMode); + const forceFreshSession = fallbackModeUsesFreshSession(codexTransientFallbackMode); + const sessionId = canResumeSession && !forceFreshSession ? runtimeSessionId : null; if (runtimeSessionId && !canResumeSession) { await onLog( "stdout", @@ -443,28 +497,66 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix; instructionsChars = promptInstructionsPrefix.length; + const continuationSummary = parseObject(context.taskcoreContinuationSummary); + const continuationSummaryBody = asString(continuationSummary.body, "").trim() || null; + const codexFallbackHandoffNote = + forceFreshSession + ? buildCodexTransientHandoffNote({ + previousSessionId: runtimeSessionId || runtime.sessionId || null, + fallbackMode: codexTransientFallbackMode ?? "fresh_session", + continuationSummaryBody, + }) + : ""; const commandNotes = (() => { if (!instructionsFilePath) { - return [repoAgentsNote]; + const notes = [repoAgentsNote]; + if (forceSaferInvocation) { + notes.push("Codex transient fallback requested safer invocation settings for this retry."); + } + if (forceFreshSession) { + notes.push("Codex transient fallback forced a fresh session with a continuation handoff."); + } + return notes; } if (instructionsPrefix.length > 0) { if (shouldUseResumeDeltaPrompt) { - return [ + const notes = [ `Loaded agent instructions from ${instructionsFilePath}`, "Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.", repoAgentsNote, ]; + if (forceSaferInvocation) { + notes.push("Codex transient fallback requested safer invocation settings for this retry."); + } + if (forceFreshSession) { + notes.push("Codex transient fallback forced a fresh session with a continuation handoff."); + } + return notes; } - return [ + const notes = [ `Loaded agent instructions from ${instructionsFilePath}`, `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, repoAgentsNote, ]; + if (forceSaferInvocation) { + notes.push("Codex transient fallback requested safer invocation settings for this retry."); + } + if (forceFreshSession) { + notes.push("Codex transient fallback forced a fresh session with a continuation handoff."); + } + return notes; } - return [ + const notes = [ `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, repoAgentsNote, ]; + if (forceSaferInvocation) { + notes.push("Codex transient fallback requested safer invocation settings for this retry."); + } + if (forceFreshSession) { + notes.push("Codex transient fallback forced a fresh session with a continuation handoff."); + } + return notes; })(); const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.taskcoreSessionHandoffMarkdown, "").trim(); @@ -472,6 +564,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const execArgs = buildCodexExecArgs(config, { resumeSessionId }); + const execArgs = buildCodexExecArgs( + forceSaferInvocation ? { ...config, fastMode: false } : config, + { resumeSessionId }, + ); const args = execArgs.args; const commandNotesWithFastMode = execArgs.fastModeIgnoredReason == null @@ -539,6 +635,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise }, clearSessionOnMissingSession = false, + isRetry = false, ): AdapterExecutionResult => { if (attempt.proc.timedOut) { return { @@ -550,7 +647,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise { it("captures session id, assistant summary, usage, and error message", () => { @@ -81,3 +85,36 @@ describe("isCodexUnknownSessionError", () => { expect(isCodexUnknownSessionError("", "model overloaded")).toBe(false); }); }); + +describe("isCodexTransientUpstreamError", () => { + it("classifies the remote-compaction high-demand failure as transient upstream", () => { + expect( + isCodexTransientUpstreamError({ + errorMessage: + "Error running remote compact task: We're currently experiencing high demand, which may cause temporary errors.", + }), + ).toBe(true); + expect( + isCodexTransientUpstreamError({ + stderr: "We're currently experiencing high demand, which may cause temporary errors.", + }), + ).toBe(true); + }); + + it("does not classify deterministic compaction errors as transient", () => { + expect( + isCodexTransientUpstreamError({ + errorMessage: [ + "Error running remote compact task: {", + ' "error": {', + ' "message": "Unknown parameter: \'prompt_cache_retention\'.",', + ' "type": "invalid_request_error",', + ' "param": "prompt_cache_retention",', + ' "code": "unknown_parameter"', + " }", + "}", + ].join("\n"), + }), + ).toBe(false); + }); +}); diff --git a/packages/adapters/codex-local/src/server/parse.ts b/packages/adapters/codex-local/src/server/parse.ts index 38e4ee9..4eef9b3 100644 --- a/packages/adapters/codex-local/src/server/parse.ts +++ b/packages/adapters/codex-local/src/server/parse.ts @@ -1,5 +1,9 @@ import { asString, asNumber, parseObject, parseJson } from "@taskcore/adapter-utils/server-utils"; +const CODEX_TRANSIENT_UPSTREAM_RE = + /(?:we(?:'|’)re\s+currently\s+experiencing\s+high\s+demand|temporary\s+errors|rate[-\s]?limit(?:ed)?|too\s+many\s+requests|\b429\b|server\s+overloaded|service\s+unavailable|try\s+again\s+later)/i; +const CODEX_REMOTE_COMPACTION_RE = /remote\s+compact\s+task/i; + export function parseCodexJsonl(stdout: string) { let sessionId: string | null = null; let finalMessage: string | null = null; @@ -71,3 +75,25 @@ export function isCodexUnknownSessionError(stdout: string, stderr: string): bool haystack, ); } + +export function isCodexTransientUpstreamError(input: { + stdout?: string | null; + stderr?: string | null; + errorMessage?: string | null; +}): boolean { + const haystack = [ + input.errorMessage ?? "", + input.stdout ?? "", + input.stderr ?? "", + ] + .join("\n") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); + + if (!CODEX_TRANSIENT_UPSTREAM_RE.test(haystack)) return false; + // Keep automatic retries scoped to the observed remote-compaction/high-demand + // failure shape; broader 429s may be caused by user or account limits. + return CODEX_REMOTE_COMPACTION_RE.test(haystack) || /high\s+demand|temporary\s+errors/i.test(haystack); +} diff --git a/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts b/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts index 0f5e303..d6abf64 100644 --- a/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts +++ b/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts @@ -22,11 +22,11 @@ import { getQuotaWindows } from "./quota.js"; function createChildThatErrorsOnMicrotask(err: Error): ChildProcess { const child = new EventEmitter() as ChildProcess; const stream = Object.assign(new EventEmitter(), { - setEncoding: () => { }, + setEncoding: () => {}, }); Object.assign(child, { stdout: stream, - stderr: Object.assign(new EventEmitter(), { setEncoding: () => { } }), + stderr: Object.assign(new EventEmitter(), { setEncoding: () => {} }), stdin: { write: vi.fn(), end: vi.fn() }, kill: vi.fn(), }); diff --git a/packages/adapters/codex-local/src/server/quota.ts b/packages/adapters/codex-local/src/server/quota.ts index f940952..06f59b9 100644 --- a/packages/adapters/codex-local/src/server/quota.ts +++ b/packages/adapters/codex-local/src/server/quota.ts @@ -86,14 +86,14 @@ function parsePlanAndEmailFromToken(idToken: string | null, accessToken: string const directEmail = typeof payload.email === "string" ? payload.email : null; const authBlock = typeof payload["https://api.openai.com/auth"] === "object" && - payload["https://api.openai.com/auth"] !== null && - !Array.isArray(payload["https://api.openai.com/auth"]) + payload["https://api.openai.com/auth"] !== null && + !Array.isArray(payload["https://api.openai.com/auth"]) ? payload["https://api.openai.com/auth"] as Record : null; const profileBlock = typeof payload["https://api.openai.com/profile"] === "object" && - payload["https://api.openai.com/profile"] !== null && - !Array.isArray(payload["https://api.openai.com/profile"]) + payload["https://api.openai.com/profile"] !== null && + !Array.isArray(payload["https://api.openai.com/profile"]) ? payload["https://api.openai.com/profile"] as Record : null; const email = diff --git a/packages/adapters/codex-local/src/server/test.ts b/packages/adapters/codex-local/src/server/test.ts index d90bb1d..5dd6691 100644 --- a/packages/adapters/codex-local/src/server/test.ts +++ b/packages/adapters/codex-local/src/server/test.ts @@ -160,7 +160,7 @@ export async function testEnvironment( timeoutSec: 45, graceSec: 5, stdin: "Respond with hello.", - onLog: async () => { }, + onLog: async () => {}, }, ); const parsed = parseCodexJsonl(probe.stdout); @@ -187,8 +187,8 @@ export async function testEnvironment( ...(hasHello ? {} : { - hint: "Try the probe manually (`codex exec --json -` then prompt: Respond with hello) to inspect full output.", - }), + hint: "Try the probe manually (`codex exec --json -` then prompt: Respond with hello) to inspect full output.", + }), }); } else if (CODEX_AUTH_REQUIRED_RE.test(authEvidence)) { checks.push({ diff --git a/packages/adapters/cursor-local/CHANGELOG.md b/packages/adapters/cursor-local/CHANGELOG.md index 1b0349f..35758e7 100644 --- a/packages/adapters/cursor-local/CHANGELOG.md +++ b/packages/adapters/cursor-local/CHANGELOG.md @@ -1,12 +1,12 @@ # @taskcore/adapter-cursor-local -## 0.2.1 +## 0.3.1 ### Patch Changes -- Stable release preparation for 0.2.1 +- Stable release preparation for 0.3.1 - Updated dependencies - - @taskcore/adapter-utils@0.2.1 + - @taskcore/adapter-utils@0.3.1 ## 0.3.0 diff --git a/packages/adapters/cursor-local/package.json b/packages/adapters/cursor-local/package.json index b35a4d6..f6a5a63 100644 --- a/packages/adapters/cursor-local/package.json +++ b/packages/adapters/cursor-local/package.json @@ -58,4 +58,4 @@ "@types/node": "^24.6.0", "typescript": "^5.7.3" } -} \ No newline at end of file +} diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 52e5ae7..6cc19b9 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -21,6 +21,7 @@ import { renderTemplate, renderTaskcoreWakePrompt, stringifyTaskcoreWakePayload, + DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE, joinPromptSections, runChildProcess, } from "@taskcore/adapter-utils/server-utils"; @@ -110,12 +111,12 @@ export async function ensureCursorSkillsInjected( const skillsEntries = options.skillsEntries ?? (options.skillsDir ? (await fs.readdir(options.skillsDir, { withFileTypes: true })) - .filter((entry) => entry.isDirectory()) - .map((entry) => ({ - key: entry.name, - runtimeName: entry.name, - source: path.join(options.skillsDir!, entry.name), - })) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + key: entry.name, + runtimeName: entry.name, + source: path.join(options.skillsDir!, entry.name), + })) : await readTaskcoreRuntimeSkillEntries({}, __moduleDir)); if (skillsEntries.length === 0) return; @@ -164,7 +165,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise => typeof value === "object" && value !== null, - ) + (value): value is Record => typeof value === "object" && value !== null, + ) : []; const configuredCwd = asString(config.cwd, ""); const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; @@ -486,12 +487,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise) + sessionId: resolvedSessionId, + cwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + } as Record) : null; const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; const stderrLine = firstNonEmptyLine(attempt.proc.stderr); diff --git a/packages/adapters/cursor-local/src/server/skills.ts b/packages/adapters/cursor-local/src/server/skills.ts index d5d830c..aecb86c 100644 --- a/packages/adapters/cursor-local/src/server/skills.ts +++ b/packages/adapters/cursor-local/src/server/skills.ts @@ -77,7 +77,7 @@ export async function syncCursorSkills( if (!available) continue; if (desiredSet.has(available.key)) continue; if (installedEntry.targetPath !== available.source) continue; - await fs.unlink(path.join(skillsHome, name)).catch(() => { }); + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); } return buildCursorSkillSnapshot(ctx.config); diff --git a/packages/adapters/cursor-local/src/server/test.ts b/packages/adapters/cursor-local/src/server/test.ts index 428a012..d090f4f 100644 --- a/packages/adapters/cursor-local/src/server/test.ts +++ b/packages/adapters/cursor-local/src/server/test.ts @@ -201,7 +201,7 @@ export async function testEnvironment( env, timeoutSec: 45, graceSec: 5, - onLog: async () => { }, + onLog: async () => {}, }, ); const parsed = parseCursorJsonl(probe.stdout); @@ -228,8 +228,8 @@ export async function testEnvironment( ...(hasHello ? {} : { - hint: "Try `agent -p --mode ask --output-format json \"Respond with hello.\"` manually to inspect full output.", - }), + hint: "Try `agent -p --mode ask --output-format json \"Respond with hello.\"` manually to inspect full output.", + }), }); } else if (CURSOR_AUTH_REQUIRED_RE.test(authEvidence)) { checks.push({ diff --git a/packages/adapters/gemini-local/package.json b/packages/adapters/gemini-local/package.json index ba8e038..6521179 100644 --- a/packages/adapters/gemini-local/package.json +++ b/packages/adapters/gemini-local/package.json @@ -58,4 +58,4 @@ "@types/node": "^24.6.0", "typescript": "^5.7.3" } -} \ No newline at end of file +} diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 526f847..e7b041a 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -24,6 +24,7 @@ import { renderTemplate, renderTaskcoreWakePrompt, stringifyTaskcoreWakePayload, + DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE, runChildProcess, } from "@taskcore/adapter-utils/server-utils"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; @@ -140,7 +141,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { }); + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); } return buildGeminiSkillSnapshot(ctx.config); diff --git a/packages/adapters/openclaw-gateway/CHANGELOG.md b/packages/adapters/openclaw-gateway/CHANGELOG.md index 341455f..fc42e96 100644 --- a/packages/adapters/openclaw-gateway/CHANGELOG.md +++ b/packages/adapters/openclaw-gateway/CHANGELOG.md @@ -1,12 +1,12 @@ # @taskcore/adapter-openclaw-gateway -## 0.2.1 +## 0.3.1 ### Patch Changes -- Stable release preparation for 0.2.1 +- Stable release preparation for 0.3.1 - Updated dependencies - - @taskcore/adapter-utils@0.2.1 + - @taskcore/adapter-utils@0.3.1 ## 0.3.0 diff --git a/packages/adapters/openclaw-gateway/package.json b/packages/adapters/openclaw-gateway/package.json index c6b356b..36fac44 100644 --- a/packages/adapters/openclaw-gateway/package.json +++ b/packages/adapters/openclaw-gateway/package.json @@ -59,4 +59,4 @@ "@types/ws": "^8.18.1", "typescript": "^5.7.3" } -} \ No newline at end of file +} diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 0c83b5e..53dada0 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -312,8 +312,8 @@ function buildWakePayload(ctx: AdapterExecutionContext): WakePayload { approvalStatus: nonEmpty(context.approvalStatus), issueIds: Array.isArray(context.issueIds) ? context.issueIds.filter( - (value): value is string => typeof value === "string" && value.trim().length > 0, - ) + (value): value is string => typeof value === "string" && value.trim().length > 0, + ) : [], }; } @@ -420,7 +420,11 @@ function buildWakeText( " - POST /api/issues/{issueId}/checkout with {\"agentId\":\"$TASKCORE_AGENT_ID\",\"expectedStatuses\":[\"todo\",\"backlog\",\"blocked\",\"in_review\"]}", " - GET /api/issues/{issueId}", " - GET /api/issues/{issueId}/comments", - " - Execute the issue instructions exactly.", + " - Execute the issue instructions exactly. If the issue is actionable, take concrete action in this run; do not stop at a plan unless planning was requested.", + " - Leave durable progress with a clear next action. Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes.", + " - Create child issues directly when you know what needs to be done; use POST /api/issues/{issueId}/interactions with kind suggest_tasks, ask_user_questions, or request_confirmation when the board/user must choose, answer, or confirm before you can continue.", + " - For plan approval, update the plan document first, then create request_confirmation targeting the latest plan revision with idempotencyKey confirmation:{issueId}:plan:{revisionId}; wait for acceptance before creating implementation subtasks.", + " - If blocked, PATCH /api/issues/{issueId} with {\"status\":\"blocked\",\"comment\":\"what is blocked, who owns the unblock, and the next action\"}.", " - If instructions require a comment, POST /api/issues/{issueId}/comments with {\"body\":\"...\"}.", " - PATCH /api/issues/{issueId} with {\"status\":\"done\",\"comment\":\"what changed and why\"}.", "4) If issueId does not exist:", @@ -433,9 +437,9 @@ function buildWakeText( "- POST /api/companies/{companyId}/issues (when asked to create a new issue)", ...(structuredWakePrompt ? [ - "", - structuredWakePrompt, - ] + "", + structuredWakePrompt, + ] : []), "", "Complete the workflow in this run.", @@ -473,8 +477,8 @@ function buildStandardTaskcorePayload( const configuredWorkspaceRuntime = parseObject(ctx.config.workspaceRuntime); const runtimeServiceIntents = Array.isArray(ctx.context.taskcoreRuntimeServiceIntents) ? ctx.context.taskcoreRuntimeServiceIntents.filter( - (entry): entry is Record => Boolean(asRecord(entry)), - ) + (entry): entry is Record => Boolean(asRecord(entry)), + ) : []; const standardTaskcore: Record = { @@ -653,7 +657,7 @@ class GatewayWsClient { this.resolveChallenge = resolve; this.rejectChallenge = reject; }); - this.challengePromise.catch(() => { }); + this.challengePromise.catch(() => {}); } async connect( @@ -742,9 +746,9 @@ class GatewayWsClient { const timer = opts.timeoutMs > 0 ? setTimeout(() => { - this.pending.delete(id); - reject(new Error(`gateway request timeout (${method})`)); - }, opts.timeoutMs) + this.pending.delete(id); + reject(new Error(`gateway request timeout (${method})`)); + }, opts.timeoutMs) : null; this.pending.set(id, { @@ -852,7 +856,7 @@ async function autoApproveDevicePairing(params: { const client = new GatewayWsClient({ url: params.url, headers: params.headers, - onEvent: () => { }, + onEvent: () => {}, onLog: params.onLog, }); @@ -960,8 +964,8 @@ function extractRuntimeServicesFromMeta(meta: Record | null): A const rawScopeType = nonEmpty(entry.scopeType)?.toLowerCase(); const scopeType = rawScopeType === "project_workspace" || - rawScopeType === "execution_workspace" || - rawScopeType === "agent" + rawScopeType === "execution_workspace" || + rawScopeType === "agent" ? rawScopeType : "run"; const rawHealth = nonEmpty(entry.healthStatus)?.toLowerCase(); @@ -1270,10 +1274,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise => typeof value === "object" && value !== null, - ) + (value): value is Record => typeof value === "object" && value !== null, + ) : []; const configuredCwd = asString(config.cwd, ""); const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; @@ -362,12 +363,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise) + sessionId: resolvedSessionId, + cwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + } as Record) : null; const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; diff --git a/packages/adapters/opencode-local/src/server/models.ts b/packages/adapters/opencode-local/src/server/models.ts index c9c7ce4..409943c 100644 --- a/packages/adapters/opencode-local/src/server/models.ts +++ b/packages/adapters/opencode-local/src/server/models.ts @@ -13,7 +13,7 @@ const MODELS_DISCOVERY_TIMEOUT_MS = 20_000; function resolveOpenCodeCommand(input: unknown): string { const envOverride = typeof process.env.TASKCORE_OPENCODE_COMMAND === "string" && - process.env.TASKCORE_OPENCODE_COMMAND.trim().length > 0 + process.env.TASKCORE_OPENCODE_COMMAND.trim().length > 0 ? process.env.TASKCORE_OPENCODE_COMMAND.trim() : "opencode"; return asString(input, envOverride); @@ -132,7 +132,7 @@ export async function discoverOpenCodeModels(input: { env: runtimeEnv, timeoutSec: MODELS_DISCOVERY_TIMEOUT_MS / 1000, graceSec: 3, - onLog: async () => { }, + onLog: async () => {}, }, ); diff --git a/packages/adapters/opencode-local/src/server/runtime-config.ts b/packages/adapters/opencode-local/src/server/runtime-config.ts index ba4a64e..c1542f1 100644 --- a/packages/adapters/opencode-local/src/server/runtime-config.ts +++ b/packages/adapters/opencode-local/src/server/runtime-config.ts @@ -40,7 +40,7 @@ export async function prepareOpenCodeRuntimeConfig(input: { return { env: input.env, notes: [], - cleanup: async () => { }, + cleanup: async () => {}, }; } diff --git a/packages/adapters/opencode-local/src/server/skills.ts b/packages/adapters/opencode-local/src/server/skills.ts index 6f29957..5531e69 100644 --- a/packages/adapters/opencode-local/src/server/skills.ts +++ b/packages/adapters/opencode-local/src/server/skills.ts @@ -81,7 +81,7 @@ export async function syncOpenCodeSkills( if (!available) continue; if (desiredSet.has(available.key)) continue; if (installedEntry.targetPath !== available.source) continue; - await fs.unlink(path.join(skillsHome, name)).catch(() => { }); + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); } return buildOpenCodeSkillSnapshot(ctx.config); diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index 736af4f..66a7dbb 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -256,7 +256,7 @@ export async function testEnvironment( timeoutSec: 60, graceSec: 5, stdin: "Respond with hello.", - onLog: async () => { }, + onLog: async () => {}, }, ); @@ -284,8 +284,8 @@ export async function testEnvironment( ...(hasHello ? {} : { - hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", - }), + hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", + }), }); } else if (/ProviderModelNotFoundError/i.test(authEvidence)) { checks.push({ diff --git a/packages/adapters/pi-local/CHANGELOG.md b/packages/adapters/pi-local/CHANGELOG.md index d1c2f80..33ef25b 100644 --- a/packages/adapters/pi-local/CHANGELOG.md +++ b/packages/adapters/pi-local/CHANGELOG.md @@ -1,12 +1,12 @@ # @taskcore/adapter-pi-local -## 0.2.1 +## 0.3.1 ### Patch Changes -- Stable release preparation for 0.2.1 +- Stable release preparation for 0.3.1 - Updated dependencies - - @taskcore/adapter-utils@0.2.1 + - @taskcore/adapter-utils@0.3.1 ## 0.3.0 diff --git a/packages/adapters/pi-local/package.json b/packages/adapters/pi-local/package.json index 627e3e6..9c7f562 100644 --- a/packages/adapters/pi-local/package.json +++ b/packages/adapters/pi-local/package.json @@ -57,4 +57,4 @@ "@types/node": "^24.6.0", "typescript": "^5.7.3" } -} \ No newline at end of file +} diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 7ba5c49..3f04551 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -22,6 +22,7 @@ import { renderTemplate, renderTaskcoreWakePrompt, stringifyTaskcoreWakePayload, + DEFAULT_TASKCORE_AGENT_PROMPT_TEMPLATE, runChildProcess, } from "@taskcore/adapter-utils/server-utils"; import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js"; @@ -113,7 +114,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise => typeof value === "object" && value !== null, - ) + (value): value is Record => typeof value === "object" && value !== null, + ) : []; const configuredCwd = asString(config.cwd, ""); const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); - + // Ensure sessions directory exists await ensureSessionsDir(); - + // Inject skills const piSkillEntries = await readTaskcoreRuntimeSkillEntries(config, __moduleDir); const desiredPiSkillNames = resolveTaskcoreDesiredSkillNames(config, piSkillEntries); @@ -155,7 +156,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const env: Record = { ...buildTaskcoreEnv(agent) }; env.TASKCORE_RUN_ID = runId; - + const wakeTaskId = (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || @@ -180,7 +181,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; const wakePayloadJson = stringifyTaskcoreWakePayload(context.taskcoreWake); - + if (wakeTaskId) env.TASKCORE_TASK_ID = wakeTaskId; if (wakeReason) env.TASKCORE_WAKE_REASON = wakeReason; if (wakeCommentId) env.TASKCORE_WAKE_COMMENT_ID = wakeCommentId; @@ -202,7 +203,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", @@ -240,7 +241,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 && (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); const sessionPath = canResumeSession ? runtimeSessionId : buildSessionPath(agent.id, new Date().toISOString()); - + if (runtimeSessionId && !canResumeSession) { await onLog( "stdout", @@ -266,7 +267,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const args: string[] = []; - + // Use JSON mode for structured output with print mode (non-interactive) args.push("--mode", "json"); args.push("-p"); // Non-interactive mode: process prompt and exit - + // Use --append-system-prompt to extend Pi's default system prompt args.push("--append-system-prompt", renderedSystemPromptExtension); - + if (provider) args.push("--provider", provider); if (modelId) args.push("--model", modelId); if (thinking) args.push("--thinking", thinking); @@ -359,7 +360,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push(...extraArgs); - + // Add the user prompt as the last argument args.push(userPrompt); @@ -390,13 +391,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0); - + if ( canResumeSession && initialFailed && diff --git a/packages/adapters/pi-local/src/server/models.ts b/packages/adapters/pi-local/src/server/models.ts index 8bfa606..789fa0a 100644 --- a/packages/adapters/pi-local/src/server/models.ts +++ b/packages/adapters/pi-local/src/server/models.ts @@ -16,32 +16,32 @@ function firstNonEmptyLine(text: string): string { function parseModelsOutput(stdout: string): AdapterModel[] { const parsed: AdapterModel[] = []; const lines = stdout.split(/\r?\n/); - + // Skip header line if present let startIndex = 0; if (lines.length > 0 && (lines[0].includes("provider") || lines[0].includes("model"))) { startIndex = 1; } - + for (let i = startIndex; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; - + // Parse format: "provider model context max-out thinking images" // Split by 2+ spaces to handle the columnar format const parts = line.split(/\s{2,}/); if (parts.length < 2) continue; - + const provider = parts[0].trim(); const model = parts[1].trim(); - + if (!provider || !model) continue; if (provider === "provider" && model === "model") continue; // Skip header - + const id = `${provider}/${model}`; parsed.push({ id, label: id }); } - + return parsed; } @@ -66,7 +66,7 @@ function sortModels(models: AdapterModel[]): AdapterModel[] { function resolvePiCommand(input: unknown): string { const envOverride = typeof process.env.TASKCORE_PI_COMMAND === "string" && - process.env.TASKCORE_PI_COMMAND.trim().length > 0 + process.env.TASKCORE_PI_COMMAND.trim().length > 0 ? process.env.TASKCORE_PI_COMMAND.trim() : "pi"; return asString(input, envOverride); @@ -119,7 +119,7 @@ export async function discoverPiModels(input: { env: runtimeEnv, timeoutSec: 20, graceSec: 3, - onLog: async () => { }, + onLog: async () => {}, }, ); diff --git a/packages/adapters/pi-local/src/server/parse.ts b/packages/adapters/pi-local/src/server/parse.ts index c034064..024ff5f 100644 --- a/packages/adapters/pi-local/src/server/parse.ts +++ b/packages/adapters/pi-local/src/server/parse.ts @@ -99,14 +99,14 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { result.finalMessage = text; result.messages.push(text); } - + // Extract usage and cost from assistant message const usage = asRecord(message.usage); if (usage) { result.usage.inputTokens += asNumber(usage.input, 0); result.usage.outputTokens += asNumber(usage.output, 0); result.usage.cachedInputTokens += asNumber(usage.cacheRead, 0); - + // Pi stores cost in usage.cost.total (and broken down in usage.cost.input, etc.) const cost = asRecord(usage.cost); if (cost) { @@ -114,7 +114,7 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { } } } - + // Tool results are in toolResults array const toolResults = event.toolResults as Array> | undefined; if (toolResults) { @@ -122,7 +122,7 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { const toolCallId = asString(tr.toolCallId, ""); const content = tr.content; const isError = tr.isError === true; - + // Find matching tool call by toolCallId const existingCall = result.toolCalls.find((tc) => tc.toolCallId === toolCallId); if (existingCall) { @@ -183,7 +183,7 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { const toolName = asString(event.toolName, ""); const toolResult = event.result; const isError = event.isError === true; - + // Find the tool call by toolCallId (not toolName, to handle multiple calls to same tool) const existingCall = result.toolCalls.find((tc) => tc.toolCallId === toolCallId); if (existingCall) { @@ -202,7 +202,7 @@ export function parsePiJsonl(stdout: string): ParsedPiOutput { result.usage.inputTokens += asNumber(usage.inputTokens ?? usage.input, 0); result.usage.outputTokens += asNumber(usage.outputTokens ?? usage.output, 0); result.usage.cachedInputTokens += asNumber(usage.cachedInputTokens ?? usage.cacheRead, 0); - + // Cost may be in usage.costUsd (direct) or usage.cost.total (Pi format) const cost = asRecord(usage.cost); if (cost) { diff --git a/packages/adapters/pi-local/src/server/skills.ts b/packages/adapters/pi-local/src/server/skills.ts index 37502a6..326c472 100644 --- a/packages/adapters/pi-local/src/server/skills.ts +++ b/packages/adapters/pi-local/src/server/skills.ts @@ -77,7 +77,7 @@ export async function syncPiSkills( if (!available) continue; if (desiredSet.has(available.key)) continue; if (installedEntry.targetPath !== available.source) continue; - await fs.unlink(path.join(skillsHome, name)).catch(() => { }); + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); } return buildPiSkillSnapshot(ctx.config); diff --git a/packages/adapters/pi-local/src/server/test.ts b/packages/adapters/pi-local/src/server/test.ts index a0feec7..22f74f9 100644 --- a/packages/adapters/pi-local/src/server/test.ts +++ b/packages/adapters/pi-local/src/server/test.ts @@ -197,8 +197,8 @@ export async function testEnvironment( if (canRunProbe && configuredModel) { // Parse model for probe - const provider = configuredModel.includes("/") - ? configuredModel.slice(0, configuredModel.indexOf("/")) + const provider = configuredModel.includes("/") + ? configuredModel.slice(0, configuredModel.indexOf("/")) : ""; const modelId = configuredModel.includes("/") ? configuredModel.slice(configuredModel.indexOf("/") + 1) @@ -227,7 +227,7 @@ export async function testEnvironment( env: runtimeEnv, timeoutSec: 60, graceSec: 5, - onLog: async () => { }, + onLog: async () => {}, }, ); @@ -255,8 +255,8 @@ export async function testEnvironment( ...(hasHello ? {} : { - hint: "Run `pi --mode json` manually and prompt `Respond with hello` to inspect output.", - }), + hint: "Run `pi --mode json` manually and prompt `Respond with hello` to inspect output.", + }), }); } else if (PI_AUTH_REQUIRED_RE.test(authEvidence)) { checks.push({ diff --git a/packages/adapters/pi-local/src/ui/build-config.ts b/packages/adapters/pi-local/src/ui/build-config.ts index d99bda6..9f2c9aa 100644 --- a/packages/adapters/pi-local/src/ui/build-config.ts +++ b/packages/adapters/pi-local/src/ui/build-config.ts @@ -51,11 +51,11 @@ export function buildPiLocalConfig(v: CreateConfigValues): Record): { text: string; thinking: string } { if (typeof content === "string") return { text: content, thinking: "" }; if (!Array.isArray(content)) return { text: "", thinking: "" }; - + let text = ""; let thinking = ""; - + for (const c of content) { if (c.type === "text" && c.text) { text += c.text; @@ -32,7 +32,7 @@ function extractTextContent(content: string | Array<{ type: string; text?: strin thinking += c.thinking; } } - + return { text, thinking }; } @@ -66,7 +66,7 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { if (type === "agent_end") { const entries: TranscriptEntry[] = []; - + // Extract final message from messages array if available const messages = parsed.messages as Array> | undefined; if (messages && messages.length > 0) { @@ -74,14 +74,14 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { if (lastMessage?.role === "assistant") { const content = lastMessage.content as string | Array<{ type: string; text?: string; thinking?: string }>; const { text, thinking } = extractTextContent(content); - + if (thinking) { entries.push({ kind: "thinking", ts, text: thinking }); } if (text) { entries.push({ kind: "assistant", ts, text }); } - + // Extract usage const usage = asRecord(lastMessage.usage); if (usage) { @@ -90,7 +90,7 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { const cachedTokens = (usage.cacheRead ?? usage.cachedInputTokens ?? 0) as number; const costRecord = asRecord(usage.cost); const costUsd = (costRecord?.total ?? usage.costUsd ?? 0) as number; - + if (inputTokens > 0 || outputTokens > 0) { entries.push({ kind: "result", @@ -108,11 +108,11 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { } } } - + if (entries.length === 0) { entries.push({ kind: "system", ts, text: "✅ Pi agent finished" }); } - + return entries; } @@ -124,13 +124,13 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { if (type === "turn_end") { const message = asRecord(parsed.message); const toolResults = parsed.toolResults as Array> | undefined; - + const entries: TranscriptEntry[] = []; - + if (message) { const content = message.content as string | Array<{ type: string; text?: string; thinking?: string }>; const { text, thinking } = extractTextContent(content); - + if (thinking) { entries.push({ kind: "thinking", ts, text: thinking }); } @@ -138,14 +138,14 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { entries.push({ kind: "assistant", ts, text }); } } - + // Process tool results - match with pending tool calls if (toolResults) { for (const tr of toolResults) { const toolCallId = asString(tr.toolCallId, `tool-${Date.now()}`); const content = tr.content; const isError = tr.isError === true; - + // Extract text from Pi's content array format let contentStr: string; if (typeof content === "string") { @@ -156,11 +156,11 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { } else { contentStr = JSON.stringify(content); } - + // Get tool name from pending calls if available const pendingCall = pendingToolCalls.get(toolCallId); const toolName = asString(tr.toolName, pendingCall?.toolName || "tool"); - + entries.push({ kind: "tool_result", ts, @@ -169,12 +169,12 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { content: contentStr, isError, }); - + // Clean up pending call pendingToolCalls.delete(toolCallId); } } - + return entries; } @@ -187,7 +187,7 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { const assistantEvent = asRecord(parsed.assistantMessageEvent); if (assistantEvent) { const msgType = asString(assistantEvent.type); - + // Handle thinking deltas if (msgType === "thinking_delta") { const delta = asString(assistantEvent.delta); @@ -195,7 +195,7 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { return [{ kind: "thinking", ts, text: delta, delta: true }]; } } - + // Handle text deltas if (msgType === "text_delta") { const delta = asString(assistantEvent.delta); @@ -203,7 +203,7 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { return [{ kind: "assistant", ts, text: delta, delta: true }]; } } - + // Handle thinking end - emit full thinking block if (msgType === "thinking_end") { const content = asString(assistantEvent.content); @@ -211,7 +211,7 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { return [{ kind: "thinking", ts, text: content }]; } } - + // Handle text end - emit full text block if (msgType === "text_end") { const content = asString(assistantEvent.content); @@ -228,19 +228,19 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { if (message) { const content = message.content as string | Array<{ type: string; text?: string; thinking?: string }>; const { text, thinking } = extractTextContent(content); - + const entries: TranscriptEntry[] = []; - + // Emit final thinking block if present if (thinking) { entries.push({ kind: "thinking", ts, text: thinking }); } - + // Emit final text block if present if (text) { entries.push({ kind: "assistant", ts, text }); } - + return entries; } return []; @@ -251,10 +251,10 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { const toolCallId = asString(parsed.toolCallId, `tool-${Date.now()}`); const toolName = asString(parsed.toolName, "tool"); const args = parsed.args; - + // Track this tool call for later matching pendingToolCalls.set(toolCallId, { toolName, args }); - + return [{ kind: "tool_call", ts, @@ -273,7 +273,7 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { const toolName = asString(parsed.toolName, "tool"); const result = parsed.result; const isError = parsed.isError === true; - + // Extract text from Pi's content array format let contentStr: string; if (typeof result === "string") { @@ -292,10 +292,10 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { } else { contentStr = String(result); } - + // Clean up pending call pendingToolCalls.delete(toolCallId); - + return [{ kind: "tool_result", ts, From 25b5f83e24587b47a4e39ed9de07393ced4ace0d Mon Sep 17 00:00:00 2001 From: NeoPilot <221231603+neopilotai@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:08:47 +0000 Subject: [PATCH 04/13] =?UTF-8?q?=F0=9F=A7=A9=20plugins:=20update=20plugin?= =?UTF-8?q?=20system=20and=20MCP=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/mcp-server/README.md | 6 + packages/mcp-server/package.json | 2 +- packages/mcp-server/src/client.ts | 2 +- packages/mcp-server/src/tools.test.ts | 159 +++++++++ packages/mcp-server/src/tools.ts | 182 ++++++++++ .../create-taskcore-plugin/package.json | 2 +- .../create-taskcore-plugin/src/index.ts | 4 +- .../package.json | 2 +- .../plugin-file-browser-example/package.json | 2 +- .../src/ui/index.tsx | 14 +- .../plugin-hello-world-example/package.json | 2 +- .../plugin-kitchen-sink-example/package.json | 2 +- .../.gitignore | 3 + .../README.md | 48 +++ .../esbuild.config.mjs | 17 + .../migrations/001_orchestration_smoke.sql | 10 + .../package.json | 46 +++ .../rollup.config.mjs | 28 ++ .../src/manifest.ts | 82 +++++ .../src/ui/index.tsx | 134 ++++++++ .../src/worker.ts | 253 ++++++++++++++ .../tests/plugin.spec.ts | 162 +++++++++ .../tsconfig.json | 27 ++ .../vitest.config.ts | 8 + packages/plugins/sdk/README.md | 153 +++++++++ packages/plugins/sdk/package.json | 2 +- packages/plugins/sdk/src/define-plugin.ts | 31 ++ .../plugins/sdk/src/host-client-factory.ts | 73 +++- packages/plugins/sdk/src/index.ts | 32 ++ packages/plugins/sdk/src/protocol.ts | 160 ++++++++- packages/plugins/sdk/src/testing.ts | 320 +++++++++++++++++- packages/plugins/sdk/src/types.ts | 313 ++++++++++++++++- packages/plugins/sdk/src/ui/runtime.ts | 2 +- packages/plugins/sdk/src/worker-rpc-host.ts | 214 +++++++++++- 34 files changed, 2453 insertions(+), 44 deletions(-) create mode 100644 packages/plugins/examples/plugin-orchestration-smoke-example/.gitignore create mode 100644 packages/plugins/examples/plugin-orchestration-smoke-example/README.md create mode 100644 packages/plugins/examples/plugin-orchestration-smoke-example/esbuild.config.mjs create mode 100644 packages/plugins/examples/plugin-orchestration-smoke-example/migrations/001_orchestration_smoke.sql create mode 100644 packages/plugins/examples/plugin-orchestration-smoke-example/package.json create mode 100644 packages/plugins/examples/plugin-orchestration-smoke-example/rollup.config.mjs create mode 100644 packages/plugins/examples/plugin-orchestration-smoke-example/src/manifest.ts create mode 100644 packages/plugins/examples/plugin-orchestration-smoke-example/src/ui/index.tsx create mode 100644 packages/plugins/examples/plugin-orchestration-smoke-example/src/worker.ts create mode 100644 packages/plugins/examples/plugin-orchestration-smoke-example/tests/plugin.spec.ts create mode 100644 packages/plugins/examples/plugin-orchestration-smoke-example/tsconfig.json create mode 100644 packages/plugins/examples/plugin-orchestration-smoke-example/vitest.config.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index f1aa949..89f6a7e 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -47,6 +47,8 @@ Read tools: - `taskcoreListDocumentRevisions` - `taskcoreListProjects` - `taskcoreGetProject` +- `taskcoreGetIssueWorkspaceRuntime` +- `taskcoreWaitForIssueWorkspaceService` - `taskcoreListGoals` - `taskcoreGetGoal` - `taskcoreListApprovals` @@ -61,8 +63,12 @@ Write tools: - `taskcoreCheckoutIssue` - `taskcoreReleaseIssue` - `taskcoreAddComment` +- `taskcoreSuggestTasks` +- `taskcoreAskUserQuestions` +- `taskcoreRequestConfirmation` - `taskcoreUpsertIssueDocument` - `taskcoreRestoreIssueDocumentRevision` +- `taskcoreControlIssueWorkspaceServices` - `taskcoreCreateApproval` - `taskcoreLinkIssueApproval` - `taskcoreUnlinkIssueApproval` diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index e963ee9..23284b6 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -52,4 +52,4 @@ "typescript": "^5.7.3", "vitest": "^3.0.5" } -} \ No newline at end of file +} diff --git a/packages/mcp-server/src/client.ts b/packages/mcp-server/src/client.ts index 6ed572c..2468b68 100644 --- a/packages/mcp-server/src/client.ts +++ b/packages/mcp-server/src/client.ts @@ -49,7 +49,7 @@ async function parseResponseBody(response: Response): Promise { } export class TaskcoreApiClient { - constructor(private readonly config: TaskcoreMcpConfig) { } + constructor(private readonly config: TaskcoreMcpConfig) {} get defaults() { return { diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts index c41dc4b..61f99a3 100644 --- a/packages/mcp-server/src/tools.test.ts +++ b/packages/mcp-server/src/tools.test.ts @@ -107,6 +107,165 @@ describe("taskcore MCP tools", () => { }); }); + it("controls issue workspace services through the current execution workspace", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(mockJsonResponse({ + currentExecutionWorkspace: { + id: "44444444-4444-4444-8444-444444444444", + runtimeServices: [], + }, + })) + .mockResolvedValueOnce(mockJsonResponse({ + operation: { id: "operation-1" }, + workspace: { + id: "44444444-4444-4444-8444-444444444444", + runtimeServices: [ + { + id: "55555555-5555-4555-8555-555555555555", + serviceName: "web", + status: "running", + url: "http://127.0.0.1:5173", + }, + ], + }, + })); + vi.stubGlobal("fetch", fetchMock); + + const tool = getTool("taskcoreControlIssueWorkspaceServices"); + await tool.execute({ + issueId: "PAP-1135", + action: "restart", + workspaceCommandId: "web", + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [lookupUrl, lookupInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(String(lookupUrl)).toBe("http://localhost:3100/api/issues/PAP-1135/heartbeat-context"); + expect(lookupInit.method).toBe("GET"); + + const [controlUrl, controlInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(String(controlUrl)).toBe( + "http://localhost:3100/api/execution-workspaces/44444444-4444-4444-8444-444444444444/runtime-services/restart", + ); + expect(controlInit.method).toBe("POST"); + expect(JSON.parse(String(controlInit.body))).toEqual({ + workspaceCommandId: "web", + }); + }); + + it("waits for an issue workspace runtime service URL", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(mockJsonResponse({ + currentExecutionWorkspace: { + id: "44444444-4444-4444-8444-444444444444", + runtimeServices: [ + { + id: "55555555-5555-4555-8555-555555555555", + serviceName: "web", + status: "running", + healthStatus: "healthy", + url: "http://127.0.0.1:5173", + }, + ], + }, + })); + vi.stubGlobal("fetch", fetchMock); + + const tool = getTool("taskcoreWaitForIssueWorkspaceService"); + const response = await tool.execute({ + issueId: "PAP-1135", + serviceName: "web", + timeoutSeconds: 1, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(response.content[0]?.text).toContain("http://127.0.0.1:5173"); + }); + + it("creates suggest_tasks interactions with the expected issue-scoped payload", async () => { + const fetchMock = vi.fn().mockResolvedValue( + mockJsonResponse({ id: "interaction-1", kind: "suggest_tasks" }), + ); + vi.stubGlobal("fetch", fetchMock); + + const tool = getTool("taskcoreSuggestTasks"); + await tool.execute({ + issueId: "PAP-1135", + idempotencyKey: "run-1:suggest", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(String(url)).toBe("http://localhost:3100/api/issues/PAP-1135/interactions"); + expect(init.method).toBe("POST"); + expect(JSON.parse(String(init.body))).toEqual({ + kind: "suggest_tasks", + continuationPolicy: "wake_assignee", + idempotencyKey: "run-1:suggest", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + }); + }); + + it("creates request_confirmation interactions with plan target payloads", async () => { + const fetchMock = vi.fn().mockResolvedValue( + mockJsonResponse({ id: "interaction-1", kind: "request_confirmation" }), + ); + vi.stubGlobal("fetch", fetchMock); + + const tool = getTool("taskcoreRequestConfirmation"); + await tool.execute({ + issueId: "PAP-1135", + idempotencyKey: "confirmation:PAP-1135:plan:33333333-3333-4333-8333-333333333333", + title: "Plan approval", + payload: { + version: 1, + prompt: "Accept this plan?", + acceptLabel: "Accept plan", + allowDeclineReason: true, + rejectLabel: "Request changes", + rejectRequiresReason: true, + supersedeOnUserComment: true, + target: { + type: "issue_document", + key: "plan", + revisionId: "33333333-3333-4333-8333-333333333333", + revisionNumber: 3, + }, + }, + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(String(url)).toBe("http://localhost:3100/api/issues/PAP-1135/interactions"); + expect(init.method).toBe("POST"); + expect(JSON.parse(String(init.body))).toEqual({ + kind: "request_confirmation", + continuationPolicy: "none", + idempotencyKey: "confirmation:PAP-1135:plan:33333333-3333-4333-8333-333333333333", + title: "Plan approval", + payload: { + version: 1, + prompt: "Accept this plan?", + acceptLabel: "Accept plan", + allowDeclineReason: true, + rejectLabel: "Request changes", + rejectRequiresReason: true, + supersedeOnUserComment: true, + target: { + type: "issue_document", + key: "plan", + revisionId: "33333333-3333-4333-8333-333333333333", + revisionNumber: 3, + }, + }, + }); + }); + it("creates approvals with the expected company-scoped payload", async () => { const fetchMock = vi.fn().mockResolvedValue( mockJsonResponse({ id: "approval-1" }), diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index c378d42..20e0255 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -1,9 +1,13 @@ import { z } from "zod"; import { addIssueCommentSchema, + askUserQuestionsPayloadSchema, checkoutIssueSchema, createApprovalSchema, createIssueSchema, + issueThreadInteractionContinuationPolicySchema, + requestConfirmationPayloadSchema, + suggestTasksPayloadSchema, updateIssueSchema, upsertIssueDocumentSchema, linkIssueApprovalSchema, @@ -107,6 +111,39 @@ const addCommentToolSchema = z.object({ issueId: issueIdSchema, }).merge(addIssueCommentSchema); +const createSuggestTasksToolSchema = z.object({ + issueId: issueIdSchema, + idempotencyKey: z.string().trim().max(255).nullable().optional(), + sourceCommentId: z.string().uuid().nullable().optional(), + sourceRunId: z.string().uuid().nullable().optional(), + title: z.string().trim().max(240).nullable().optional(), + summary: z.string().trim().max(1000).nullable().optional(), + continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"), + payload: suggestTasksPayloadSchema, +}); + +const createAskUserQuestionsToolSchema = z.object({ + issueId: issueIdSchema, + idempotencyKey: z.string().trim().max(255).nullable().optional(), + sourceCommentId: z.string().uuid().nullable().optional(), + sourceRunId: z.string().uuid().nullable().optional(), + title: z.string().trim().max(240).nullable().optional(), + summary: z.string().trim().max(1000).nullable().optional(), + continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"), + payload: askUserQuestionsPayloadSchema, +}); + +const createRequestConfirmationToolSchema = z.object({ + issueId: issueIdSchema, + idempotencyKey: z.string().trim().max(255).nullable().optional(), + sourceCommentId: z.string().uuid().nullable().optional(), + sourceRunId: z.string().uuid().nullable().optional(), + title: z.string().trim().max(240).nullable().optional(), + summary: z.string().trim().max(1000).nullable().optional(), + continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"), + payload: requestConfirmationPayloadSchema, +}); + const approvalDecisionSchema = z.object({ approvalId: approvalIdSchema, action: z.enum(["approve", "reject", "requestRevision", "resubmit"]), @@ -124,6 +161,66 @@ const apiRequestSchema = z.object({ jsonBody: z.string().optional(), }); +const workspaceRuntimeControlTargetSchema = z.object({ + workspaceCommandId: z.string().min(1).optional().nullable(), + runtimeServiceId: z.string().uuid().optional().nullable(), + serviceIndex: z.number().int().nonnegative().optional().nullable(), +}); + +const issueWorkspaceRuntimeControlSchema = z.object({ + issueId: issueIdSchema, + action: z.enum(["start", "stop", "restart"]), +}).merge(workspaceRuntimeControlTargetSchema); + +const waitForIssueWorkspaceServiceSchema = z.object({ + issueId: issueIdSchema, + runtimeServiceId: z.string().uuid().optional().nullable(), + serviceName: z.string().min(1).optional().nullable(), + timeoutSeconds: z.number().int().positive().max(300).optional(), +}); + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function readCurrentExecutionWorkspace(context: unknown): Record | null { + if (!context || typeof context !== "object") return null; + const workspace = (context as { currentExecutionWorkspace?: unknown }).currentExecutionWorkspace; + return workspace && typeof workspace === "object" ? workspace as Record : null; +} + +function readWorkspaceRuntimeServices(workspace: Record | null): Array> { + const raw = workspace?.runtimeServices; + return Array.isArray(raw) + ? raw.filter((entry): entry is Record => Boolean(entry) && typeof entry === "object") + : []; +} + +function selectRuntimeService( + services: Array>, + input: { runtimeServiceId?: string | null; serviceName?: string | null }, +) { + if (input.runtimeServiceId) { + return services.find((service) => service.id === input.runtimeServiceId) ?? null; + } + if (input.serviceName) { + return services.find((service) => service.serviceName === input.serviceName) ?? null; + } + return services.find((service) => service.status === "running" || service.status === "starting") + ?? services[0] + ?? null; +} + +async function getIssueWorkspaceRuntime(client: TaskcoreApiClient, issueId: string) { + const context = await client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/heartbeat-context`); + const workspace = readCurrentExecutionWorkspace(context); + return { + context, + workspace, + runtimeServices: readWorkspaceRuntimeServices(workspace), + }; +} + export function createToolDefinitions(client: TaskcoreApiClient): ToolDefinition[] { return [ makeTool( @@ -247,6 +344,55 @@ export function createToolDefinitions(client: TaskcoreApiClient): ToolDefinition return client.requestJson("GET", `/projects/${encodeURIComponent(projectId)}${qs}`); }, ), + makeTool( + "taskcoreGetIssueWorkspaceRuntime", + "Get the current execution workspace and runtime services for an issue, including service URLs", + z.object({ issueId: issueIdSchema }), + async ({ issueId }) => getIssueWorkspaceRuntime(client, issueId), + ), + makeTool( + "taskcoreControlIssueWorkspaceServices", + "Start, stop, or restart the current issue execution workspace runtime services", + issueWorkspaceRuntimeControlSchema, + async ({ issueId, action, ...target }) => { + const runtime = await getIssueWorkspaceRuntime(client, issueId); + const workspaceId = typeof runtime.workspace?.id === "string" ? runtime.workspace.id : null; + if (!workspaceId) { + throw new Error("Issue has no current execution workspace"); + } + return client.requestJson( + "POST", + `/execution-workspaces/${encodeURIComponent(workspaceId)}/runtime-services/${action}`, + { body: target }, + ); + }, + ), + makeTool( + "taskcoreWaitForIssueWorkspaceService", + "Wait until an issue execution workspace runtime service is running and has a URL when one is exposed", + waitForIssueWorkspaceServiceSchema, + async ({ issueId, runtimeServiceId, serviceName, timeoutSeconds }) => { + const deadline = Date.now() + (timeoutSeconds ?? 60) * 1000; + let latest: Awaited> | null = null; + while (Date.now() <= deadline) { + latest = await getIssueWorkspaceRuntime(client, issueId); + const service = selectRuntimeService(latest.runtimeServices, { runtimeServiceId, serviceName }); + if (service?.status === "running" && service.healthStatus !== "unhealthy") { + return { + workspace: latest.workspace, + service, + }; + } + await sleep(1000); + } + + return { + timedOut: true, + latestWorkspace: latest?.workspace ?? null, + latestRuntimeServices: latest?.runtimeServices ?? [], + }; + }, + ), makeTool( "taskcoreListGoals", "List goals in a company", @@ -334,6 +480,42 @@ export function createToolDefinitions(client: TaskcoreApiClient): ToolDefinition async ({ issueId, ...body }) => client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/comments`, { body }), ), + makeTool( + "taskcoreSuggestTasks", + "Create a suggest_tasks interaction on an issue", + createSuggestTasksToolSchema, + async ({ issueId, ...body }) => + client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, { + body: { + kind: "suggest_tasks", + ...body, + }, + }), + ), + makeTool( + "taskcoreAskUserQuestions", + "Create an ask_user_questions interaction on an issue", + createAskUserQuestionsToolSchema, + async ({ issueId, ...body }) => + client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, { + body: { + kind: "ask_user_questions", + ...body, + }, + }), + ), + makeTool( + "taskcoreRequestConfirmation", + "Create a request_confirmation interaction on an issue", + createRequestConfirmationToolSchema, + async ({ issueId, ...body }) => + client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, { + body: { + kind: "request_confirmation", + ...body, + }, + }), + ), makeTool( "taskcoreUpsertIssueDocument", "Create or update an issue document", diff --git a/packages/plugins/create-taskcore-plugin/package.json b/packages/plugins/create-taskcore-plugin/package.json index 886bf1a..f89d1f9 100644 --- a/packages/plugins/create-taskcore-plugin/package.json +++ b/packages/plugins/create-taskcore-plugin/package.json @@ -47,4 +47,4 @@ "@types/node": "^24.6.0", "typescript": "^5.7.3" } -} \ No newline at end of file +} diff --git a/packages/plugins/create-taskcore-plugin/src/index.ts b/packages/plugins/create-taskcore-plugin/src/index.ts index b3de177..9e62123 100644 --- a/packages/plugins/create-taskcore-plugin/src/index.ts +++ b/packages/plugins/create-taskcore-plugin/src/index.ts @@ -434,8 +434,8 @@ pnpm test \`\`\` ${sdkDependency.startsWith("file:") - ? `This scaffold snapshots \`@taskcore/plugin-sdk\` and \`@taskcore/shared\` from a local Taskcore checkout at:\n\n\`${toPosixPath(localSdkPath)}\`\n\nThe packed tarballs live in \`.taskcore-sdk/\` for local development. Before publishing this plugin, switch those dependencies to published package versions once they are available on npm.\n\n` - : ""} + ? `This scaffold snapshots \`@taskcore/plugin-sdk\` and \`@taskcore/shared\` from a local Taskcore checkout at:\n\n\`${toPosixPath(localSdkPath)}\`\n\nThe packed tarballs live in \`.taskcore-sdk/\` for local development. Before publishing this plugin, switch those dependencies to published package versions once they are available on npm.\n\n` + : ""} ## Install Into Taskcore diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/package.json b/packages/plugins/examples/plugin-authoring-smoke-example/package.json index 9516392..4465dce 100644 --- a/packages/plugins/examples/plugin-authoring-smoke-example/package.json +++ b/packages/plugins/examples/plugin-authoring-smoke-example/package.json @@ -42,4 +42,4 @@ "peerDependencies": { "react": ">=18" } -} \ No newline at end of file +} diff --git a/packages/plugins/examples/plugin-file-browser-example/package.json b/packages/plugins/examples/plugin-file-browser-example/package.json index 998d64e..2a8eb53 100644 --- a/packages/plugins/examples/plugin-file-browser-example/package.json +++ b/packages/plugins/examples/plugin-file-browser-example/package.json @@ -39,4 +39,4 @@ "peerDependencies": { "react": ">=18" } -} \ No newline at end of file +} diff --git a/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx b/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx index 567bb9f..d84e639 100644 --- a/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx +++ b/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx @@ -279,8 +279,9 @@ function FileTreeNode({
  • + + + {data.summary ? ( +
    +
    Child{data.summary.childIssueId ?? "none"}
    +
    Blocker{data.summary.blockerIssueId ?? "none"}
    +
    Billing{data.summary.billingCode}
    +
    Subtree{data.summary.subtreeIssueIds.length}
    +
    Wakeup{data.summary.wakeupQueued ? "queued" : "not queued"}
    +
    + ) : ( +
    No smoke run recorded for this issue.
    + )} + + ); +} + +export function SettingsPage({ context }: PluginSettingsPageProps) { + const { data, loading, error } = usePluginData("surface-status", { + companyId: context.companyId, + }); + + if (loading) return
    Loading orchestration smoke settings...
    ; + if (error) return
    Orchestration smoke settings error: {error.message}
    ; + if (!data) return null; + + return ( +
    + Orchestration Smoke Surface + +
    + ); +} diff --git a/packages/plugins/examples/plugin-orchestration-smoke-example/src/worker.ts b/packages/plugins/examples/plugin-orchestration-smoke-example/src/worker.ts new file mode 100644 index 0000000..e22b06b --- /dev/null +++ b/packages/plugins/examples/plugin-orchestration-smoke-example/src/worker.ts @@ -0,0 +1,253 @@ +import { randomUUID } from "node:crypto"; +import { definePlugin, runWorker, type PluginApiRequestInput } from "@taskcore/plugin-sdk"; + +type SmokeInput = { + companyId: string; + issueId: string; + assigneeAgentId?: string | null; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; +}; + +type SmokeSummary = { + rootIssueId: string; + childIssueId: string | null; + blockerIssueId: string | null; + billingCode: string; + joinedRows: unknown[]; + subtreeIssueIds: string[]; + wakeupQueued: boolean; +}; + +let readSmokeSummary: ((companyId: string, issueId: string) => Promise) | null = null; +let initializeSmoke: ((input: SmokeInput) => Promise) | null = null; + +function tableName(namespace: string) { + return `${namespace}.smoke_runs`; +} + +function stringField(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +const plugin = definePlugin({ + async setup(ctx) { + readSmokeSummary = async function readSummary(companyId: string, issueId: string): Promise { + const rows = await ctx.db.query<{ + root_issue_id: string; + child_issue_id: string | null; + blocker_issue_id: string | null; + billing_code: string; + issue_title: string; + last_summary: unknown; + }>( + `SELECT s.root_issue_id, s.child_issue_id, s.blocker_issue_id, s.billing_code, i.title AS issue_title, s.last_summary + FROM ${tableName(ctx.db.namespace)} s + JOIN public.issues i ON i.id = s.root_issue_id + WHERE s.root_issue_id = $1`, + [issueId], + ); + const row = rows[0]; + if (!row) return null; + const orchestration = await ctx.issues.summaries.getOrchestration({ + issueId, + companyId, + includeSubtree: true, + billingCode: row.billing_code, + }); + return { + rootIssueId: row.root_issue_id, + childIssueId: row.child_issue_id, + blockerIssueId: row.blocker_issue_id, + billingCode: row.billing_code, + joinedRows: rows, + subtreeIssueIds: orchestration.subtreeIssueIds, + wakeupQueued: Boolean((row.last_summary as { wakeupQueued?: unknown } | null)?.wakeupQueued), + }; + }; + + initializeSmoke = async function runSmoke(input: SmokeInput): Promise { + const root = await ctx.issues.get(input.issueId, input.companyId); + if (!root) throw new Error(`Issue not found: ${input.issueId}`); + + const billingCode = `plugin-smoke:${input.issueId}`; + const actor = { + actorAgentId: input.actorAgentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? null, + }; + const blocker = await ctx.issues.create({ + companyId: input.companyId, + parentId: input.issueId, + inheritExecutionWorkspaceFromIssueId: input.issueId, + title: "Orchestration smoke blocker", + description: "Resolved blocker used to verify plugin relation writes without preventing the smoke wakeup.", + status: "done", + priority: "low", + billingCode, + originKind: `plugin:${ctx.manifest.id}:blocker`, + originId: `${input.issueId}:blocker`, + actor, + }); + + const child = await ctx.issues.create({ + companyId: input.companyId, + parentId: input.issueId, + inheritExecutionWorkspaceFromIssueId: input.issueId, + title: "Orchestration smoke child", + description: "Generated by the orchestration smoke plugin to verify issue, document, relation, wakeup, and summary APIs.", + status: "todo", + priority: "medium", + assigneeAgentId: input.assigneeAgentId ?? root.assigneeAgentId ?? undefined, + billingCode, + originKind: `plugin:${ctx.manifest.id}:child`, + originId: `${input.issueId}:child`, + blockedByIssueIds: [blocker.id], + actor, + }); + + await ctx.issues.relations.setBlockedBy(child.id, [blocker.id], input.companyId, actor); + await ctx.issues.documents.upsert({ + issueId: child.id, + companyId: input.companyId, + key: "orchestration-smoke", + title: "Orchestration Smoke", + format: "markdown", + body: [ + "# Orchestration Smoke", + "", + `- Root issue: ${input.issueId}`, + `- Child issue: ${child.id}`, + `- Billing code: ${billingCode}`, + ].join("\n"), + changeSummary: "Recorded orchestration smoke output", + }); + + const wakeup = await ctx.issues.requestWakeup(child.id, input.companyId, { + reason: "plugin:orchestration_smoke", + contextSource: "plugin-orchestration-smoke", + idempotencyKey: `${input.issueId}:child`, + ...actor, + }); + const orchestration = await ctx.issues.summaries.getOrchestration({ + issueId: input.issueId, + companyId: input.companyId, + includeSubtree: true, + billingCode, + }); + const summarySnapshot = { + childIssueId: child.id, + blockerIssueId: blocker.id, + wakeupQueued: wakeup.queued, + subtreeIssueIds: orchestration.subtreeIssueIds, + }; + + await ctx.db.execute( + `INSERT INTO ${tableName(ctx.db.namespace)} (id, root_issue_id, child_issue_id, blocker_issue_id, billing_code, last_summary) + VALUES ($1, $2, $3, $4, $5, $6::jsonb) + ON CONFLICT (id) DO UPDATE SET + child_issue_id = EXCLUDED.child_issue_id, + blocker_issue_id = EXCLUDED.blocker_issue_id, + billing_code = EXCLUDED.billing_code, + last_summary = EXCLUDED.last_summary, + updated_at = now()`, + [ + randomUUID(), + input.issueId, + child.id, + blocker.id, + billingCode, + JSON.stringify(summarySnapshot), + ], + ); + + return { + rootIssueId: input.issueId, + childIssueId: child.id, + blockerIssueId: blocker.id, + billingCode, + joinedRows: await ctx.db.query( + `SELECT s.id, s.billing_code, i.title AS root_title + FROM ${tableName(ctx.db.namespace)} s + JOIN public.issues i ON i.id = s.root_issue_id + WHERE s.root_issue_id = $1`, + [input.issueId], + ), + subtreeIssueIds: orchestration.subtreeIssueIds, + wakeupQueued: wakeup.queued, + }; + }; + + ctx.data.register("surface-status", async (params) => { + const companyId = stringField(params.companyId); + const issueId = stringField(params.issueId); + return { + status: "ok", + checkedAt: new Date().toISOString(), + databaseNamespace: ctx.db.namespace, + routeKeys: (ctx.manifest.apiRoutes ?? []).map((route) => route.routeKey), + capabilities: ctx.manifest.capabilities, + summary: companyId && issueId ? await readSmokeSummary?.(companyId, issueId) ?? null : null, + }; + }); + + ctx.actions.register("initialize-smoke", async (params) => { + const companyId = stringField(params.companyId); + const issueId = stringField(params.issueId); + if (!companyId || !issueId) throw new Error("companyId and issueId are required"); + if (!initializeSmoke) throw new Error("Smoke initializer is not ready"); + return initializeSmoke({ + companyId, + issueId, + assigneeAgentId: stringField(params.assigneeAgentId), + actorAgentId: stringField(params.actorAgentId), + actorUserId: stringField(params.actorUserId), + actorRunId: stringField(params.actorRunId), + }); + }); + }, + + async onApiRequest(input: PluginApiRequestInput) { + if (input.routeKey === "summary") { + const issueId = input.params.issueId; + return { + body: await readSmokeSummary?.(input.companyId, issueId) ?? null, + }; + } + + if (input.routeKey === "initialize") { + if (!initializeSmoke) throw new Error("Smoke initializer is not ready"); + const body = input.body as Record | null; + return { + status: 201, + body: await initializeSmoke({ + companyId: input.companyId, + issueId: input.params.issueId, + assigneeAgentId: stringField(body?.assigneeAgentId), + actorAgentId: input.actor.agentId ?? null, + actorUserId: input.actor.userId ?? null, + actorRunId: input.actor.runId ?? null, + }), + }; + } + + return { + status: 404, + body: { error: `Unknown orchestration smoke route: ${input.routeKey}` }, + }; + }, + + async onHealth() { + return { + status: "ok", + message: "Orchestration smoke plugin worker is running", + details: { + surfaces: ["database", "scoped-api-route", "issue-panel", "orchestration-apis"], + }, + }; + } +}); + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/examples/plugin-orchestration-smoke-example/tests/plugin.spec.ts b/packages/plugins/examples/plugin-orchestration-smoke-example/tests/plugin.spec.ts new file mode 100644 index 0000000..ac0a7da --- /dev/null +++ b/packages/plugins/examples/plugin-orchestration-smoke-example/tests/plugin.spec.ts @@ -0,0 +1,162 @@ +import { randomUUID } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { pluginManifestV1Schema, type Issue } from "@taskcore/shared"; +import { createTestHarness } from "@taskcore/plugin-sdk/testing"; +import manifest from "../src/manifest.js"; +import plugin from "../src/worker.js"; + +function issue(input: Partial & Pick): Issue { + const now = new Date(); + const { id, companyId, title, ...rest } = input; + return { + id, + companyId, + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title, + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: null, + identifier: null, + originKind: "manual", + originId: null, + originRunId: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: now, + updatedAt: now, + ...rest, + }; +} + +describe("orchestration smoke plugin", () => { + it("declares the Phase 1 orchestration surfaces", () => { + expect(pluginManifestV1Schema.parse(manifest)).toMatchObject({ + id: "taskcore.plugin-orchestration-smoke-example", + database: { + migrationsDir: "migrations", + coreReadTables: ["issues"], + }, + apiRoutes: [ + expect.objectContaining({ routeKey: "initialize" }), + expect.objectContaining({ routeKey: "summary" }), + ], + }); + }); + + it("creates plugin-owned orchestration rows, issue tree, document, wakeup, and summary reads", async () => { + const companyId = randomUUID(); + const rootIssueId = randomUUID(); + const agentId = randomUUID(); + const harness = createTestHarness({ manifest }); + harness.seed({ + issues: [ + issue({ + id: rootIssueId, + companyId, + title: "Root orchestration issue", + assigneeAgentId: agentId, + }), + ], + }); + await plugin.definition.setup(harness.ctx); + + const result = await harness.performAction<{ + rootIssueId: string; + childIssueId: string; + blockerIssueId: string; + billingCode: string; + subtreeIssueIds: string[]; + wakeupQueued: boolean; + }>("initialize-smoke", { + companyId, + issueId: rootIssueId, + assigneeAgentId: agentId, + }); + + expect(result.rootIssueId).toBe(rootIssueId); + expect(result.childIssueId).toEqual(expect.any(String)); + expect(result.blockerIssueId).toEqual(expect.any(String)); + expect(result.billingCode).toBe(`plugin-smoke:${rootIssueId}`); + expect(result.wakeupQueued).toBe(true); + expect(result.subtreeIssueIds).toEqual(expect.arrayContaining([rootIssueId, result.childIssueId])); + expect(harness.dbExecutes[0]?.sql).toContain(".smoke_runs"); + expect(harness.dbQueries.some((entry) => entry.sql.includes("JOIN public.issues"))).toBe(true); + + const relations = await harness.ctx.issues.relations.get(result.childIssueId, companyId); + expect(relations.blockedBy).toEqual([ + expect.objectContaining({ + id: result.blockerIssueId, + status: "done", + }), + ]); + const docs = await harness.ctx.issues.documents.list(result.childIssueId, companyId); + expect(docs).toEqual([ + expect.objectContaining({ + key: "orchestration-smoke", + title: "Orchestration Smoke", + }), + ]); + }); + + it("dispatches the scoped API route through the same smoke path", async () => { + const companyId = randomUUID(); + const rootIssueId = randomUUID(); + const agentId = randomUUID(); + const harness = createTestHarness({ manifest }); + harness.seed({ + issues: [ + issue({ + id: rootIssueId, + companyId, + title: "Scoped API root", + assigneeAgentId: agentId, + }), + ], + }); + await plugin.definition.setup(harness.ctx); + + await expect(plugin.definition.onApiRequest?.({ + routeKey: "initialize", + method: "POST", + path: `/issues/${rootIssueId}/smoke`, + params: { issueId: rootIssueId }, + query: {}, + body: { assigneeAgentId: agentId }, + actor: { + actorType: "user", + actorId: "board", + userId: "board", + agentId: null, + runId: null, + }, + companyId, + headers: {}, + })).resolves.toMatchObject({ + status: 201, + body: expect.objectContaining({ + rootIssueId, + wakeupQueued: true, + }), + }); + }); +}); diff --git a/packages/plugins/examples/plugin-orchestration-smoke-example/tsconfig.json b/packages/plugins/examples/plugin-orchestration-smoke-example/tsconfig.json new file mode 100644 index 0000000..a697519 --- /dev/null +++ b/packages/plugins/examples/plugin-orchestration-smoke-example/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "ES2022", + "DOM" + ], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "." + }, + "include": [ + "src", + "tests" + ], + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/packages/plugins/examples/plugin-orchestration-smoke-example/vitest.config.ts b/packages/plugins/examples/plugin-orchestration-smoke-example/vitest.config.ts new file mode 100644 index 0000000..649a293 --- /dev/null +++ b/packages/plugins/examples/plugin-orchestration-smoke-example/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.spec.ts"], + environment: "node", + }, +}); diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index 91eea33..5d5d744 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -118,10 +118,13 @@ Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name, | `project.created`, `project.updated` | project | | `project.workspace_created`, `project.workspace_updated`, `project.workspace_deleted` | project_workspace | | `issue.created`, `issue.updated`, `issue.comment.created` | issue | +| `issue.document.created`, `issue.document.updated`, `issue.document.deleted` | issue | +| `issue.relations.updated`, `issue.checked_out`, `issue.released`, `issue.assignment_wakeup_requested` | issue | | `agent.created`, `agent.updated`, `agent.status_changed` | agent | | `agent.run.started`, `agent.run.finished`, `agent.run.failed`, `agent.run.cancelled` | run | | `goal.created`, `goal.updated` | goal | | `approval.created`, `approval.decided` | approval | +| `budget.incident.opened`, `budget.incident.resolved` | budget_incident | | `cost_event.created` | cost | | `activity.logged` | activity | @@ -301,18 +304,29 @@ Declare in `manifest.capabilities`. Grouped by scope: | | `project.workspaces.read` | | | `issues.read` | | | `issue.comments.read` | +| | `issue.documents.read` | +| | `issue.relations.read` | +| | `issue.subtree.read` | | | `agents.read` | | | `goals.read` | | | `goals.create` | | | `goals.update` | | | `activity.read` | | | `costs.read` | +| | `issues.orchestration.read` | +| | `database.namespace.read` | | | `issues.create` | | | `issues.update` | +| | `issues.checkout` | +| | `issues.wakeup` | | | `issue.comments.create` | +| | `issue.documents.write` | +| | `issue.relations.write` | | | `activity.log.write` | | | `metrics.write` | | | `telemetry.track` | +| | `database.namespace.migrate` | +| | `database.namespace.write` | | **Instance** | `instance.settings.register` | | | `plugin.state.read` | | | `plugin.state.write` | @@ -320,6 +334,7 @@ Declare in `manifest.capabilities`. Grouped by scope: | | `events.emit` | | | `jobs.schedule` | | | `webhooks.receive` | +| | `api.routes.register` | | | `http.outbound` | | | `secrets.read-ref` | | **Agent** | `agent.tools.register` | @@ -337,6 +352,144 @@ Declare in `manifest.capabilities`. Grouped by scope: Full list in code: import `PLUGIN_CAPABILITIES` from `@taskcore/plugin-sdk`. +### Restricted Database Namespace + +Trusted orchestration plugins can declare a host-owned PostgreSQL namespace: + +```ts +database: { + migrationsDir: "migrations", + coreReadTables: ["issues"], +} +``` + +Declare `database.namespace.migrate` and `database.namespace.read`; add +`database.namespace.write` when the worker needs runtime writes. Migrations run +before worker startup, are checksum-recorded, and may create or alter objects +only inside the plugin namespace. Runtime `ctx.db.query()` allows `SELECT` from +`ctx.db.namespace` plus manifest-whitelisted `public` core tables. Runtime +`ctx.db.execute()` allows `INSERT`, `UPDATE`, and `DELETE` only against the +plugin namespace. + +### Scoped API Routes + +Manifest-declared `apiRoutes` expose JSON routes under +`/api/plugins/:pluginId/api/*` without letting a plugin claim core paths: + +```ts +apiRoutes: [ + { + routeKey: "initialize", + method: "POST", + path: "/issues/:issueId/smoke", + auth: "board-or-agent", + capability: "api.routes.register", + checkoutPolicy: "required-for-agent-in-progress", + companyResolution: { from: "issue", param: "issueId" }, + }, +] +``` + +Implement `onApiRequest(input)` in the worker to handle the route. The host +performs auth, company access, capability, route matching, and checkout policy +before dispatch. The worker receives route params, query, parsed JSON body, +sanitized headers, actor context, and `companyId`; responses are JSON `{ status?, +headers?, body? }`. + +## Issue Orchestration APIs + +Workflow plugins can use `ctx.issues` for orchestration-grade issue operations without importing host server internals. + +Expanded create/update fields include blockers, billing code, board or agent assignees, labels, namespaced plugin origins, request depth, and safe execution workspace fields: + +```ts +const child = await ctx.issues.create({ + companyId, + parentId: missionIssueId, + inheritExecutionWorkspaceFromIssueId: missionIssueId, + title: "Implement feature slice", + status: "todo", + assigneeAgentId: workerAgentId, + billingCode: "mission:alpha", + originKind: "plugin:taskcore.missions:feature", + originId: "mission-alpha:feature-1", + blockedByIssueIds: [planningIssueId], +}); +``` + +If `originKind` is omitted, the host stores `plugin:`. Plugins may use sub-kinds such as `plugin::feature`, but the host rejects attempts to set another plugin's namespace. + +Blocker relationships are also exposed as first-class helpers: + +```ts +const relations = await ctx.issues.relations.get(child.id, companyId); +await ctx.issues.relations.setBlockedBy(child.id, [planningIssueId], companyId); +await ctx.issues.relations.addBlockers(child.id, [validationIssueId], companyId); +await ctx.issues.relations.removeBlockers(child.id, [planningIssueId], companyId); +``` + +Subtree reads can include just the issue tree, or compact related data for orchestration dashboards: + +```ts +const subtree = await ctx.issues.getSubtree(missionIssueId, companyId, { + includeRoot: true, + includeRelations: true, + includeDocuments: true, + includeActiveRuns: true, + includeAssignees: true, +}); +``` + +Agent-run actions can assert checkout ownership before mutating in-progress work: + +```ts +await ctx.issues.assertCheckoutOwner({ + issueId, + companyId, + actorAgentId: runCtx.agentId, + actorRunId: runCtx.runId, +}); +``` + +Plugins can request assignment wakeups through the host so budget stops, execution locks, blocker checks, and heartbeat policy still apply: + +```ts +await ctx.issues.requestWakeup(child.id, companyId, { + reason: "mission_advance", + contextSource: "missions.advance", +}); + +await ctx.issues.requestWakeups([featureIssueId, validationIssueId], companyId, { + reason: "mission_advance", + contextSource: "missions.advance", + idempotencyKeyPrefix: `mission:${missionIssueId}:advance`, +}); +``` + +Use `ctx.issues.summaries.getOrchestration()` when a workflow needs compact reads across a root issue or subtree: + +```ts +const summary = await ctx.issues.summaries.getOrchestration({ + issueId: missionIssueId, + companyId, + includeSubtree: true, + billingCode: "mission:alpha", +}); +``` + +Required capabilities: + +| API | Capability | +|-----|------------| +| `ctx.issues.relations.get` | `issue.relations.read` | +| `ctx.issues.relations.setBlockedBy` / `addBlockers` / `removeBlockers` | `issue.relations.write` | +| `ctx.issues.getSubtree` | `issue.subtree.read` | +| `ctx.issues.assertCheckoutOwner` | `issues.checkout` | +| `ctx.issues.requestWakeup` / `requestWakeups` | `issues.wakeup` | +| `ctx.issues.summaries.getOrchestration` | `issues.orchestration.read` | + +Plugin-originated mutations are logged with `actorType: "plugin"` and details fields `sourcePluginId`, `sourcePluginKey`, `initiatingActorType`, `initiatingActorId`, and `initiatingRunId` when a user or agent run initiated the plugin work. + ## UI quick start ```tsx diff --git a/packages/plugins/sdk/package.json b/packages/plugins/sdk/package.json index 851c2d9..58e6ef6 100644 --- a/packages/plugins/sdk/package.json +++ b/packages/plugins/sdk/package.json @@ -123,4 +123,4 @@ "optional": true } } -} \ No newline at end of file +} diff --git a/packages/plugins/sdk/src/define-plugin.ts b/packages/plugins/sdk/src/define-plugin.ts index 0ac0581..5f043bf 100644 --- a/packages/plugins/sdk/src/define-plugin.ts +++ b/packages/plugins/sdk/src/define-plugin.ts @@ -107,6 +107,30 @@ export interface PluginWebhookInput { requestId: string; } +export interface PluginApiRequestInput { + routeKey: string; + method: string; + path: string; + params: Record; + query: Record; + body: unknown; + actor: { + actorType: "user" | "agent"; + actorId: string; + agentId?: string | null; + userId?: string | null; + runId?: string | null; + }; + companyId: string; + headers: Record; +} + +export interface PluginApiResponse { + status?: number; + headers?: Record; + body?: unknown; +} + // --------------------------------------------------------------------------- // Plugin definition // --------------------------------------------------------------------------- @@ -197,6 +221,13 @@ export interface PluginDefinition { * @see PLUGIN_SPEC.md §13.7 — `handleWebhook` */ onWebhook?(input: PluginWebhookInput): Promise; + + /** + * Called for manifest-declared scoped JSON API routes under + * `/api/plugins/:pluginId/api/*` after the host has enforced auth, company + * access, capabilities, and checkout policy. + */ + onApiRequest?(input: PluginApiRequestInput): Promise; } // --------------------------------------------------------------------------- diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index f38183d..d1fd592 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -97,6 +97,13 @@ export interface HostServices { delete(params: WorkerToHostMethods["state.delete"][0]): Promise; }; + /** Provides restricted plugin database namespace methods. */ + db: { + namespace(params: WorkerToHostMethods["db.namespace"][0]): Promise; + query(params: WorkerToHostMethods["db.query"][0]): Promise; + execute(params: WorkerToHostMethods["db.execute"][0]): Promise; + }; + /** Provides `entities.upsert`, `entities.list`. */ entities: { upsert(params: WorkerToHostMethods["entities.upsert"][0]): Promise; @@ -160,14 +167,24 @@ export interface HostServices { getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise; }; - /** Provides `issues.list`, `issues.get`, `issues.create`, `issues.update`, `issues.listComments`, `issues.createComment`. */ + /** Provides issue read/write, relation, checkout, wakeup, summary, comment methods. */ issues: { list(params: WorkerToHostMethods["issues.list"][0]): Promise; get(params: WorkerToHostMethods["issues.get"][0]): Promise; create(params: WorkerToHostMethods["issues.create"][0]): Promise; update(params: WorkerToHostMethods["issues.update"][0]): Promise; + getRelations(params: WorkerToHostMethods["issues.relations.get"][0]): Promise; + setBlockedBy(params: WorkerToHostMethods["issues.relations.setBlockedBy"][0]): Promise; + addBlockers(params: WorkerToHostMethods["issues.relations.addBlockers"][0]): Promise; + removeBlockers(params: WorkerToHostMethods["issues.relations.removeBlockers"][0]): Promise; + assertCheckoutOwner(params: WorkerToHostMethods["issues.assertCheckoutOwner"][0]): Promise; + getSubtree(params: WorkerToHostMethods["issues.getSubtree"][0]): Promise; + requestWakeup(params: WorkerToHostMethods["issues.requestWakeup"][0]): Promise; + requestWakeups(params: WorkerToHostMethods["issues.requestWakeups"][0]): Promise; + getOrchestrationSummary(params: WorkerToHostMethods["issues.summaries.getOrchestration"][0]): Promise; listComments(params: WorkerToHostMethods["issues.listComments"][0]): Promise; createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise; + createInteraction(params: WorkerToHostMethods["issues.createInteraction"][0]): Promise; }; /** Provides `issues.documents.list`, `issues.documents.get`, `issues.documents.upsert`, `issues.documents.delete`. */ @@ -269,6 +286,10 @@ const METHOD_CAPABILITY_MAP: Record { + return services.db.namespace(params); + }), + "db.query": gated("db.query", async (params) => { + return services.db.query(params); + }), + "db.execute": gated("db.execute", async (params) => { + return services.db.execute(params); + }), + // Entities "entities.upsert": gated("entities.upsert", async (params) => { return services.entities.upsert(params); @@ -503,12 +544,42 @@ export function createHostClientHandlers( "issues.update": gated("issues.update", async (params) => { return services.issues.update(params); }), + "issues.relations.get": gated("issues.relations.get", async (params) => { + return services.issues.getRelations(params); + }), + "issues.relations.setBlockedBy": gated("issues.relations.setBlockedBy", async (params) => { + return services.issues.setBlockedBy(params); + }), + "issues.relations.addBlockers": gated("issues.relations.addBlockers", async (params) => { + return services.issues.addBlockers(params); + }), + "issues.relations.removeBlockers": gated("issues.relations.removeBlockers", async (params) => { + return services.issues.removeBlockers(params); + }), + "issues.assertCheckoutOwner": gated("issues.assertCheckoutOwner", async (params) => { + return services.issues.assertCheckoutOwner(params); + }), + "issues.getSubtree": gated("issues.getSubtree", async (params) => { + return services.issues.getSubtree(params); + }), + "issues.requestWakeup": gated("issues.requestWakeup", async (params) => { + return services.issues.requestWakeup(params); + }), + "issues.requestWakeups": gated("issues.requestWakeups", async (params) => { + return services.issues.requestWakeups(params); + }), + "issues.summaries.getOrchestration": gated("issues.summaries.getOrchestration", async (params) => { + return services.issues.getOrchestrationSummary(params); + }), "issues.listComments": gated("issues.listComments", async (params) => { return services.issues.listComments(params); }), "issues.createComment": gated("issues.createComment", async (params) => { return services.issues.createComment(params); }), + "issues.createInteraction": gated("issues.createInteraction", async (params) => { + return services.issues.createInteraction(params); + }), // Issue Documents "issues.documents.list": gated("issues.documents.list", async (params) => { diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index d53bd27..0fe6300 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -95,6 +95,8 @@ export type { PluginHealthDiagnostics, PluginConfigValidationResult, PluginWebhookInput, + PluginApiRequestInput, + PluginApiResponse, } from "./define-plugin.js"; export type { TestHarness, @@ -171,6 +173,22 @@ export type { PluginProjectsClient, PluginCompaniesClient, PluginIssuesClient, + PluginIssueMutationActor, + PluginIssueRelationsClient, + PluginIssueRelationSummary, + PluginIssueCheckoutOwnership, + PluginIssueWakeupResult, + PluginIssueWakeupBatchResult, + PluginIssueRunSummary, + PluginIssueApprovalSummary, + PluginIssueCostSummary, + PluginBudgetIncidentSummary, + PluginIssueInvocationBlockSummary, + PluginIssueOrchestrationSummary, + PluginIssueSubtreeOptions, + PluginIssueAssigneeSummary, + PluginIssueSubtree, + PluginIssueSummariesClient, PluginAgentsClient, PluginAgentSessionsClient, AgentSession, @@ -203,8 +221,10 @@ export type { Project, Issue, IssueComment, + IssueDocumentSummary, Agent, Goal, + PluginDatabaseClient, } from "./types.js"; // Manifest and constant types re-exported from @taskcore/shared @@ -221,7 +241,12 @@ export type { PluginLauncherRenderDeclaration, PluginLauncherDeclaration, PluginMinimumHostVersion, + PluginDatabaseDeclaration, + PluginApiRouteCompanyResolution, + PluginApiRouteDeclaration, PluginRecord, + PluginDatabaseNamespaceRecord, + PluginMigrationRecord, PluginConfig, JsonSchema, PluginStatus, @@ -238,6 +263,13 @@ export type { PluginJobRunStatus, PluginJobRunTrigger, PluginWebhookDeliveryStatus, + PluginDatabaseCoreReadTable, + PluginDatabaseMigrationStatus, + PluginDatabaseNamespaceMode, + PluginDatabaseNamespaceStatus, + PluginApiRouteAuthMode, + PluginApiRouteCheckoutPolicy, + PluginApiRouteMethod, PluginEventType, PluginBridgeErrorCode, } from "./types.js"; diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index db1a352..d24646b 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -27,6 +27,8 @@ import type { IssueComment, IssueDocument, IssueDocumentSummary, + IssueThreadInteraction, + CreateIssueThreadInteraction, Agent, Goal, } from "@taskcore/shared"; @@ -34,6 +36,12 @@ export type { PluginLauncherRenderContextSnapshot } from "@taskcore/shared"; import type { PluginEvent, + PluginIssueCheckoutOwnership, + PluginIssueOrchestrationSummary, + PluginIssueRelationSummary, + PluginIssueSubtree, + PluginIssueWakeupBatchResult, + PluginIssueWakeupResult, PluginJobContext, PluginWorkspace, ToolRunContext, @@ -41,6 +49,8 @@ import type { } from "./types.js"; import type { PluginHealthDiagnostics, + PluginApiRequestInput, + PluginApiResponse, PluginConfigValidationResult, PluginWebhookInput, } from "./define-plugin.js"; @@ -219,6 +229,8 @@ export interface InitializeParams { }; /** Host API version. */ apiVersion: number; + /** Host-derived plugin database namespace, when the manifest declares database access. */ + databaseNamespace?: string | null; } /** @@ -341,12 +353,12 @@ export interface PluginModalBoundsRequest { */ export interface PluginRenderCloseEvent { reason: - | "escapeKey" - | "backdrop" - | "hostNavigation" - | "programmatic" - | "submit" - | "unknown"; + | "escapeKey" + | "backdrop" + | "hostNavigation" + | "programmatic" + | "submit" + | "unknown"; nativeEvent?: unknown; } @@ -374,6 +386,8 @@ export interface HostToWorkerMethods { runJob: [params: RunJobParams, result: void]; /** @see PLUGIN_SPEC.md §13.7 */ handleWebhook: [params: PluginWebhookInput, result: void]; + /** Scoped plugin API route dispatch. */ + handleApiRequest: [params: PluginApiRequestInput, result: PluginApiResponse]; /** @see PLUGIN_SPEC.md §13.8 */ getData: [params: GetDataParams, result: unknown]; /** @see PLUGIN_SPEC.md §13.9 */ @@ -399,6 +413,7 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[] "onEvent", "runJob", "handleWebhook", + "handleApiRequest", "getData", "performAction", "executeTool", @@ -432,6 +447,20 @@ export interface WorkerToHostMethods { result: void, ]; + // Restricted plugin database namespace + "db.namespace": [ + params: Record, + result: string, + ]; + "db.query": [ + params: { sql: string; params?: unknown[] }, + result: unknown[], + ]; + "db.execute": [ + params: { sql: string; params?: unknown[] }, + result: { rowCount: number }, + ]; + // Entities "entities.upsert": [ params: { @@ -569,6 +598,8 @@ export interface WorkerToHostMethods { companyId: string; projectId?: string; assigneeAgentId?: string; + originKind?: string; + originId?: string; status?: string; limit?: number; offset?: number; @@ -588,8 +619,23 @@ export interface WorkerToHostMethods { inheritExecutionWorkspaceFromIssueId?: string; title: string; description?: string; + status?: string; priority?: string; assigneeAgentId?: string; + assigneeUserId?: string | null; + requestDepth?: number; + billingCode?: string | null; + originKind?: string | null; + originId?: string | null; + originRunId?: string | null; + blockedByIssueIds?: string[]; + labelIds?: string[]; + executionWorkspaceId?: string | null; + executionWorkspacePreference?: string | null; + executionWorkspaceSettings?: Record | null; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; }, result: Issue, ]; @@ -601,6 +647,99 @@ export interface WorkerToHostMethods { }, result: Issue, ]; + "issues.relations.get": [ + params: { issueId: string; companyId: string }, + result: PluginIssueRelationSummary, + ]; + "issues.relations.setBlockedBy": [ + params: { + issueId: string; + companyId: string; + blockedByIssueIds: string[]; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; + }, + result: PluginIssueRelationSummary, + ]; + "issues.relations.addBlockers": [ + params: { + issueId: string; + companyId: string; + blockerIssueIds: string[]; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; + }, + result: PluginIssueRelationSummary, + ]; + "issues.relations.removeBlockers": [ + params: { + issueId: string; + companyId: string; + blockerIssueIds: string[]; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; + }, + result: PluginIssueRelationSummary, + ]; + "issues.assertCheckoutOwner": [ + params: { + issueId: string; + companyId: string; + actorAgentId: string; + actorRunId: string; + }, + result: PluginIssueCheckoutOwnership, + ]; + "issues.getSubtree": [ + params: { + issueId: string; + companyId: string; + includeRoot?: boolean; + includeRelations?: boolean; + includeDocuments?: boolean; + includeActiveRuns?: boolean; + includeAssignees?: boolean; + }, + result: PluginIssueSubtree, + ]; + "issues.requestWakeup": [ + params: { + issueId: string; + companyId: string; + reason?: string; + contextSource?: string; + idempotencyKey?: string | null; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; + }, + result: PluginIssueWakeupResult, + ]; + "issues.requestWakeups": [ + params: { + issueIds: string[]; + companyId: string; + reason?: string; + contextSource?: string; + idempotencyKeyPrefix?: string | null; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; + }, + result: PluginIssueWakeupBatchResult[], + ]; + "issues.summaries.getOrchestration": [ + params: { + issueId: string; + companyId: string; + includeSubtree?: boolean; + billingCode?: string | null; + }, + result: PluginIssueOrchestrationSummary, + ]; "issues.listComments": [ params: { issueId: string; companyId: string }, result: IssueComment[], @@ -609,6 +748,15 @@ export interface WorkerToHostMethods { params: { issueId: string; body: string; companyId: string; authorAgentId?: string }, result: IssueComment, ]; + "issues.createInteraction": [ + params: { + issueId: string; + companyId: string; + interaction: CreateIssueThreadInteraction; + authorAgentId?: string | null; + }, + result: IssueThreadInteraction, + ]; // Issue Documents "issues.documents.list": [ diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 616a1fe..bf9493e 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -3,10 +3,14 @@ import type { TaskcorePluginManifestV1, PluginCapability, PluginEventType, + PluginIssueOriginKind, Company, Project, Issue, IssueComment, + IssueThreadInteraction, + CreateIssueThreadInteraction, + IssueDocument, Agent, Goal, } from "@taskcore/shared"; @@ -72,6 +76,8 @@ export interface TestHarness { activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record }>; metrics: Array<{ name: string; value: number; tags?: Record }>; telemetry: Array<{ eventName: string; dimensions?: Record }>; + dbQueries: Array<{ sql: string; params?: unknown[] }>; + dbExecutes: Array<{ sql: string; params?: unknown[] }>; } type EventRegistration = { @@ -134,6 +140,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const activity: TestHarness["activity"] = []; const metrics: TestHarness["metrics"] = []; const telemetry: TestHarness["telemetry"] = []; + const dbQueries: TestHarness["dbQueries"] = []; + const dbExecutes: TestHarness["dbExecutes"] = []; const state = new Map(); const entities = new Map(); @@ -141,7 +149,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const companies = new Map(); const projects = new Map(); const issues = new Map(); + const blockedByIssueIds = new Map(); const issueComments = new Map(); + const issueInteractions = new Map(); + const issueDocuments = new Map(); const agents = new Map(); const goals = new Map(); const projectWorkspaces = new Map(); @@ -156,6 +167,42 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const actionHandlers = new Map) => Promise>(); const toolHandlers = new Map Promise>(); + function issueRelationSummary(issueId: string) { + const issue = issues.get(issueId); + if (!issue) throw new Error(`Issue not found: ${issueId}`); + const summarize = (candidateId: string) => { + const related = issues.get(candidateId); + if (!related || related.companyId !== issue.companyId) return null; + return { + id: related.id, + identifier: related.identifier, + title: related.title, + status: related.status, + priority: related.priority, + assigneeAgentId: related.assigneeAgentId, + assigneeUserId: related.assigneeUserId, + }; + }; + const blockedBy = (blockedByIssueIds.get(issueId) ?? []) + .map(summarize) + .filter((value): value is NonNullable => value !== null); + const blocks = [...blockedByIssueIds.entries()] + .filter(([, blockers]) => blockers.includes(issueId)) + .map(([blockedIssueId]) => summarize(blockedIssueId)) + .filter((value): value is NonNullable => value !== null); + return { blockedBy, blocks }; + } + + const defaultPluginOriginKind: PluginIssueOriginKind = `plugin:${manifest.id}`; + function normalizePluginOriginKind(originKind: unknown = defaultPluginOriginKind): PluginIssueOriginKind { + if (originKind == null || originKind === "") return defaultPluginOriginKind; + if (typeof originKind !== "string") throw new Error("Plugin issue originKind must be a string"); + if (originKind === defaultPluginOriginKind || originKind.startsWith(`${defaultPluginOriginKind}:`)) { + return originKind as PluginIssueOriginKind; + } + throw new Error(`Plugin may only use originKind values under ${defaultPluginOriginKind}`); + } + const ctx: PluginContext = { manifest, config: { @@ -195,6 +242,19 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { launchers.set(launcher.id, launcher); }, }, + db: { + namespace: manifest.database ? `test_${manifest.id.replace(/[^a-z0-9_]+/g, "_")}` : "", + async query(sql, params) { + requireCapability(manifest, capabilitySet, "database.namespace.read"); + dbQueries.push({ sql, params }); + return []; + }, + async execute(sql, params) { + requireCapability(manifest, capabilitySet, "database.namespace.write"); + dbExecutes.push({ sql, params }); + return { rowCount: 0 }; + }, + }, http: { async fetch(url, init) { requireCapability(manifest, capabilitySet, "http.outbound"); @@ -338,6 +398,11 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { out = out.filter((issue) => issue.companyId === companyId); if (input?.projectId) out = out.filter((issue) => issue.projectId === input.projectId); if (input?.assigneeAgentId) out = out.filter((issue) => issue.assigneeAgentId === input.assigneeAgentId); + if (input?.originKind) { + if (input.originKind.startsWith("plugin:")) normalizePluginOriginKind(input.originKind); + out = out.filter((issue) => issue.originKind === input.originKind); + } + if (input?.originId) out = out.filter((issue) => issue.originId === input.originId); if (input?.status) out = out.filter((issue) => issue.status === input.status); if (input?.offset) out = out.slice(input.offset); if (input?.limit) out = out.slice(0, input.limit); @@ -360,10 +425,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { parentId: input.parentId ?? null, title: input.title, description: input.description ?? null, - status: "todo", + status: input.status ?? "todo", priority: input.priority ?? "medium", assigneeAgentId: input.assigneeAgentId ?? null, - assigneeUserId: null, + assigneeUserId: input.assigneeUserId ?? null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, @@ -372,12 +437,15 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { createdByUserId: null, issueNumber: null, identifier: null, - requestDepth: 0, - billingCode: null, + originKind: normalizePluginOriginKind(input.originKind), + originId: input.originId ?? null, + originRunId: input.originRunId ?? null, + requestDepth: input.requestDepth ?? 0, + billingCode: input.billingCode ?? null, assigneeAdapterOverrides: null, - executionWorkspaceId: null, - executionWorkspacePreference: null, - executionWorkspaceSettings: null, + executionWorkspaceId: input.executionWorkspaceId ?? null, + executionWorkspacePreference: input.executionWorkspacePreference ?? null, + executionWorkspaceSettings: input.executionWorkspaceSettings ?? null, startedAt: null, completedAt: null, cancelledAt: null, @@ -386,20 +454,75 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { updatedAt: now, }; issues.set(record.id, record); + if (input.blockedByIssueIds) blockedByIssueIds.set(record.id, [...new Set(input.blockedByIssueIds)]); return record; }, async update(issueId, patch, companyId) { requireCapability(manifest, capabilitySet, "issues.update"); const record = issues.get(issueId); if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`); + const { blockedByIssueIds: nextBlockedByIssueIds, ...issuePatch } = patch; + if (issuePatch.originKind !== undefined) { + issuePatch.originKind = normalizePluginOriginKind(issuePatch.originKind); + } const updated: Issue = { ...record, - ...patch, + ...issuePatch, updatedAt: new Date(), }; issues.set(issueId, updated); + if (nextBlockedByIssueIds !== undefined) { + blockedByIssueIds.set(issueId, [...new Set(nextBlockedByIssueIds)]); + } return updated; }, + async assertCheckoutOwner(input) { + requireCapability(manifest, capabilitySet, "issues.checkout"); + const record = issues.get(input.issueId); + if (!isInCompany(record, input.companyId)) throw new Error(`Issue not found: ${input.issueId}`); + if ( + record.status !== "in_progress" || + record.assigneeAgentId !== input.actorAgentId || + (record.checkoutRunId !== null && record.checkoutRunId !== input.actorRunId) + ) { + throw new Error("Issue run ownership conflict"); + } + return { + issueId: record.id, + status: record.status, + assigneeAgentId: record.assigneeAgentId, + checkoutRunId: record.checkoutRunId, + adoptedFromRunId: null, + }; + }, + async requestWakeup(issueId, companyId) { + requireCapability(manifest, capabilitySet, "issues.wakeup"); + const record = issues.get(issueId); + if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`); + if (!record.assigneeAgentId) throw new Error("Issue has no assigned agent to wake"); + if (["backlog", "done", "cancelled"].includes(record.status)) { + throw new Error(`Issue is not wakeable in status: ${record.status}`); + } + const unresolved = issueRelationSummary(issueId).blockedBy.filter((blocker) => blocker.status !== "done"); + if (unresolved.length > 0) throw new Error("Issue is blocked by unresolved blockers"); + return { queued: true, runId: randomUUID() }; + }, + async requestWakeups(issueIds, companyId) { + requireCapability(manifest, capabilitySet, "issues.wakeup"); + const results = []; + for (const issueId of issueIds) { + const record = issues.get(issueId); + if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`); + if (!record.assigneeAgentId) throw new Error("Issue has no assigned agent to wake"); + if (["backlog", "done", "cancelled"].includes(record.status)) { + throw new Error(`Issue is not wakeable in status: ${record.status}`); + } + const unresolved = issueRelationSummary(issueId).blockedBy.filter((blocker) => blocker.status !== "done"); + if (unresolved.length > 0) throw new Error("Issue is blocked by unresolved blockers"); + results.push({ issueId, queued: true, runId: randomUUID() }); + } + return results; + }, async listComments(issueId, companyId) { requireCapability(manifest, capabilitySet, "issue.comments.read"); if (!isInCompany(issues.get(issueId), companyId)) return []; @@ -427,16 +550,62 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { issueComments.set(issueId, current); return comment; }, + async createInteraction(issueId, interaction, companyId, options) { + requireCapability(manifest, capabilitySet, "issue.interactions.create"); + const parentIssue = issues.get(issueId); + if (!isInCompany(parentIssue, companyId)) { + throw new Error(`Issue not found: ${issueId}`); + } + const now = new Date(); + const current = issueInteractions.get(issueId) ?? []; + if (interaction.idempotencyKey) { + const existing = current.find((entry) => entry.idempotencyKey === interaction.idempotencyKey); + if (existing) return existing; + } + const created: IssueThreadInteraction = { + id: randomUUID(), + companyId: parentIssue.companyId, + issueId, + kind: interaction.kind, + status: "pending", + continuationPolicy: interaction.continuationPolicy ?? "wake_assignee", + idempotencyKey: interaction.idempotencyKey ?? null, + sourceCommentId: interaction.sourceCommentId ?? null, + sourceRunId: interaction.sourceRunId ?? null, + title: interaction.title ?? null, + summary: interaction.summary ?? null, + createdByAgentId: options?.authorAgentId ?? null, + createdByUserId: null, + payload: interaction.payload, + result: null, + createdAt: now, + updatedAt: now, + } as IssueThreadInteraction; + current.push(created); + issueInteractions.set(issueId, current); + return created; + }, + async suggestTasks(issueId, interaction, companyId, options) { + return this.createInteraction(issueId, { ...interaction, kind: "suggest_tasks" }, companyId, options) as Promise; + }, + async askUserQuestions(issueId, interaction, companyId, options) { + return this.createInteraction(issueId, { ...interaction, kind: "ask_user_questions" }, companyId, options) as Promise; + }, + async requestConfirmation(issueId, interaction, companyId, options) { + return this.createInteraction(issueId, { ...interaction, kind: "request_confirmation" }, companyId, options) as Promise; + }, documents: { async list(issueId, companyId) { requireCapability(manifest, capabilitySet, "issue.documents.read"); if (!isInCompany(issues.get(issueId), companyId)) return []; - return []; + return [...issueDocuments.values()] + .filter((document) => document.issueId === issueId && document.companyId === companyId) + .map(({ body: _body, ...summary }) => summary); }, - async get(issueId, _key, companyId) { + async get(issueId, key, companyId) { requireCapability(manifest, capabilitySet, "issue.documents.read"); if (!isInCompany(issues.get(issueId), companyId)) return null; - return null; + return issueDocuments.get(`${issueId}|${key}`) ?? null; }, async upsert(input) { requireCapability(manifest, capabilitySet, "issue.documents.write"); @@ -444,7 +613,27 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { if (!isInCompany(parentIssue, input.companyId)) { throw new Error(`Issue not found: ${input.issueId}`); } - throw new Error("documents.upsert is not implemented in test context"); + const now = new Date(); + const existing = issueDocuments.get(`${input.issueId}|${input.key}`); + const document: IssueDocument = { + id: existing?.id ?? randomUUID(), + companyId: input.companyId, + issueId: input.issueId, + key: input.key, + title: input.title ?? existing?.title ?? null, + format: "markdown", + latestRevisionId: randomUUID(), + latestRevisionNumber: (existing?.latestRevisionNumber ?? 0) + 1, + createdByAgentId: existing?.createdByAgentId ?? null, + createdByUserId: existing?.createdByUserId ?? null, + updatedByAgentId: null, + updatedByUserId: null, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + body: input.body, + }; + issueDocuments.set(`${input.issueId}|${input.key}`, document); + return document; }, async delete(issueId, _key, companyId) { requireCapability(manifest, capabilitySet, "issue.documents.write"); @@ -452,6 +641,104 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { if (!isInCompany(parentIssue, companyId)) { throw new Error(`Issue not found: ${issueId}`); } + issueDocuments.delete(`${issueId}|${_key}`); + }, + }, + relations: { + async get(issueId, companyId) { + requireCapability(manifest, capabilitySet, "issue.relations.read"); + if (!isInCompany(issues.get(issueId), companyId)) throw new Error(`Issue not found: ${issueId}`); + return issueRelationSummary(issueId); + }, + async setBlockedBy(issueId, nextBlockedByIssueIds, companyId) { + requireCapability(manifest, capabilitySet, "issue.relations.write"); + if (!isInCompany(issues.get(issueId), companyId)) throw new Error(`Issue not found: ${issueId}`); + blockedByIssueIds.set(issueId, [...new Set(nextBlockedByIssueIds)]); + return issueRelationSummary(issueId); + }, + async addBlockers(issueId, blockerIssueIds, companyId) { + requireCapability(manifest, capabilitySet, "issue.relations.write"); + if (!isInCompany(issues.get(issueId), companyId)) throw new Error(`Issue not found: ${issueId}`); + const next = new Set(blockedByIssueIds.get(issueId) ?? []); + for (const blockerIssueId of blockerIssueIds) next.add(blockerIssueId); + blockedByIssueIds.set(issueId, [...next]); + return issueRelationSummary(issueId); + }, + async removeBlockers(issueId, blockerIssueIds, companyId) { + requireCapability(manifest, capabilitySet, "issue.relations.write"); + if (!isInCompany(issues.get(issueId), companyId)) throw new Error(`Issue not found: ${issueId}`); + const removals = new Set(blockerIssueIds); + blockedByIssueIds.set( + issueId, + (blockedByIssueIds.get(issueId) ?? []).filter((blockerIssueId) => !removals.has(blockerIssueId)), + ); + return issueRelationSummary(issueId); + }, + }, + async getSubtree(issueId, companyId, options) { + requireCapability(manifest, capabilitySet, "issue.subtree.read"); + const root = issues.get(issueId); + if (!isInCompany(root, companyId)) throw new Error(`Issue not found: ${issueId}`); + const includeRoot = options?.includeRoot !== false; + const allIds = [root.id]; + let frontier = [root.id]; + while (frontier.length > 0) { + const children = [...issues.values()] + .filter((issue) => issue.companyId === companyId && frontier.includes(issue.parentId ?? "")) + .map((issue) => issue.id) + .filter((id) => !allIds.includes(id)); + allIds.push(...children); + frontier = children; + } + const issueIds = includeRoot ? allIds : allIds.filter((id) => id !== root.id); + const subtreeIssues = issueIds.map((id) => issues.get(id)).filter((candidate): candidate is Issue => Boolean(candidate)); + return { + rootIssueId: root.id, + companyId, + issueIds, + issues: subtreeIssues, + ...(options?.includeRelations + ? { relations: Object.fromEntries(issueIds.map((id) => [id, issueRelationSummary(id)])) } + : {}), + ...(options?.includeDocuments ? { documents: Object.fromEntries(issueIds.map((id) => [id, []])) } : {}), + ...(options?.includeActiveRuns ? { activeRuns: Object.fromEntries(issueIds.map((id) => [id, []])) } : {}), + ...(options?.includeAssignees ? { assignees: {} } : {}), + }; + }, + summaries: { + async getOrchestration(input) { + requireCapability(manifest, capabilitySet, "issues.orchestration.read"); + const root = issues.get(input.issueId); + if (!isInCompany(root, input.companyId)) throw new Error(`Issue not found: ${input.issueId}`); + const subtreeIssueIds = [root.id]; + if (input.includeSubtree) { + let frontier = [root.id]; + while (frontier.length > 0) { + const children = [...issues.values()] + .filter((issue) => issue.companyId === input.companyId && frontier.includes(issue.parentId ?? "")) + .map((issue) => issue.id) + .filter((id) => !subtreeIssueIds.includes(id)); + subtreeIssueIds.push(...children); + frontier = children; + } + } + return { + issueId: root.id, + companyId: input.companyId, + subtreeIssueIds, + relations: Object.fromEntries(subtreeIssueIds.map((id) => [id, issueRelationSummary(id)])), + approvals: [], + runs: [], + costs: { + costCents: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + billingCode: input.billingCode ?? null, + }, + openBudgetIncidents: [], + invocationBlocks: [], + }; }, }, }, @@ -660,7 +947,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { seed(input) { for (const row of input.companies ?? []) companies.set(row.id, row); for (const row of input.projects ?? []) projects.set(row.id, row); - for (const row of input.issues ?? []) issues.set(row.id, row); + for (const row of input.issues ?? []) { + issues.set(row.id, row); + if (row.blockedBy) { + blockedByIssueIds.set(row.id, row.blockedBy.map((blocker) => blocker.id)); + } + } for (const row of input.issueComments ?? []) { const list = issueComments.get(row.issueId) ?? []; list.push(row); @@ -738,6 +1030,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { activity, metrics, telemetry, + dbQueries, + dbExecutes, }; return harness; diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 5d0800c..b30cc10 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -21,6 +21,13 @@ import type { IssueComment, IssueDocument, IssueDocumentSummary, + IssueRelationIssueSummary, + IssueThreadInteraction, + SuggestTasksInteraction, + AskUserQuestionsInteraction, + RequestConfirmationInteraction, + CreateIssueThreadInteraction, + PluginIssueOriginKind, Agent, Goal, } from "@taskcore/shared"; @@ -40,7 +47,12 @@ export type { PluginLauncherRenderDeclaration, PluginLauncherDeclaration, PluginMinimumHostVersion, + PluginDatabaseDeclaration, + PluginApiRouteDeclaration, + PluginApiRouteCompanyResolution, PluginRecord, + PluginDatabaseNamespaceRecord, + PluginMigrationRecord, PluginConfig, JsonSchema, PluginStatus, @@ -57,6 +69,13 @@ export type { PluginJobRunStatus, PluginJobRunTrigger, PluginWebhookDeliveryStatus, + PluginDatabaseCoreReadTable, + PluginDatabaseMigrationStatus, + PluginDatabaseNamespaceMode, + PluginDatabaseNamespaceStatus, + PluginApiRouteAuthMode, + PluginApiRouteCheckoutPolicy, + PluginApiRouteMethod, PluginEventType, PluginBridgeErrorCode, Company, @@ -65,6 +84,13 @@ export type { IssueComment, IssueDocument, IssueDocumentSummary, + IssueRelationIssueSummary, + IssueThreadInteraction, + SuggestTasksInteraction, + AskUserQuestionsInteraction, + RequestConfirmationInteraction, + CreateIssueThreadInteraction, + PluginIssueOriginKind, Agent, Goal, } from "@taskcore/shared"; @@ -407,6 +433,17 @@ export interface PluginLaunchersClient { register(launcher: PluginLauncherRegistration): void; } +export interface PluginDatabaseClient { + /** Host-derived PostgreSQL schema name for this plugin's namespace. */ + namespace: string; + + /** Run a restricted SELECT against the plugin namespace and whitelisted core tables. */ + query>(sql: string, params?: unknown[]): Promise; + + /** Run a restricted INSERT, UPDATE, or DELETE against the plugin namespace. */ + execute(sql: string, params?: unknown[]): Promise<{ rowCount: number }>; +} + /** * `ctx.http` — make outbound HTTP requests. * @@ -867,6 +904,178 @@ export interface PluginIssueDocumentsClient { delete(issueId: string, key: string, companyId: string): Promise; } +export interface PluginIssueMutationActor { + /** Agent that initiated the plugin operation, when the plugin is acting from an agent run. */ + actorAgentId?: string | null; + /** Board/user that initiated the plugin operation, when known. */ + actorUserId?: string | null; + /** Heartbeat run that initiated the operation. Required for checkout-aware agent actions. */ + actorRunId?: string | null; +} + +export interface PluginIssueRelationSummary { + blockedBy: IssueRelationIssueSummary[]; + blocks: IssueRelationIssueSummary[]; +} + +export interface PluginIssueRelationsClient { + /** Read blocker relationships for an issue. Requires `issue.relations.read`. */ + get(issueId: string, companyId: string): Promise; + /** Replace the issue's blocked-by relation set. Requires `issue.relations.write`. */ + setBlockedBy( + issueId: string, + blockedByIssueIds: string[], + companyId: string, + actor?: PluginIssueMutationActor, + ): Promise; + /** Add one or more blockers while preserving existing blockers. Requires `issue.relations.write`. */ + addBlockers( + issueId: string, + blockerIssueIds: string[], + companyId: string, + actor?: PluginIssueMutationActor, + ): Promise; + /** Remove one or more blockers while preserving all other blockers. Requires `issue.relations.write`. */ + removeBlockers( + issueId: string, + blockerIssueIds: string[], + companyId: string, + actor?: PluginIssueMutationActor, + ): Promise; +} + +export interface PluginIssueCheckoutOwnership { + issueId: string; + status: Issue["status"]; + assigneeAgentId: string | null; + checkoutRunId: string | null; + adoptedFromRunId: string | null; +} + +export interface PluginIssueWakeupResult { + queued: boolean; + runId: string | null; +} + +export interface PluginIssueWakeupBatchResult { + issueId: string; + queued: boolean; + runId: string | null; +} + +export interface PluginIssueRunSummary { + id: string; + issueId: string | null; + agentId: string; + status: string; + invocationSource: string; + triggerDetail: string | null; + startedAt: string | null; + finishedAt: string | null; + error: string | null; + createdAt: string; +} + +export interface PluginIssueApprovalSummary { + issueId: string; + id: string; + type: string; + status: string; + requestedByAgentId: string | null; + requestedByUserId: string | null; + decidedByUserId: string | null; + decidedAt: string | null; + createdAt: string; +} + +export interface PluginIssueCostSummary { + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + billingCode: string | null; +} + +export interface PluginBudgetIncidentSummary { + id: string; + scopeType: string; + scopeId: string; + metric: string; + windowKind: string; + thresholdType: string; + amountLimit: number; + amountObserved: number; + status: string; + approvalId: string | null; + createdAt: string; +} + +export interface PluginIssueInvocationBlockSummary { + issueId: string; + agentId: string; + scopeType: "company" | "agent" | "project"; + scopeId: string; + scopeName: string; + reason: string; +} + +export interface PluginIssueOrchestrationSummary { + issueId: string; + companyId: string; + subtreeIssueIds: string[]; + relations: Record; + approvals: PluginIssueApprovalSummary[]; + runs: PluginIssueRunSummary[]; + costs: PluginIssueCostSummary; + openBudgetIncidents: PluginBudgetIncidentSummary[]; + invocationBlocks: PluginIssueInvocationBlockSummary[]; +} + +export interface PluginIssueSubtreeOptions { + /** Include the root issue in the result. Defaults to true. */ + includeRoot?: boolean; + /** Include blocker relationship summaries keyed by issue ID. */ + includeRelations?: boolean; + /** Include issue document summaries keyed by issue ID. */ + includeDocuments?: boolean; + /** Include queued/running heartbeat runs keyed by issue ID. */ + includeActiveRuns?: boolean; + /** Include assignee summaries keyed by agent ID. */ + includeAssignees?: boolean; +} + +export interface PluginIssueAssigneeSummary { + id: string; + name: string; + role: string; + title: string | null; + status: Agent["status"]; +} + +export interface PluginIssueSubtree { + rootIssueId: string; + companyId: string; + issueIds: string[]; + issues: Issue[]; + relations?: Record; + documents?: Record; + activeRuns?: Record; + assignees?: Record; +} + +export interface PluginIssueSummariesClient { + /** + * Read the compact orchestration inputs a workflow plugin needs for an + * issue or issue subtree. Requires `issues.orchestration.read`. + */ + getOrchestration(input: { + issueId: string; + companyId: string; + includeSubtree?: boolean; + billingCode?: string | null; + }): Promise; +} + /** * `ctx.issues` — read and mutate issues plus comments. * @@ -874,8 +1083,12 @@ export interface PluginIssueDocumentsClient { * - `issues.read` for read operations * - `issues.create` for create * - `issues.update` for update + * - `issues.checkout` for checkout ownership assertions + * - `issues.wakeup` for assignment wakeup requests + * - `issues.orchestration.read` for orchestration summaries * - `issue.comments.read` for `listComments` * - `issue.comments.create` for `createComment` + * - `issue.interactions.create` for `createInteraction`, `suggestTasks`, `askUserQuestions`, and `requestConfirmation` * - `issue.documents.read` for `documents.list` and `documents.get` * - `issue.documents.write` for `documents.upsert` and `documents.delete` */ @@ -884,6 +1097,8 @@ export interface PluginIssuesClient { companyId: string; projectId?: string; assigneeAgentId?: string; + originKind?: PluginIssueOriginKind; + originId?: string; status?: Issue["status"]; limit?: number; offset?: number; @@ -897,17 +1112,80 @@ export interface PluginIssuesClient { inheritExecutionWorkspaceFromIssueId?: string; title: string; description?: string; + status?: Issue["status"]; priority?: Issue["priority"]; assigneeAgentId?: string; + assigneeUserId?: string | null; + requestDepth?: number; + billingCode?: string | null; + originKind?: PluginIssueOriginKind; + originId?: string | null; + originRunId?: string | null; + blockedByIssueIds?: string[]; + labelIds?: string[]; + executionWorkspaceId?: string | null; + executionWorkspacePreference?: string | null; + executionWorkspaceSettings?: Record | null; + actor?: PluginIssueMutationActor; }): Promise; update( issueId: string, patch: Partial>, + | "title" + | "description" + | "status" + | "priority" + | "assigneeAgentId" + | "assigneeUserId" + | "billingCode" + | "originKind" + | "originId" + | "originRunId" + | "requestDepth" + | "executionWorkspaceId" + | "executionWorkspacePreference" + >> & { + blockedByIssueIds?: string[]; + labelIds?: string[]; + executionWorkspaceSettings?: Record | null; + }, companyId: string, + actor?: PluginIssueMutationActor, ): Promise; + assertCheckoutOwner(input: { + issueId: string; + companyId: string; + actorAgentId: string; + actorRunId: string; + }): Promise; + /** + * Read a root issue's descendants with optional relation/document/run/assignee + * summaries. Requires `issue.subtree.read`. + */ + getSubtree( + issueId: string, + companyId: string, + options?: PluginIssueSubtreeOptions, + ): Promise; + requestWakeup( + issueId: string, + companyId: string, + options?: { + reason?: string; + contextSource?: string; + idempotencyKey?: string | null; + } & PluginIssueMutationActor, + ): Promise; + requestWakeups( + issueIds: string[], + companyId: string, + options?: { + reason?: string; + contextSource?: string; + idempotencyKeyPrefix?: string | null; + } & PluginIssueMutationActor, + ): Promise; listComments(issueId: string, companyId: string): Promise; createComment( issueId: string, @@ -915,8 +1193,36 @@ export interface PluginIssuesClient { companyId: string, options?: { authorAgentId?: string }, ): Promise; + createInteraction( + issueId: string, + interaction: CreateIssueThreadInteraction, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise; + suggestTasks( + issueId: string, + interaction: Omit, "kind">, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise; + askUserQuestions( + issueId: string, + interaction: Omit, "kind">, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise; + requestConfirmation( + issueId: string, + interaction: Omit, "kind">, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise; /** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */ documents: PluginIssueDocumentsClient; + /** Read and write blocker relationships. */ + relations: PluginIssueRelationsClient; + /** Read compact orchestration summaries. */ + summaries: PluginIssueSummariesClient; } /** @@ -1138,6 +1444,9 @@ export interface PluginContext { /** Register launcher metadata that the host can surface in plugin UI entry points. */ launchers: PluginLaunchersClient; + /** Restricted plugin-owned database namespace. Requires database namespace capabilities. */ + db: PluginDatabaseClient; + /** Make outbound HTTP requests. Requires `http.outbound`. */ http: PluginHttpClient; diff --git a/packages/plugins/sdk/src/ui/runtime.ts b/packages/plugins/sdk/src/ui/runtime.ts index 4fb1f67..d7bcc72 100644 --- a/packages/plugins/sdk/src/ui/runtime.ts +++ b/packages/plugins/sdk/src/ui/runtime.ts @@ -16,7 +16,7 @@ function getBridgeRegistry(): PluginBridgeRegistry | undefined { function missingBridgeValueError(name: string): Error { return new Error( `Taskcore plugin UI runtime is not initialized for "${name}". ` + - 'Ensure the host loaded the plugin bridge before rendering this UI module.', + 'Ensure the host loaded the plugin bridge before rendering this UI module.', ); } diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index dc2c8a8..09e84c3 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -38,10 +38,16 @@ import path from "node:path"; import { createInterface, type Interface as ReadlineInterface } from "node:readline"; import { fileURLToPath } from "node:url"; -import type { TaskcorePluginManifestV1 } from "@taskcore/shared"; +import type { + AskUserQuestionsInteraction, + TaskcorePluginManifestV1, + RequestConfirmationInteraction, + SuggestTasksInteraction, +} from "@taskcore/shared"; import type { TaskcorePlugin } from "./define-plugin.js"; import type { + PluginApiRequestInput, PluginHealthDiagnostics, PluginConfigValidationResult, PluginWebhookInput, @@ -250,6 +256,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost let initialized = false; let manifest: TaskcorePluginManifestV1 | null = null; let currentConfig: Record = {}; + let databaseNamespace: string | null = null; // Plugin handler registrations (populated during setup()) const eventHandlers: EventRegistration[] = []; @@ -416,6 +423,18 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, }, + db: { + get namespace() { + return databaseNamespace ?? ""; + }, + async query>(sql: string, params?: unknown[]): Promise { + return callHost("db.query", { sql, params }) as Promise; + }, + async execute(sql: string, params?: unknown[]) { + return callHost("db.execute", { sql, params }); + }, + }, + http: { async fetch(url: string, init?: RequestInit): Promise { const serializedInit: Record = {}; @@ -574,6 +593,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost companyId: input.companyId, projectId: input.projectId, assigneeAgentId: input.assigneeAgentId, + originKind: input.originKind, + originId: input.originId, status: input.status, limit: input.limit, offset: input.offset, @@ -593,19 +614,81 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost inheritExecutionWorkspaceFromIssueId: input.inheritExecutionWorkspaceFromIssueId, title: input.title, description: input.description, + status: input.status, priority: input.priority, assigneeAgentId: input.assigneeAgentId, + assigneeUserId: input.assigneeUserId, + requestDepth: input.requestDepth, + billingCode: input.billingCode, + originKind: input.originKind, + originId: input.originId, + originRunId: input.originRunId, + blockedByIssueIds: input.blockedByIssueIds, + labelIds: input.labelIds, + executionWorkspaceId: input.executionWorkspaceId, + executionWorkspacePreference: input.executionWorkspacePreference, + executionWorkspaceSettings: input.executionWorkspaceSettings, + actorAgentId: input.actor?.actorAgentId, + actorUserId: input.actor?.actorUserId, + actorRunId: input.actor?.actorRunId, }); }, - async update(issueId: string, patch, companyId: string) { + async update(issueId: string, patch, companyId: string, actor) { return callHost("issues.update", { issueId, - patch: patch as Record, + patch: { + ...(patch as Record), + actorAgentId: actor?.actorAgentId, + actorUserId: actor?.actorUserId, + actorRunId: actor?.actorRunId, + }, companyId, }); }, + async assertCheckoutOwner(input) { + return callHost("issues.assertCheckoutOwner", input); + }, + + async getSubtree(issueId: string, companyId: string, options) { + return callHost("issues.getSubtree", { + issueId, + companyId, + includeRoot: options?.includeRoot, + includeRelations: options?.includeRelations, + includeDocuments: options?.includeDocuments, + includeActiveRuns: options?.includeActiveRuns, + includeAssignees: options?.includeAssignees, + }); + }, + + async requestWakeup(issueId: string, companyId: string, options) { + return callHost("issues.requestWakeup", { + issueId, + companyId, + reason: options?.reason, + contextSource: options?.contextSource, + idempotencyKey: options?.idempotencyKey, + actorAgentId: options?.actorAgentId, + actorUserId: options?.actorUserId, + actorRunId: options?.actorRunId, + }); + }, + + async requestWakeups(issueIds: string[], companyId: string, options) { + return callHost("issues.requestWakeups", { + issueIds, + companyId, + reason: options?.reason, + contextSource: options?.contextSource, + idempotencyKeyPrefix: options?.idempotencyKeyPrefix, + actorAgentId: options?.actorAgentId, + actorUserId: options?.actorUserId, + actorRunId: options?.actorRunId, + }); + }, + async listComments(issueId: string, companyId: string) { return callHost("issues.listComments", { issueId, companyId }); }, @@ -614,6 +697,66 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost return callHost("issues.createComment", { issueId, body, companyId, authorAgentId: options?.authorAgentId }); }, + async createInteraction(issueId: string, interaction, companyId: string, options?: { authorAgentId?: string }) { + return callHost("issues.createInteraction", { + issueId, + companyId, + interaction, + authorAgentId: options?.authorAgentId, + }); + }, + + async suggestTasks( + issueId: string, + interaction, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise { + return callHost("issues.createInteraction", { + issueId, + companyId, + interaction: { + ...interaction, + kind: "suggest_tasks", + }, + authorAgentId: options?.authorAgentId, + }) as Promise; + }, + + async askUserQuestions( + issueId: string, + interaction, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise { + return callHost("issues.createInteraction", { + issueId, + companyId, + interaction: { + ...interaction, + kind: "ask_user_questions", + }, + authorAgentId: options?.authorAgentId, + }) as Promise; + }, + + async requestConfirmation( + issueId: string, + interaction, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise { + return callHost("issues.createInteraction", { + issueId, + companyId, + interaction: { + ...interaction, + kind: "request_confirmation", + }, + authorAgentId: options?.authorAgentId, + }) as Promise; + }, + documents: { async list(issueId: string, companyId: string) { return callHost("issues.documents.list", { issueId, companyId }); @@ -639,6 +782,51 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost return callHost("issues.documents.delete", { issueId, key, companyId }); }, }, + + relations: { + async get(issueId: string, companyId: string) { + return callHost("issues.relations.get", { issueId, companyId }); + }, + + async setBlockedBy(issueId: string, blockedByIssueIds: string[], companyId: string, actor) { + return callHost("issues.relations.setBlockedBy", { + issueId, + companyId, + blockedByIssueIds, + actorAgentId: actor?.actorAgentId, + actorUserId: actor?.actorUserId, + actorRunId: actor?.actorRunId, + }); + }, + + async addBlockers(issueId: string, blockerIssueIds: string[], companyId: string, actor) { + return callHost("issues.relations.addBlockers", { + issueId, + companyId, + blockerIssueIds, + actorAgentId: actor?.actorAgentId, + actorUserId: actor?.actorUserId, + actorRunId: actor?.actorRunId, + }); + }, + + async removeBlockers(issueId: string, blockerIssueIds: string[], companyId: string, actor) { + return callHost("issues.relations.removeBlockers", { + issueId, + companyId, + blockerIssueIds, + actorAgentId: actor?.actorAgentId, + actorUserId: actor?.actorUserId, + actorRunId: actor?.actorRunId, + }); + }, + }, + + summaries: { + async getOrchestration(input) { + return callHost("issues.summaries.getOrchestration", input); + }, + }, }, agents: { @@ -879,6 +1067,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost case "handleWebhook": return handleWebhook(params as PluginWebhookInput); + case "handleApiRequest": + return handleApiRequest(params as PluginApiRequestInput); + case "getData": return handleGetData(params as GetDataParams); @@ -907,6 +1098,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost manifest = params.manifest; currentConfig = params.config; + databaseNamespace = params.databaseNamespace ?? null; // Call the plugin's setup function await plugin.definition.setup(ctx); @@ -919,6 +1111,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost if (plugin.definition.onConfigChanged) supportedMethods.push("configChanged"); if (plugin.definition.onHealth) supportedMethods.push("health"); if (plugin.definition.onShutdown) supportedMethods.push("shutdown"); + if (plugin.definition.onApiRequest) supportedMethods.push("handleApiRequest"); return { ok: true, supportedMethods }; } @@ -993,8 +1186,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost // handler doesn't prevent the rest from running. notifyHost("log", { level: "error", - message: `Event handler for "${registration.name}" failed: ${err instanceof Error ? err.message : String(err) - }`, + message: `Event handler for "${registration.name}" failed: ${ + err instanceof Error ? err.message : String(err) + }`, meta: { eventType: event.eventType, stack: err instanceof Error ? err.stack : undefined }, }); } @@ -1019,6 +1213,16 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost await plugin.definition.onWebhook(params); } + async function handleApiRequest(params: PluginApiRequestInput): Promise { + if (!plugin.definition.onApiRequest) { + throw Object.assign( + new Error("handleApiRequest is not implemented by this plugin"), + { code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED }, + ); + } + return plugin.definition.onApiRequest(params); + } + async function handleGetData(params: GetDataParams): Promise { const handler = dataHandlers.get(params.key); if (!handler) { From 2918a7f294dc0d3a0f72cc6e92fadec862956a01 Mon Sep 17 00:00:00 2001 From: NeoPilot <221231603+neopilotai@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:08:50 +0000 Subject: [PATCH 05/13] =?UTF-8?q?=F0=9F=9A=80=20server:=20update=20API,=20?= =?UTF-8?q?routes,=20and=20services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/CHANGELOG.md | 24 +- server/package.json | 2 +- server/src/__tests__/access-service.test.ts | 224 + .../src/__tests__/access-validators.test.ts | 33 + server/src/__tests__/activity-routes.test.ts | 63 +- server/src/__tests__/activity-service.test.ts | 423 +- server/src/__tests__/adapter-registry.test.ts | 243 + .../__tests__/adapter-routes-authz.test.ts | 287 ++ server/src/__tests__/adapter-routes.test.ts | 159 +- .../agent-adapter-validation-routes.test.ts | 14 +- .../agent-cross-tenant-authz-routes.test.ts | 267 + .../__tests__/agent-live-run-routes.test.ts | 82 +- .../agent-permissions-routes.test.ts | 500 +- .../src/__tests__/agent-skills-routes.test.ts | 24 +- .../app-private-hostname-gate.test.ts | 32 + .../__tests__/app-vite-dev-routing.test.ts | 27 + .../approval-routes-idempotency.test.ts | 133 +- server/src/__tests__/assets.test.ts | 42 +- server/src/__tests__/auth-routes.test.ts | 170 + .../src/__tests__/auth-session-route.test.ts | 61 + .../__tests__/authz-company-access.test.ts | 157 + server/src/__tests__/better-auth.test.ts | 43 + .../__tests__/claude-local-adapter.test.ts | 2 +- .../__tests__/claude-local-execute.test.ts | 26 +- server/src/__tests__/cli-auth-routes.test.ts | 52 +- .../src/__tests__/codex-local-adapter.test.ts | 2 +- .../src/__tests__/codex-local-execute.test.ts | 151 +- .../codex-local-skill-injection.test.ts | 4 +- .../company-portability-routes.test.ts | 158 +- .../src/__tests__/company-portability.test.ts | 353 +- .../__tests__/company-skills-routes.test.ts | 62 +- .../__tests__/company-skills-service.test.ts | 92 + server/src/__tests__/company-skills.test.ts | 2 +- .../company-user-directory-route.test.ts | 132 + server/src/__tests__/costs-service.test.ts | 264 +- .../__tests__/cursor-local-adapter.test.ts | 2 +- .../__tests__/cursor-local-execute.test.ts | 8 +- .../cursor-local-skill-injection.test.ts | 2 +- .../src/__tests__/dashboard-service.test.ts | 169 + server/src/__tests__/dev-runner-paths.test.ts | 5 +- .../src/__tests__/documents-service.test.ts | 115 + .../execution-workspaces-routes.test.ts | 90 + .../execution-workspaces-service.test.ts | 134 - server/src/__tests__/feedback-service.test.ts | 4 +- .../__tests__/gemini-local-adapter.test.ts | 2 +- .../__tests__/gemini-local-execute.test.ts | 6 +- .../__tests__/health-dev-server-token.test.ts | 128 + server/src/__tests__/health.test.ts | 128 +- .../heartbeat-comment-wake-batching.test.ts | 271 +- .../heartbeat-context-summary.test.ts | 83 + .../heartbeat-dependency-scheduling.test.ts | 346 ++ ...eartbeat-issue-liveness-escalation.test.ts | 280 ++ server/src/__tests__/heartbeat-list.test.ts | 137 +- .../heartbeat-process-recovery.test.ts | 635 ++- .../heartbeat-retry-scheduling.test.ts | 338 ++ .../__tests__/heartbeat-run-summary.test.ts | 8 + .../heartbeat-workspace-session.test.ts | 38 - server/src/__tests__/http-log-policy.test.ts | 3 + .../instance-database-backups-routes.test.ts | 149 + .../instance-settings-routes.test.ts | 22 +- .../invite-accept-existing-member.test.ts | 126 + .../src/__tests__/invite-create-route.test.ts | 137 + server/src/__tests__/invite-expiry.test.ts | 4 +- .../src/__tests__/invite-join-grants.test.ts | 66 +- .../src/__tests__/invite-list-route.test.ts | 164 + .../src/__tests__/invite-logo-route.test.ts | 146 + .../__tests__/invite-onboarding-text.test.ts | 1 + .../__tests__/invite-summary-route.test.ts | 278 ++ .../invite-test-resolution-route.test.ts | 215 + .../issue-activity-events-routes.test.ts | 223 +- ...ue-agent-mutation-ownership-routes.test.ts | 464 ++ .../__tests__/issue-attachment-routes.test.ts | 47 +- .../issue-closed-workspace-routes.test.ts | 45 + .../issue-comment-cancel-routes.test.ts | 147 +- .../issue-comment-reopen-routes.test.ts | 304 +- .../issue-continuation-summary.test.ts | 86 + .../issue-dependency-wakeups-routes.test.ts | 47 + .../issue-document-restore-routes.test.ts | 177 +- .../issue-execution-policy-routes.test.ts | 13 + .../__tests__/issue-feedback-routes.test.ts | 19 +- server/src/__tests__/issue-liveness.test.ts | 185 + .../issue-references-service.test.ts | 244 + .../__tests__/issue-telemetry-routes.test.ts | 13 + .../issue-thread-interaction-routes.test.ts | 584 +++ .../issue-thread-interactions-service.test.ts | 881 ++++ ...issue-update-comment-wakeup-routes.test.ts | 32 + .../issue-workspace-command-authz.test.ts | 269 + .../issues-goal-context-routes.test.ts | 180 +- server/src/__tests__/issues-service.test.ts | 703 ++- .../src/__tests__/join-request-dedupe.test.ts | 104 + .../openclaw-gateway-adapter.test.ts | 12 +- .../openclaw-invite-prompt-route.test.ts | 132 +- .../__tests__/opencode-local-adapter.test.ts | 2 +- server/src/__tests__/pi-local-execute.test.ts | 2 +- server/src/__tests__/plugin-database.test.ts | 269 + .../plugin-orchestration-apis.test.ts | 372 ++ .../src/__tests__/plugin-routes-authz.test.ts | 581 +++ .../plugin-scoped-api-routes.test.ts | 461 ++ .../plugin-sdk-orchestration-contract.test.ts | 240 + server/src/__tests__/quota-windows.test.ts | 79 +- server/src/__tests__/routines-e2e.test.ts | 17 +- server/src/__tests__/routines-routes.test.ts | 17 + server/src/__tests__/routines-service.test.ts | 138 + .../src/__tests__/run-continuations.test.ts | 153 + server/src/__tests__/run-liveness.test.ts | 132 + .../server-startup-feedback-export.test.ts | 36 +- .../__tests__/shared-telemetry-events.test.ts | 73 + .../sidebar-preferences-routes.test.ts | 38 +- .../src/__tests__/user-profile-routes.test.ts | 218 + .../src/__tests__/vite-html-renderer.test.ts | 8 +- .../workspace-runtime-routes-authz.test.ts | 445 ++ .../workspace-runtime-service-authz.test.ts | 279 ++ .../src/__tests__/workspace-runtime.test.ts | 463 +- server/src/__tests__/worktree-config.test.ts | 107 + server/src/adapters/http/execute.test.ts | 46 + server/src/adapters/http/execute.ts | 11 + server/src/adapters/registry.ts | 108 +- server/src/adapters/utils.ts | 2 +- server/src/app.ts | 102 +- server/src/attachment-types.ts | 14 - server/src/auth/better-auth.ts | 29 +- server/src/config.ts | 26 +- server/src/index.ts | 254 +- server/src/lib/join-request-dedupe.ts | 88 + server/src/log-redaction.ts | 2 + server/src/middleware/auth.ts | 43 +- server/src/middleware/board-mutation-guard.ts | 6 + server/src/middleware/http-log-policy.ts | 3 + server/src/onboarding-assets/ceo/AGENTS.md | 5 + server/src/onboarding-assets/ceo/HEARTBEAT.md | 3 + .../src/onboarding-assets/default/AGENTS.md | 14 +- server/src/routes/access.ts | 1825 ++++++- server/src/routes/activity.ts | 8 +- server/src/routes/adapters.ts | 49 +- server/src/routes/agents.ts | 360 +- server/src/routes/approvals.ts | 37 +- server/src/routes/auth.ts | 100 + server/src/routes/authz.ts | 45 +- server/src/routes/companies.ts | 2 +- server/src/routes/costs.ts | 8 +- server/src/routes/execution-workspaces.ts | 175 +- server/src/routes/health.ts | 65 +- server/src/routes/index.ts | 2 +- .../src/routes/instance-database-backups.ts | 30 + server/src/routes/instance-settings.ts | 16 +- server/src/routes/issues.ts | 984 +++- server/src/routes/llms.ts | 2 +- server/src/routes/onboarding.ts | 20 - server/src/routes/plugins.ts | 436 +- server/src/routes/projects.ts | 56 +- server/src/routes/sidebar-badges.ts | 27 +- server/src/routes/user-profiles.ts | 436 ++ server/src/routes/workspace-command-authz.ts | 115 + .../routes/workspace-runtime-service-authz.ts | 138 + server/src/services/access.ts | 435 +- server/src/services/activity-log.ts | 41 +- server/src/services/activity.ts | 296 +- server/src/services/agents.ts | 57 +- server/src/services/approvals.ts | 2 +- server/src/services/board-auth.ts | 35 +- server/src/services/budgets.ts | 2 +- server/src/services/circuitBreakers.ts | 104 - server/src/services/companies.ts | 8 +- server/src/services/company-export-readme.ts | 6 +- server/src/services/company-member-roles.ts | 59 + server/src/services/company-portability.ts | 205 +- server/src/services/company-skills.ts | 124 +- server/src/services/costs.ts | 82 +- server/src/services/dashboard.ts | 59 +- server/src/services/documents.ts | 25 +- server/src/services/execution-workspaces.ts | 202 +- server/src/services/finance.ts | 14 +- server/src/services/heartbeat-run-summary.ts | 26 +- .../services/heartbeat-stop-metadata.test.ts | 86 + .../src/services/heartbeat-stop-metadata.ts | 119 + server/src/services/heartbeat.ts | 4328 ++++++++++++----- server/src/services/index.ts | 17 +- server/src/services/instance-settings.ts | 11 +- server/src/services/invite-grants.ts | 68 + .../services/issue-continuation-summary.ts | 269 + server/src/services/issue-execution-policy.ts | 8 +- server/src/services/issue-liveness.ts | 324 ++ server/src/services/issue-references.ts | 407 ++ .../issue-thread-interactions.test.ts | 215 + .../src/services/issue-thread-interactions.ts | 1152 +++++ server/src/services/issues.ts | 754 ++- server/src/services/onboarding.ts | 35 - .../services/plugin-capability-validator.ts | 15 + server/src/services/plugin-database.ts | 498 ++ server/src/services/plugin-host-services.ts | 764 ++- server/src/services/plugin-lifecycle.ts | 14 +- server/src/services/plugin-loader.ts | 62 +- server/src/services/plugin-worker-manager.ts | 6 +- .../project-workspace-runtime-config.ts | 6 +- server/src/services/projects.ts | 16 +- server/src/services/routines.ts | 123 +- server/src/services/run-continuations.ts | 188 + server/src/services/run-liveness.ts | 227 + server/src/services/workspace-operations.ts | 6 +- server/src/services/workspace-runtime.ts | 137 +- server/src/startup-banner.ts | 27 +- server/src/vite-html-renderer.ts | 2 +- server/src/worktree-config.ts | 42 +- 203 files changed, 31395 insertions(+), 3596 deletions(-) create mode 100644 server/src/__tests__/access-service.test.ts create mode 100644 server/src/__tests__/access-validators.test.ts create mode 100644 server/src/__tests__/adapter-routes-authz.test.ts create mode 100644 server/src/__tests__/agent-cross-tenant-authz-routes.test.ts create mode 100644 server/src/__tests__/app-private-hostname-gate.test.ts create mode 100644 server/src/__tests__/app-vite-dev-routing.test.ts create mode 100644 server/src/__tests__/auth-routes.test.ts create mode 100644 server/src/__tests__/auth-session-route.test.ts create mode 100644 server/src/__tests__/authz-company-access.test.ts create mode 100644 server/src/__tests__/better-auth.test.ts create mode 100644 server/src/__tests__/company-skills-service.test.ts create mode 100644 server/src/__tests__/company-user-directory-route.test.ts create mode 100644 server/src/__tests__/dashboard-service.test.ts create mode 100644 server/src/__tests__/documents-service.test.ts create mode 100644 server/src/__tests__/execution-workspaces-routes.test.ts create mode 100644 server/src/__tests__/health-dev-server-token.test.ts create mode 100644 server/src/__tests__/heartbeat-context-summary.test.ts create mode 100644 server/src/__tests__/heartbeat-dependency-scheduling.test.ts create mode 100644 server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts create mode 100644 server/src/__tests__/heartbeat-retry-scheduling.test.ts create mode 100644 server/src/__tests__/instance-database-backups-routes.test.ts create mode 100644 server/src/__tests__/invite-accept-existing-member.test.ts create mode 100644 server/src/__tests__/invite-create-route.test.ts create mode 100644 server/src/__tests__/invite-list-route.test.ts create mode 100644 server/src/__tests__/invite-logo-route.test.ts create mode 100644 server/src/__tests__/invite-summary-route.test.ts create mode 100644 server/src/__tests__/invite-test-resolution-route.test.ts create mode 100644 server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts create mode 100644 server/src/__tests__/issue-continuation-summary.test.ts create mode 100644 server/src/__tests__/issue-liveness.test.ts create mode 100644 server/src/__tests__/issue-references-service.test.ts create mode 100644 server/src/__tests__/issue-thread-interaction-routes.test.ts create mode 100644 server/src/__tests__/issue-thread-interactions-service.test.ts create mode 100644 server/src/__tests__/issue-workspace-command-authz.test.ts create mode 100644 server/src/__tests__/join-request-dedupe.test.ts create mode 100644 server/src/__tests__/plugin-database.test.ts create mode 100644 server/src/__tests__/plugin-orchestration-apis.test.ts create mode 100644 server/src/__tests__/plugin-routes-authz.test.ts create mode 100644 server/src/__tests__/plugin-scoped-api-routes.test.ts create mode 100644 server/src/__tests__/plugin-sdk-orchestration-contract.test.ts create mode 100644 server/src/__tests__/run-continuations.test.ts create mode 100644 server/src/__tests__/run-liveness.test.ts create mode 100644 server/src/__tests__/shared-telemetry-events.test.ts create mode 100644 server/src/__tests__/user-profile-routes.test.ts create mode 100644 server/src/__tests__/workspace-runtime-routes-authz.test.ts create mode 100644 server/src/__tests__/workspace-runtime-service-authz.test.ts create mode 100644 server/src/adapters/http/execute.test.ts create mode 100644 server/src/lib/join-request-dedupe.ts create mode 100644 server/src/routes/auth.ts create mode 100644 server/src/routes/instance-database-backups.ts delete mode 100644 server/src/routes/onboarding.ts create mode 100644 server/src/routes/user-profiles.ts create mode 100644 server/src/routes/workspace-command-authz.ts create mode 100644 server/src/routes/workspace-runtime-service-authz.ts delete mode 100644 server/src/services/circuitBreakers.ts create mode 100644 server/src/services/company-member-roles.ts create mode 100644 server/src/services/heartbeat-stop-metadata.test.ts create mode 100644 server/src/services/heartbeat-stop-metadata.ts create mode 100644 server/src/services/invite-grants.ts create mode 100644 server/src/services/issue-continuation-summary.ts create mode 100644 server/src/services/issue-liveness.ts create mode 100644 server/src/services/issue-references.ts create mode 100644 server/src/services/issue-thread-interactions.test.ts create mode 100644 server/src/services/issue-thread-interactions.ts delete mode 100644 server/src/services/onboarding.ts create mode 100644 server/src/services/plugin-database.ts create mode 100644 server/src/services/run-continuations.ts create mode 100644 server/src/services/run-liveness.ts diff --git a/server/CHANGELOG.md b/server/CHANGELOG.md index 32b5887..004a46d 100644 --- a/server/CHANGELOG.md +++ b/server/CHANGELOG.md @@ -1,21 +1,21 @@ # @taskcore/server -## 0.2.1 +## 0.3.1 ### Patch Changes -- Stable release preparation for 0.2.1 +- Stable release preparation for 0.3.1 - Updated dependencies - - @taskcore/adapter-utils@0.2.1 - - @taskcore/adapter-claude-local@0.2.1 - - @taskcore/adapter-codex-local@0.2.1 - - @taskcore/adapter-cursor-local@0.2.1 - - @taskcore/adapter-gemini-local@0.2.1 - - @taskcore/adapter-openclaw-gateway@0.2.1 - - @taskcore/adapter-opencode-local@0.2.1 - - @taskcore/adapter-pi-local@0.2.1 - - @taskcore/db@0.2.1 - - @taskcore/shared@0.2.1 + - @taskcore/adapter-utils@0.3.1 + - @taskcore/adapter-claude-local@0.3.1 + - @taskcore/adapter-codex-local@0.3.1 + - @taskcore/adapter-cursor-local@0.3.1 + - @taskcore/adapter-gemini-local@0.3.1 + - @taskcore/adapter-openclaw-gateway@0.3.1 + - @taskcore/adapter-opencode-local@0.3.1 + - @taskcore/adapter-pi-local@0.3.1 + - @taskcore/db@0.3.1 + - @taskcore/shared@0.3.1 ## 0.3.0 diff --git a/server/package.json b/server/package.json index 7bbad5c..4b89953 100644 --- a/server/package.json +++ b/server/package.json @@ -92,4 +92,4 @@ "vite": "^6.1.0", "vitest": "^3.0.5" } -} \ No newline at end of file +} diff --git a/server/src/__tests__/access-service.test.ts b/server/src/__tests__/access-service.test.ts new file mode 100644 index 0000000..96ceb80 --- /dev/null +++ b/server/src/__tests__/access-service.test.ts @@ -0,0 +1,224 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + companies, + companyMemberships, + createDb, + instanceUserRoles, + issues, + principalPermissionGrants, +} from "@taskcore/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { accessService } from "../services/access.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +async function createCompanyWithOwner(db: ReturnType) { + const company = await db + .insert(companies) + .values({ + name: `Access Service ${randomUUID()}`, + issuePrefix: `AS${randomUUID().slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); + + const owner = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `owner-${randomUUID()}`, + status: "active", + membershipRole: "owner", + }) + .returning() + .then((rows) => rows[0]!); + + return { company, owner }; +} + +describeEmbeddedPostgres("access service", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-access-service-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issues); + await db.delete(principalPermissionGrants); + await db.delete(instanceUserRoles); + await db.delete(companyMemberships); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("rejects combined access updates that would demote the last active owner", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const access = accessService(db); + + await expect( + access.updateMemberAndPermissions( + company.id, + owner.id, + { membershipRole: "admin", grants: [] }, + "admin-user", + ), + ).rejects.toThrow("Cannot remove the last active owner"); + + const unchanged = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.id, owner.id)) + .then((rows) => rows[0]!); + expect(unchanged.membershipRole).toBe("owner"); + }); + + it("rejects role-only updates that would suspend the last active owner", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const access = accessService(db); + + await expect( + access.updateMember(company.id, owner.id, { status: "suspended" }), + ).rejects.toThrow("Cannot remove the last active owner"); + + const unchanged = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.id, owner.id)) + .then((rows) => rows[0]!); + expect(unchanged.status).toBe("active"); + }); + + it("archives members, clears grants, and reassigns open issues without deleting history", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const member = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `member-${randomUUID()}`, + status: "active", + membershipRole: "operator", + }) + .returning() + .then((rows) => rows[0]!); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: member.principalId, + permissionKey: "tasks:assign", + grantedByUserId: owner.principalId, + }); + const openIssue = await db + .insert(issues) + .values({ + companyId: company.id, + title: "Open assigned issue", + status: "in_progress", + assigneeUserId: member.principalId, + }) + .returning() + .then((rows) => rows[0]!); + const doneIssue = await db + .insert(issues) + .values({ + companyId: company.id, + title: "Historical assigned issue", + status: "done", + assigneeUserId: member.principalId, + }) + .returning() + .then((rows) => rows[0]!); + + const access = accessService(db); + const result = await access.archiveMember(company.id, member.id, { + reassignment: { assigneeUserId: owner.principalId }, + }); + + expect(result?.reassignedIssueCount).toBe(1); + const archived = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.id, member.id)) + .then((rows) => rows[0]!); + expect(archived.status).toBe("archived"); + + const remainingGrants = await db + .select() + .from(principalPermissionGrants) + .where(eq(principalPermissionGrants.principalId, member.principalId)); + expect(remainingGrants).toHaveLength(0); + + const reassignedIssue = await db + .select() + .from(issues) + .where(eq(issues.id, openIssue.id)) + .then((rows) => rows[0]!); + expect(reassignedIssue.assigneeUserId).toBe(owner.principalId); + expect(reassignedIssue.status).toBe("todo"); + + const historicalIssue = await db + .select() + .from(issues) + .where(eq(issues.id, doneIssue.id)) + .then((rows) => rows[0]!); + expect(historicalIssue.assigneeUserId).toBe(member.principalId); + }); + + it("rejects instance-level company access removal for self and protected users", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const access = accessService(db); + + await expect( + access.setUserCompanyAccess(owner.principalId, [], { actorUserId: owner.principalId }), + ).rejects.toThrow("You cannot remove yourself"); + + const admin = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `admin-${randomUUID()}`, + status: "active", + membershipRole: "admin", + }) + .returning() + .then((rows) => rows[0]!); + + await expect( + access.setUserCompanyAccess(admin.principalId, [], { actorUserId: owner.principalId }), + ).rejects.toThrow("Owners and admins cannot be removed from company access"); + + const operator = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `operator-${randomUUID()}`, + status: "active", + membershipRole: "operator", + }) + .returning() + .then((rows) => rows[0]!); + await db.insert(instanceUserRoles).values({ + userId: operator.principalId, + role: "instance_admin", + }); + + await expect( + access.setUserCompanyAccess(operator.principalId, [], { actorUserId: owner.principalId }), + ).rejects.toThrow("Instance admins cannot be removed from company access"); + }); +}); diff --git a/server/src/__tests__/access-validators.test.ts b/server/src/__tests__/access-validators.test.ts new file mode 100644 index 0000000..e05f842 --- /dev/null +++ b/server/src/__tests__/access-validators.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + updateCompanyMemberWithPermissionsSchema, + updateCurrentUserProfileSchema, +} from "@taskcore/shared"; + +describe("access validators", () => { + it("accepts HTTP(S) and Taskcore asset image URLs", () => { + expect(updateCurrentUserProfileSchema.safeParse({ + name: "Ada Lovelace", + image: "https://example.com/avatar.png", + }).success).toBe(true); + expect(updateCurrentUserProfileSchema.safeParse({ + name: "Ada Lovelace", + image: "/api/assets/avatar/content", + }).success).toBe(true); + }); + + it("rejects data URI profile images", () => { + expect(updateCurrentUserProfileSchema.safeParse({ + name: "Ada Lovelace", + image: "data:image/png;base64,AAAA", + }).success).toBe(false); + }); + + it("defaults omitted combined member grants to an empty list", () => { + const result = updateCompanyMemberWithPermissionsSchema.parse({ + membershipRole: "operator", + }); + + expect(result.grants).toEqual([]); + }); +}); diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index e8b50db..82167c8 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -21,6 +21,10 @@ const mockIssueService = vi.hoisted(() => ({ vi.mock("../services/activity.js", () => ({ activityService: () => mockActivityService, + normalizeActivityLimit: (limit: number | undefined) => { + if (!Number.isFinite(limit)) return 100; + return Math.max(1, Math.min(500, Math.floor(limit ?? 100))); + }, })); vi.mock("../services/index.js", () => ({ @@ -28,7 +32,15 @@ vi.mock("../services/index.js", () => ({ heartbeatService: () => mockHeartbeatService, })); -async function createApp() { +async function createApp( + actor: Record = { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, +) { const [{ errorHandler }, { activityRoutes }] = await Promise.all([ import("../middleware/index.js"), import("../routes/activity.js"), @@ -36,13 +48,7 @@ async function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = { - type: "board", - userId: "user-1", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - }; + (req as any).actor = actor; next(); }); app.use("/api", activityRoutes({} as any)); @@ -56,6 +62,38 @@ describe("activity routes", () => { vi.clearAllMocks(); }); + it("limits company activity lists by default", async () => { + mockActivityService.list.mockResolvedValue([]); + + const app = await createApp(); + const res = await request(app).get("/api/companies/company-1/activity"); + + expect(res.status).toBe(200); + expect(mockActivityService.list).toHaveBeenCalledWith({ + companyId: "company-1", + agentId: undefined, + entityType: undefined, + entityId: undefined, + limit: 100, + }); + }); + + it("caps requested company activity list limits", async () => { + mockActivityService.list.mockResolvedValue([]); + + const app = await createApp(); + const res = await request(app).get("/api/companies/company-1/activity?limit=5000&entityType=issue"); + + expect(res.status).toBe(200); + expect(mockActivityService.list).toHaveBeenCalledWith({ + companyId: "company-1", + agentId: undefined, + entityType: "issue", + entityId: undefined, + limit: 500, + }); + }); + it("resolves issue identifiers before loading runs", async () => { mockIssueService.getByIdentifier.mockResolvedValue({ id: "issue-uuid-1", @@ -105,4 +143,13 @@ describe("activity routes", () => { expect(res.status).toBe(403); expect(mockActivityService.issuesForRun).not.toHaveBeenCalled(); }); + + it("rejects anonymous heartbeat run issue lookups before run existence checks", async () => { + const app = await createApp({ type: "none", source: "none" }); + const res = await request(app).get("/api/heartbeat-runs/missing-run/issues"); + + expect(res.status).toBe(401); + expect(mockHeartbeatService.getRun).not.toHaveBeenCalled(); + expect(mockActivityService.issuesForRun).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/activity-service.test.ts b/server/src/__tests__/activity-service.test.ts index 8bd9a88..ec66d71 100644 --- a/server/src/__tests__/activity-service.test.ts +++ b/server/src/__tests__/activity-service.test.ts @@ -1,6 +1,18 @@ import { randomUUID } from "node:crypto"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { agents, companies, createDb, heartbeatRuns } from "@taskcore/db"; +import { + activityLog, + agents, + companies, + createDb, + documentRevisions, + documents, + heartbeatRuns, + issueComments, + issueDocuments, + issues, +} from "@taskcore/db"; +import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@taskcore/shared"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -9,6 +21,8 @@ import { activityService } from "../services/activity.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +type ActivityService = ReturnType; +type IssueRun = Awaited>[number]; if (!embeddedPostgresSupport.supported) { console.warn( @@ -16,6 +30,23 @@ if (!embeddedPostgresSupport.supported) { ); } +async function waitForIssueRun( + service: ActivityService, + companyId: string, + issueId: string, + predicate: (run: IssueRun) => boolean, +) { + const deadline = Date.now() + 2_000; + let latestRuns: IssueRun[] = []; + while (Date.now() < deadline) { + latestRuns = await service.runsForIssue(companyId, issueId); + const run = latestRuns.find(predicate); + if (run) return { run, runs: latestRuns }; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Timed out waiting for issue run. Latest run count: ${latestRuns.length}`); +} + describeEmbeddedPostgres("activity service", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; @@ -26,6 +57,12 @@ describeEmbeddedPostgres("activity service", () => { }, 20_000); afterEach(async () => { + await db.delete(activityLog); + await db.delete(issueComments); + await db.delete(issueDocuments); + await db.delete(documentRevisions); + await db.delete(documents); + await db.delete(issues); await db.delete(heartbeatRuns); await db.delete(agents); await db.delete(companies); @@ -35,6 +72,51 @@ describeEmbeddedPostgres("activity service", () => { await tempDb?.cleanup(); }); + it("limits company activity lists", async () => { + const companyId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(activityLog).values([ + { + companyId, + actorType: "system", + actorId: "system", + action: "test.oldest", + entityType: "company", + entityId: companyId, + createdAt: new Date("2026-04-21T10:00:00.000Z"), + }, + { + companyId, + actorType: "system", + actorId: "system", + action: "test.middle", + entityType: "company", + entityId: companyId, + createdAt: new Date("2026-04-21T11:00:00.000Z"), + }, + { + companyId, + actorType: "system", + actorId: "system", + action: "test.newest", + entityType: "company", + entityId: companyId, + createdAt: new Date("2026-04-21T12:00:00.000Z"), + }, + ]); + + const result = await activityService(db).list({ companyId, limit: 2 }); + + expect(result.map((event) => event.action)).toEqual(["test.newest", "test.middle"]); + }); + it("returns compact usage and result summaries for issue runs", async () => { const companyId = randomUUID(); const agentId = randomUUID(); @@ -78,9 +160,17 @@ describeEmbeddedPostgres("activity service", () => { resultJson: { billing_type: "metered", total_cost_usd: 0.42, + stopReason: "timeout", + effectiveTimeoutSec: 30, + timeoutFired: true, summary: "done", nestedHuge: { payload: "y".repeat(256_000) }, }, + livenessState: "advanced", + livenessReason: "Run produced concrete action evidence: 1 issue comment(s)", + continuationAttempt: 2, + lastUsefulActionAt: new Date("2026-04-18T19:59:00.000Z"), + nextAction: "Review the completed output.", }); const runs = await activityService(db).runsForIssue(companyId, issueId); @@ -111,6 +201,337 @@ describeEmbeddedPostgres("activity service", () => { costUsd: 0.42, cost_usd: 0.42, total_cost_usd: 0.42, + stopReason: "timeout", + effectiveTimeoutSec: 30, + timeoutFired: true, + }); + expect(runs[0]).toMatchObject({ + livenessState: "advanced", + livenessReason: "Run produced concrete action evidence: 1 issue comment(s)", + continuationAttempt: 2, + lastUsefulActionAt: new Date("2026-04-18T19:59:00.000Z"), + nextAction: "Review the completed output.", + }); + }); + + it("backfills missing liveness for completed issue runs before returning the ledger", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + const completedAt = new Date("2026-04-18T20:04:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Fix run ledger", + description: "Make the run ledger answer whether a run advanced.", + status: "done", + priority: "medium", + assigneeAgentId: agentId, + completedAt, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + startedAt: new Date("2026-04-18T20:00:00.000Z"), + finishedAt: completedAt, + contextSnapshot: { issueId }, + resultJson: { + summary: "Finished the implementation.", + }, + livenessState: null, + livenessReason: null, + lastUsefulActionAt: null, + nextAction: null, + }); + + await db.insert(issueComments).values({ + companyId, + issueId, + authorAgentId: agentId, + createdByRunId: runId, + body: "Done", + createdAt: completedAt, + }); + + const service = activityService(db); + const { run, runs } = await waitForIssueRun( + service, + companyId, + issueId, + (entry) => entry.runId === runId && entry.livenessState === "completed", + ); + + expect(runs).toHaveLength(1); + expect(run).toMatchObject({ + runId, + livenessState: "completed", + livenessReason: "Issue is done", + continuationAttempt: 0, + lastUsefulActionAt: completedAt, + }); + + const [persisted] = await db.select().from(heartbeatRuns); + expect(persisted).toMatchObject({ + id: runId, + livenessState: "completed", + livenessReason: "Issue is done", + continuationAttempt: 0, + lastUsefulActionAt: completedAt, + }); + }); + + it("does not backfill document evidence from a different run", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + const otherRunId = randomUUID(); + const documentId = randomUUID(); + const revisionId = randomUUID(); + const createdAt = new Date("2026-04-18T20:08:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Fix run ledger", + description: "Make the run ledger answer whether a run advanced.", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + }); + + await db.insert(heartbeatRuns).values([ + { + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + startedAt: new Date("2026-04-18T20:00:00.000Z"), + finishedAt: new Date("2026-04-18T20:02:00.000Z"), + contextSnapshot: { issueId }, + resultJson: { + summary: "Next steps:\n- inspect files", + }, + livenessState: null, + livenessReason: null, + }, + { + id: otherRunId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + startedAt: new Date("2026-04-18T20:05:00.000Z"), + finishedAt: createdAt, + contextSnapshot: { issueId }, + resultJson: { + summary: "Updated the plan document.", + }, + livenessState: "advanced", + livenessReason: "Run produced concrete action evidence: 1 document revision(s)", + }, + ]); + + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Plan", + format: "markdown", + latestBody: "# Plan\n\n- Inspect files", + latestRevisionId: revisionId, + latestRevisionNumber: 1, + createdByAgentId: agentId, + updatedByAgentId: agentId, + createdAt, + updatedAt: createdAt, + }); + + await db.insert(documentRevisions).values({ + id: revisionId, + companyId, + documentId, + revisionNumber: 1, + title: "Plan", + format: "markdown", + body: "# Plan\n\n- Inspect files", + createdByAgentId: agentId, + createdByRunId: otherRunId, + createdAt, + }); + + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId, + key: "plan", + createdAt, + updatedAt: createdAt, + }); + + const service = activityService(db); + const { run: backfilledRun } = await waitForIssueRun( + service, + companyId, + issueId, + (entry) => entry.runId === runId && entry.livenessState === "plan_only", + ); + + expect(backfilledRun).toMatchObject({ + runId, + livenessState: "plan_only", + livenessReason: "Run described future work without concrete action evidence", + lastUsefulActionAt: null, + }); + }); + + it("does not treat continuation summary revisions as concrete backfill evidence", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + const documentId = randomUUID(); + const revisionId = randomUUID(); + const createdAt = new Date("2026-04-18T20:12:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Fix run ledger", + description: "Make the run ledger answer whether a run advanced.", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + startedAt: new Date("2026-04-18T20:10:00.000Z"), + finishedAt: createdAt, + contextSnapshot: { issueId }, + resultJson: { + summary: "Next steps:\n- inspect files", + }, + livenessState: null, + livenessReason: null, + }); + + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Continuation Summary", + format: "markdown", + latestBody: "# Continuation Summary", + latestRevisionId: revisionId, + latestRevisionNumber: 1, + createdByAgentId: agentId, + updatedByAgentId: agentId, + createdAt, + updatedAt: createdAt, + }); + + await db.insert(documentRevisions).values({ + id: revisionId, + companyId, + documentId, + revisionNumber: 1, + title: "Continuation Summary", + format: "markdown", + body: "# Continuation Summary", + createdByAgentId: agentId, + createdByRunId: runId, + createdAt, + }); + + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId, + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + createdAt, + updatedAt: createdAt, + }); + + const service = activityService(db); + const { run: backfilledRun } = await waitForIssueRun( + service, + companyId, + issueId, + (entry) => entry.runId === runId && entry.livenessState === "plan_only", + ); + + expect(backfilledRun).toMatchObject({ + runId, + livenessState: "plan_only", + livenessReason: "Run described future work without concrete action evidence", + lastUsefulActionAt: null, }); }); }); diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 6f7b097..2959807 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -1,5 +1,28 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import type { ServerAdapterModule } from "../adapters/index.js"; + +const hermesExecuteMock = vi.hoisted(() => + vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + })), +); + +vi.mock("hermes-paperclip-adapter/server", () => ({ + execute: hermesExecuteMock, + testEnvironment: async () => ({ + adapterType: "hermes_local", + status: "pass", + checks: [], + testedAt: new Date(0).toISOString(), + }), + sessionCodec: null, + listSkills: async () => [], + syncSkills: async () => ({ entries: [] }), + detectModel: async () => null, +})); + import { detectAdapterModel, findActiveServerAdapter, @@ -39,6 +62,7 @@ describe("server adapter registry", () => { unregisterServerAdapter("external_test"); unregisterServerAdapter("claude_local"); setOverridePaused("claude_local", false); + hermesExecuteMock.mockClear(); }); it("registers external adapters and exposes them through lookup helpers", async () => { @@ -95,6 +119,51 @@ describe("server adapter registry", () => { ]); }); + it("exposes capability flags from registered adapters", () => { + const adapterWithCaps: ServerAdapterModule = { + type: "external_test", + execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), + testEnvironment: async () => ({ + adapterType: "external_test", + status: "pass" as const, + checks: [], + testedAt: new Date(0).toISOString(), + }), + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "customPathKey", + requiresMaterializedRuntimeSkills: true, + }; + + registerServerAdapter(adapterWithCaps); + + const resolved = findActiveServerAdapter("external_test"); + expect(resolved).not.toBeNull(); + expect(resolved!.supportsInstructionsBundle).toBe(true); + expect(resolved!.instructionsPathKey).toBe("customPathKey"); + expect(resolved!.requiresMaterializedRuntimeSkills).toBe(true); + expect(resolved!.supportsLocalAgentJwt).toBe(true); + }); + + it("returns undefined for capability flags on adapters that do not set them", () => { + registerServerAdapter(externalAdapter); + + const resolved = findActiveServerAdapter("external_test"); + expect(resolved).not.toBeNull(); + expect(resolved!.supportsInstructionsBundle).toBeUndefined(); + expect(resolved!.instructionsPathKey).toBeUndefined(); + expect(resolved!.requiresMaterializedRuntimeSkills).toBeUndefined(); + }); + + it("built-in claude_local adapter declares capability flags", () => { + const adapter = findActiveServerAdapter("claude_local"); + expect(adapter).not.toBeNull(); + expect(adapter!.supportsInstructionsBundle).toBe(true); + expect(adapter!.instructionsPathKey).toBe("instructionsFilePath"); + expect(adapter!.requiresMaterializedRuntimeSkills).toBe(false); + expect(adapter!.supportsLocalAgentJwt).toBe(true); + }); + it("switches active adapter behavior back to the builtin when an override is paused", async () => { const builtIn = findServerAdapter("claude_local"); expect(builtIn).not.toBeNull(); @@ -140,4 +209,178 @@ describe("server adapter registry", () => { expect(await detectAdapterModel("claude_local")).toBeNull(); expect(detectModel).toHaveBeenCalledTimes(1); }); + + it("injects the local agent JWT and Taskcore API auth guidance into Hermes", async () => { + const adapter = requireServerAdapter("hermes_local"); + + await adapter.execute({ + runId: "run-123", + agent: { + id: "agent-123", + companyId: "company-123", + name: "Hermes Agent", + role: "engineer", + adapterType: "hermes_local", + adapterConfig: { + env: { + OPENAI_API_KEY: "llm-token", + }, + promptTemplate: "Existing prompt", + }, + }, + runtime: {}, + config: {}, + context: {}, + onLog: async () => {}, + onMeta: async () => {}, + onSpawn: async () => {}, + authToken: "agent-run-jwt", + }); + + expect(hermesExecuteMock).toHaveBeenCalledTimes(1); + const [patchedCtx] = hermesExecuteMock.mock.calls[0]; + expect(patchedCtx.agent.adapterConfig).toMatchObject({ + env: { + OPENAI_API_KEY: "llm-token", + TASKCORE_API_KEY: "agent-run-jwt", + TASKCORE_RUN_ID: "run-123", + }, + }); + expect(patchedCtx.agent.adapterConfig.promptTemplate).toContain( + "Authorization: Bearer $TASKCORE_API_KEY", + ); + expect(patchedCtx.agent.adapterConfig.promptTemplate).toContain( + "X-Taskcore-Run-Id: $TASKCORE_RUN_ID", + ); + expect(patchedCtx.agent.adapterConfig.promptTemplate).toContain("Existing prompt"); + }); + + it("preserves Hermes command normalization while injecting auth", async () => { + const adapter = requireServerAdapter("hermes_local"); + + await adapter.execute({ + runId: "run-123", + agent: { + id: "agent-123", + companyId: "company-123", + name: "Hermes Agent", + role: "engineer", + adapterType: "hermes_local", + adapterConfig: { + command: "agent-hermes", + }, + }, + runtime: {}, + config: { + command: "runtime-hermes", + }, + context: {}, + onLog: async () => {}, + onMeta: async () => {}, + onSpawn: async () => {}, + authToken: "agent-run-jwt", + }); + + expect(hermesExecuteMock).toHaveBeenCalledTimes(1); + const [patchedCtx] = hermesExecuteMock.mock.calls[0]; + expect(patchedCtx.config.hermesCommand).toBe("runtime-hermes"); + expect(patchedCtx.agent.adapterConfig.hermesCommand).toBe("agent-hermes"); + expect(patchedCtx.agent.adapterConfig.env.TASKCORE_API_KEY).toBe("agent-run-jwt"); + }); + + it("passes the original Hermes context through when authToken is absent", async () => { + const adapter = requireServerAdapter("hermes_local"); + const ctx = { + runId: "run-123", + agent: { + id: "agent-123", + companyId: "company-123", + name: "Hermes Agent", + role: "engineer", + adapterType: "hermes_local", + adapterConfig: { + env: { + TASKCORE_API_KEY: "server-level-key", + }, + promptTemplate: "Existing prompt", + }, + }, + runtime: {}, + config: {}, + context: {}, + onLog: async () => {}, + onMeta: async () => {}, + onSpawn: async () => {}, + }; + + await adapter.execute(ctx); + + expect(hermesExecuteMock).toHaveBeenCalledTimes(1); + expect(hermesExecuteMock).toHaveBeenCalledWith(ctx); + }); + + it("preserves an explicit Hermes Taskcore API key and does not set promptTemplate when none was configured", async () => { + const adapter = requireServerAdapter("hermes_local"); + + await adapter.execute({ + runId: "run-123", + agent: { + id: "agent-123", + companyId: "company-123", + name: "Hermes Agent", + role: "engineer", + adapterType: "hermes_local", + adapterConfig: { + env: { + TASKCORE_API_KEY: "explicit-agent-key", + TASKCORE_RUN_ID: "stale-run-id", + }, + }, + }, + runtime: {}, + config: {}, + context: {}, + onLog: async () => {}, + onMeta: async () => {}, + onSpawn: async () => {}, + authToken: "agent-run-jwt", + }); + + const [patchedCtx] = hermesExecuteMock.mock.calls[0]; + expect(patchedCtx.agent.adapterConfig.env.TASKCORE_API_KEY).toBe("explicit-agent-key"); + expect(patchedCtx.agent.adapterConfig.env.TASKCORE_RUN_ID).toBe("run-123"); + // No custom promptTemplate was set — Hermes must use its built-in default. + // Setting promptTemplate here would replace the full default with just the auth guard text, + // stripping assigned issue / workflow instructions. + expect(patchedCtx.agent.adapterConfig.promptTemplate).toBeUndefined(); + }); + + it("does not set promptTemplate when no custom template is configured, preserving Hermes default", async () => { + const adapter = requireServerAdapter("hermes_local"); + + await adapter.execute({ + runId: "run-123", + agent: { + id: "agent-123", + companyId: "company-123", + name: "Hermes Agent", + role: "engineer", + adapterType: "hermes_local", + adapterConfig: {}, + }, + runtime: {}, + config: {}, + context: {}, + onLog: async () => {}, + onMeta: async () => {}, + onSpawn: async () => {}, + authToken: "agent-run-jwt", + }); + + const [patchedCtx] = hermesExecuteMock.mock.calls[0]; + // promptTemplate must remain unset so Hermes uses its built-in heartbeat/task prompt. + expect(patchedCtx.agent.adapterConfig.promptTemplate).toBeUndefined(); + // Auth token is still injected. + expect(patchedCtx.agent.adapterConfig.env.TASKCORE_API_KEY).toBe("agent-run-jwt"); + }); }); diff --git a/server/src/__tests__/adapter-routes-authz.test.ts b/server/src/__tests__/adapter-routes-authz.test.ts new file mode 100644 index 0000000..8c23bf0 --- /dev/null +++ b/server/src/__tests__/adapter-routes-authz.test.ts @@ -0,0 +1,287 @@ +import express from "express"; +import request from "supertest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ServerAdapterModule } from "../adapters/index.js"; + +const mocks = vi.hoisted(() => { + const externalRecords = new Map(); + + return { + externalRecords, + execFile: vi.fn((_file: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: unknown) => { + const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : maybeCallback; + if (typeof callback === "function") { + callback(null, "", ""); + } + return { + kill: vi.fn(), + on: vi.fn(), + }; + }), + listAdapterPlugins: vi.fn(), + addAdapterPlugin: vi.fn((record: any) => { + externalRecords.set(record.type, record); + }), + removeAdapterPlugin: vi.fn((type: string) => { + externalRecords.delete(type); + }), + getAdapterPluginByType: vi.fn((type: string) => externalRecords.get(type)), + getAdapterPluginsDir: vi.fn(), + getDisabledAdapterTypes: vi.fn(), + setAdapterDisabled: vi.fn(), + loadExternalAdapterPackage: vi.fn(), + buildExternalAdapters: vi.fn(async () => []), + reloadExternalAdapter: vi.fn(), + getUiParserSource: vi.fn(), + getOrExtractUiParserSource: vi.fn(), + }; +}); + +vi.mock("node:child_process", () => ({ + execFile: mocks.execFile, +})); + +vi.mock("../services/adapter-plugin-store.js", () => ({ + listAdapterPlugins: mocks.listAdapterPlugins, + addAdapterPlugin: mocks.addAdapterPlugin, + removeAdapterPlugin: mocks.removeAdapterPlugin, + getAdapterPluginByType: mocks.getAdapterPluginByType, + getAdapterPluginsDir: mocks.getAdapterPluginsDir, + getDisabledAdapterTypes: mocks.getDisabledAdapterTypes, + setAdapterDisabled: mocks.setAdapterDisabled, +})); + +vi.mock("../adapters/plugin-loader.js", () => ({ + buildExternalAdapters: mocks.buildExternalAdapters, + loadExternalAdapterPackage: mocks.loadExternalAdapterPackage, + getUiParserSource: mocks.getUiParserSource, + getOrExtractUiParserSource: mocks.getOrExtractUiParserSource, + reloadExternalAdapter: mocks.reloadExternalAdapter, +})); + +function registerRouteMocks() { + vi.doMock("node:child_process", () => ({ + execFile: mocks.execFile, + })); + + vi.doMock("../services/adapter-plugin-store.js", () => ({ + listAdapterPlugins: mocks.listAdapterPlugins, + addAdapterPlugin: mocks.addAdapterPlugin, + removeAdapterPlugin: mocks.removeAdapterPlugin, + getAdapterPluginByType: mocks.getAdapterPluginByType, + getAdapterPluginsDir: mocks.getAdapterPluginsDir, + getDisabledAdapterTypes: mocks.getDisabledAdapterTypes, + setAdapterDisabled: mocks.setAdapterDisabled, + })); + + vi.doMock("../adapters/plugin-loader.js", () => ({ + buildExternalAdapters: mocks.buildExternalAdapters, + loadExternalAdapterPackage: mocks.loadExternalAdapterPackage, + getUiParserSource: mocks.getUiParserSource, + getOrExtractUiParserSource: mocks.getOrExtractUiParserSource, + reloadExternalAdapter: mocks.reloadExternalAdapter, + })); +} + +const EXTERNAL_ADAPTER_TYPE = "external_admin_test"; +const EXTERNAL_PACKAGE_NAME = "taskcore-external-adapter"; +let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes; +let errorHandler: typeof import("../middleware/index.js").errorHandler; +let registerServerAdapter: typeof import("../adapters/registry.js").registerServerAdapter; +let unregisterServerAdapter: typeof import("../adapters/registry.js").unregisterServerAdapter; +let setOverridePaused: typeof import("../adapters/registry.js").setOverridePaused; + +function createAdapter(type = EXTERNAL_ADAPTER_TYPE): ServerAdapterModule { + return { + type, + models: [], + execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), + testEnvironment: async () => ({ + adapterType: type, + status: "pass", + checks: [], + testedAt: new Date(0).toISOString(), + }), + }; +} + +function installedRecord(type = EXTERNAL_ADAPTER_TYPE) { + return { + packageName: EXTERNAL_PACKAGE_NAME, + type, + installedAt: new Date(0).toISOString(), + }; +} + +function createApp(actor: Express.Request["actor"]) { + if (!adapterRoutes || !errorHandler) { + throw new Error("adapter route test dependencies were not loaded"); + } + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", adapterRoutes()); + app.use(errorHandler); + return app; +} + +function boardMember(membershipRole: "admin" | "operator" | "viewer"): Express.Request["actor"] { + return { + type: "board", + userId: `${membershipRole}-user`, + userName: null, + userEmail: null, + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + memberships: [ + { + companyId: "company-1", + membershipRole, + status: "active", + }, + ], + }; +} + +const instanceAdmin: Express.Request["actor"] = { + type: "board", + userId: "instance-admin", + userName: null, + userEmail: null, + source: "session", + isInstanceAdmin: true, + companyIds: [], + memberships: [], +}; + +function sendMutatingRequest(app: express.Express, name: string) { + switch (name) { + case "install": + return request(app) + .post("/api/adapters/install") + .send({ packageName: EXTERNAL_PACKAGE_NAME }); + case "disable": + return request(app) + .patch(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}`) + .send({ disabled: true }); + case "override": + return request(app) + .patch("/api/adapters/claude_local/override") + .send({ paused: true }); + case "delete": + return request(app).delete(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}`); + case "reload": + return request(app).post(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}/reload`); + case "reinstall": + return request(app).post(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}/reinstall`); + default: + throw new Error(`Unknown mutating adapter route: ${name}`); + } +} + +function seedInstalledExternalAdapter() { + mocks.externalRecords.set(EXTERNAL_ADAPTER_TYPE, installedRecord()); + unregisterServerAdapter(EXTERNAL_ADAPTER_TYPE); + registerServerAdapter(createAdapter()); +} + +describe("adapter management route authorization", () => { + beforeEach(async () => { + vi.resetModules(); + vi.doUnmock("node:child_process"); + vi.doUnmock("../services/adapter-plugin-store.js"); + vi.doUnmock("../adapters/plugin-loader.js"); + vi.doUnmock("../routes/adapters.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + vi.doUnmock("../adapters/registry.js"); + registerRouteMocks(); + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + + const [routes, middleware, registry] = await Promise.all([ + vi.importActual("../routes/adapters.js"), + vi.importActual("../middleware/index.js"), + vi.importActual("../adapters/registry.js"), + ]); + adapterRoutes = routes.adapterRoutes; + errorHandler = middleware.errorHandler; + registerServerAdapter = registry.registerServerAdapter; + unregisterServerAdapter = registry.unregisterServerAdapter; + setOverridePaused = registry.setOverridePaused; + vi.clearAllMocks(); + mocks.externalRecords.clear(); + + unregisterServerAdapter(EXTERNAL_ADAPTER_TYPE); + setOverridePaused("claude_local", false); + mocks.listAdapterPlugins.mockImplementation(() => [...mocks.externalRecords.values()]); + mocks.getAdapterPluginsDir.mockReturnValue("/tmp/taskcore-adapter-route-authz-test"); + mocks.getDisabledAdapterTypes.mockReturnValue([]); + mocks.setAdapterDisabled.mockReturnValue(true); + mocks.buildExternalAdapters.mockResolvedValue([]); + mocks.loadExternalAdapterPackage.mockResolvedValue(createAdapter()); + mocks.reloadExternalAdapter.mockImplementation(async (type: string) => createAdapter(type)); + }, 20_000); + + afterEach(() => { + unregisterServerAdapter(EXTERNAL_ADAPTER_TYPE); + setOverridePaused("claude_local", false); + }); + + it.each([ + "install", + "disable", + "override", + "delete", + "reload", + "reinstall", + ])("rejects %s for a non-instance-admin board user with company membership", async (routeName) => { + seedInstalledExternalAdapter(); + const app = createApp(boardMember("admin")); + + const res = await sendMutatingRequest(app, routeName); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + }); + + it.each([ + ["install", 201], + ["disable", 200], + ["override", 200], + ["delete", 200], + ["reload", 200], + ["reinstall", 200], + ] as const)("allows instance admins to reach %s", async (routeName, expectedStatus) => { + if (routeName !== "install") { + seedInstalledExternalAdapter(); + } + const app = createApp(instanceAdmin); + + const res = await sendMutatingRequest(app, routeName); + + expect(res.status, JSON.stringify(res.body)).toBe(expectedStatus); + }); + + it.each(["viewer", "operator"] as const)( + "does not let a company %s trigger adapter npm install or reload", + async (membershipRole) => { + seedInstalledExternalAdapter(); + const app = createApp(boardMember(membershipRole)); + + const install = await request(app) + .post("/api/adapters/install") + .send({ packageName: EXTERNAL_PACKAGE_NAME }); + const reload = await request(app).post(`/api/adapters/${EXTERNAL_ADAPTER_TYPE}/reload`); + + expect(install.status, JSON.stringify(install.body)).toBe(403); + expect(reload.status, JSON.stringify(reload.body)).toBe(403); + expect(mocks.execFile).not.toHaveBeenCalled(); + expect(mocks.loadExternalAdapterPackage).not.toHaveBeenCalled(); + expect(mocks.reloadExternalAdapter).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index 32bf991..55ced0c 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -4,6 +4,24 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { vi } from "vitest"; import type { ServerAdapterModule } from "../adapters/index.js"; +const mockAdapterPluginStore = vi.hoisted(() => ({ + listAdapterPlugins: vi.fn(), + addAdapterPlugin: vi.fn(), + removeAdapterPlugin: vi.fn(), + getAdapterPluginByType: vi.fn(), + getAdapterPluginsDir: vi.fn(), + getDisabledAdapterTypes: vi.fn(), + setAdapterDisabled: vi.fn(), +})); + +const mockPluginLoader = vi.hoisted(() => ({ + buildExternalAdapters: vi.fn(), + loadExternalAdapterPackage: vi.fn(), + getUiParserSource: vi.fn(), + getOrExtractUiParserSource: vi.fn(), + reloadExternalAdapter: vi.fn(), +})); + const overridingConfigSchemaAdapter: ServerAdapterModule = { type: "claude_local", execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), @@ -25,13 +43,22 @@ const overridingConfigSchemaAdapter: ServerAdapterModule = { }), }; -let registerServerAdapter: typeof import("../adapters/index.js").registerServerAdapter; -let unregisterServerAdapter: typeof import("../adapters/index.js").unregisterServerAdapter; +let registerServerAdapter: typeof import("../adapters/registry.js").registerServerAdapter; +let unregisterServerAdapter: typeof import("../adapters/registry.js").unregisterServerAdapter; let setOverridePaused: typeof import("../adapters/registry.js").setOverridePaused; let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes; let errorHandler: typeof import("../middleware/index.js").errorHandler; -function createApp() { +function registerModuleMocks() { + vi.doMock("node:child_process", async () => vi.importActual("node:child_process")); + vi.doMock("../adapters/plugin-loader.js", () => mockPluginLoader); + vi.doMock("../services/adapter-plugin-store.js", () => mockAdapterPluginStore); + vi.doMock("../routes/adapters.js", async () => vi.importActual("../routes/adapters.js")); + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js")); +} + +function createApp(actorOverrides: Partial = {}) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -41,6 +68,7 @@ function createApp() { companyIds: [], source: "local_implicit", isInstanceAdmin: false, + ...actorOverrides, }; next(); }); @@ -52,18 +80,33 @@ function createApp() { describe("adapter routes", () => { beforeEach(async () => { vi.resetModules(); - vi.doUnmock("../adapters/index.js"); + vi.doUnmock("node:child_process"); vi.doUnmock("../adapters/registry.js"); + vi.doUnmock("../adapters/plugin-loader.js"); + vi.doUnmock("../services/adapter-plugin-store.js"); vi.doUnmock("../routes/adapters.js"); + vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); - const [adapters, registry, routes, middleware] = await Promise.all([ - vi.importActual("../adapters/index.js"), + registerModuleMocks(); + mockAdapterPluginStore.listAdapterPlugins.mockReturnValue([]); + mockAdapterPluginStore.addAdapterPlugin.mockResolvedValue(undefined); + mockAdapterPluginStore.removeAdapterPlugin.mockReturnValue(false); + mockAdapterPluginStore.getAdapterPluginByType.mockReturnValue(undefined); + mockAdapterPluginStore.getAdapterPluginsDir.mockReturnValue("/tmp/taskcore-adapter-routes-test"); + mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue([]); + mockAdapterPluginStore.setAdapterDisabled.mockReturnValue(false); + mockPluginLoader.buildExternalAdapters.mockResolvedValue([]); + mockPluginLoader.loadExternalAdapterPackage.mockResolvedValue(null); + mockPluginLoader.getUiParserSource.mockResolvedValue(null); + mockPluginLoader.getOrExtractUiParserSource.mockResolvedValue(null); + mockPluginLoader.reloadExternalAdapter.mockResolvedValue(null); + const [registry, routes, middleware] = await Promise.all([ vi.importActual("../adapters/registry.js"), - vi.importActual("../routes/adapters.js"), - vi.importActual("../middleware/index.js"), + import("../routes/adapters.js"), + import("../middleware/index.js"), ]); - registerServerAdapter = adapters.registerServerAdapter; - unregisterServerAdapter = adapters.unregisterServerAdapter; + registerServerAdapter = registry.registerServerAdapter; + unregisterServerAdapter = registry.unregisterServerAdapter; setOverridePaused = registry.setOverridePaused; adapterRoutes = routes.adapterRoutes; errorHandler = middleware.errorHandler; @@ -77,6 +120,88 @@ describe("adapter routes", () => { unregisterServerAdapter("claude_local"); }); + it("GET /api/adapters includes capabilities object for each adapter", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters"); + expect(res.status).toBe(200); + const adapters = Array.isArray(res.body) ? res.body : JSON.parse(res.text); + expect(Array.isArray(adapters)).toBe(true); + expect(adapters.length).toBeGreaterThan(0); + + // Every adapter should have a capabilities object + for (const adapter of adapters) { + expect(adapter.capabilities).toBeDefined(); + expect(typeof adapter.capabilities.supportsInstructionsBundle).toBe("boolean"); + expect(typeof adapter.capabilities.supportsSkills).toBe("boolean"); + expect(typeof adapter.capabilities.supportsLocalAgentJwt).toBe("boolean"); + expect(typeof adapter.capabilities.requiresMaterializedRuntimeSkills).toBe("boolean"); + } + }); + + it("GET /api/adapters returns correct capabilities for built-in adapters", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters"); + expect(res.status).toBe(200); + + // codex_local has instructions bundle + skills + jwt, no materialized skills + // (claude_local is overridden by beforeEach, so check codex_local instead) + const codexLocal = res.body.find((a: any) => a.type === "codex_local"); + expect(codexLocal).toBeDefined(); + expect(codexLocal.capabilities).toMatchObject({ + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: false, + }); + + // process adapter should have no local capabilities + const processAdapter = res.body.find((a: any) => a.type === "process"); + expect(processAdapter).toBeDefined(); + expect(processAdapter.capabilities).toMatchObject({ + supportsInstructionsBundle: false, + supportsSkills: false, + supportsLocalAgentJwt: false, + requiresMaterializedRuntimeSkills: false, + }); + + // cursor adapter should require materialized runtime skills + const cursorAdapter = res.body.find((a: any) => a.type === "cursor"); + expect(cursorAdapter).toBeDefined(); + expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true); + expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true); + + // hermes_local currently supports skills + local JWT, but not the managed + // instructions bundle flow because the bundled adapter does not consume + // instructionsFilePath at runtime. + const hermesAdapter = res.body.find((a: any) => a.type === "hermes_local"); + expect(hermesAdapter).toBeDefined(); + expect(hermesAdapter.capabilities).toMatchObject({ + supportsInstructionsBundle: false, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: false, + }); + }); + + it("GET /api/adapters derives supportsSkills from listSkills/syncSkills presence", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters"); + expect(res.status).toBe(200); + + // http adapter has no listSkills/syncSkills + const httpAdapter = res.body.find((a: any) => a.type === "http"); + expect(httpAdapter).toBeDefined(); + expect(httpAdapter.capabilities.supportsSkills).toBe(false); + + // codex_local has listSkills/syncSkills + const codexLocal = res.body.find((a: any) => a.type === "codex_local"); + expect(codexLocal).toBeDefined(); + expect(codexLocal.capabilities.supportsSkills).toBe(true); + }); + it("uses the active adapter when resolving config schema for a paused builtin override", async () => { const app = createApp(); @@ -97,4 +222,18 @@ describe("adapter routes", () => { fields: [{ key: "mode" }], }); }); + + it("rejects signed-in users without org access", async () => { + const app = createApp({ + userId: "outsider-1", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: false, + }); + + const res = await request(app).get("/api/adapters/claude_local/config-schema"); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + }); }); diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index 1d54ff0..6fabde6 100644 --- a/server/src/__tests__/agent-adapter-validation-routes.test.ts +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -131,7 +131,19 @@ async function createApp() { }; next(); }); - app.use("/api", agentRoutes({} as any)); + const db = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(async () => [ + { + id: "company-1", + requireBoardApprovalForNewAgents: false, + }, + ]), + })), + })), + }; + app.use("/api", agentRoutes(db as any)); app.use(errorHandler); return app; } diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts new file mode 100644 index 0000000..e48fc61 --- /dev/null +++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts @@ -0,0 +1,267 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { agentRoutes } from "../routes/agents.js"; + +const agentId = "11111111-1111-4111-8111-111111111111"; +const companyId = "22222222-2222-4222-8222-222222222222"; +const keyId = "33333333-3333-4333-8333-333333333333"; + +const baseAgent = { + id: agentId, + companyId, + name: "Builder", + urlKey: "builder", + role: "engineer", + title: "Builder", + icon: null, + status: "idle", + reportsTo: null, + capabilities: null, + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date("2026-04-11T00:00:00.000Z"), + updatedAt: new Date("2026-04-11T00:00:00.000Z"), +}; + +const baseKey = { + id: keyId, + agentId, + companyId, + name: "exploit", + createdAt: new Date("2026-04-11T00:00:00.000Z"), + revokedAt: null, +}; + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + terminate: vi.fn(), + remove: vi.fn(), + listKeys: vi.fn(), + createApiKey: vi.fn(), + getKeyById: vi.fn(), + revokeKey: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), + getMembership: vi.fn(), + ensureMembership: vi.fn(), + listPrincipalGrants: vi.fn(), + setPrincipalPermission: vi.fn(), +})); + +const mockApprovalService = vi.hoisted(() => ({ + create: vi.fn(), + getById: vi.fn(), +})); + +const mockBudgetService = vi.hoisted(() => ({ + upsertPolicy: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + cancelActiveForAgent: vi.fn(), +})); + +const mockIssueApprovalService = vi.hoisted(() => ({ + linkManyForApproval: vi.fn(), +})); + +const mockIssueService = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockSecretService = vi.hoisted(() => ({ + normalizeAdapterConfigForPersistence: vi.fn(), + resolveAdapterConfigForRuntime: vi.fn(), +})); + +const mockAgentInstructionsService = vi.hoisted(() => ({ + materializeManagedBundle: vi.fn(), +})); + +const mockCompanySkillService = vi.hoisted(() => ({ + listRuntimeSkillEntries: vi.fn(), + resolveRequestedSkillKeys: vi.fn(), +})); + +const mockWorkspaceOperationService = vi.hoisted(() => ({})); +const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); + +vi.mock("@taskcore/shared/telemetry", () => ({ + trackAgentCreated: vi.fn(), + trackErrorHandlerCrash: vi.fn(), +})); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); + +vi.mock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => mockAgentInstructionsService, + accessService: () => mockAccessService, + approvalService: () => mockApprovalService, + companySkillService: () => mockCompanySkillService, + budgetService: () => mockBudgetService, + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => mockIssueApprovalService, + issueService: () => mockIssueService, + logActivity: mockLogActivity, + secretService: () => mockSecretService, + syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), + workspaceOperationService: () => mockWorkspaceOperationService, +})); + +vi.mock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => ({ + getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })), + }), +})); + +function createApp(actor: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", agentRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("agent cross-tenant route authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); + mockAgentService.getById.mockResolvedValue(baseAgent); + mockAgentService.pause.mockResolvedValue(baseAgent); + mockAgentService.resume.mockResolvedValue(baseAgent); + mockAgentService.terminate.mockResolvedValue(baseAgent); + mockAgentService.remove.mockResolvedValue(baseAgent); + mockAgentService.listKeys.mockResolvedValue([]); + mockAgentService.createApiKey.mockResolvedValue({ + id: keyId, + name: baseKey.name, + token: "pcp_test_token", + createdAt: baseKey.createdAt, + }); + mockAgentService.getKeyById.mockResolvedValue(baseKey); + mockAgentService.revokeKey.mockResolvedValue({ + ...baseKey, + revokedAt: new Date("2026-04-11T00:05:00.000Z"), + }); + mockHeartbeatService.cancelActiveForAgent.mockResolvedValue(undefined); + mockLogActivity.mockResolvedValue(undefined); + }); + + it("rejects cross-tenant board pause before mutating the agent", async () => { + const app = createApp({ + type: "board", + userId: "mallory", + companyIds: [], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).post(`/api/agents/${agentId}/pause`).send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("User does not have access to this company"); + expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); + expect(mockAgentService.pause).not.toHaveBeenCalled(); + expect(mockHeartbeatService.cancelActiveForAgent).not.toHaveBeenCalled(); + }); + + it("rejects cross-tenant board key listing before reading any keys", async () => { + const app = createApp({ + type: "board", + userId: "mallory", + companyIds: [], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).get(`/api/agents/${agentId}/keys`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("User does not have access to this company"); + expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); + expect(mockAgentService.listKeys).not.toHaveBeenCalled(); + }); + + it("rejects cross-tenant board key creation before minting a token", async () => { + const app = createApp({ + type: "board", + userId: "mallory", + companyIds: [], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/keys`) + .send({ name: "exploit" }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("User does not have access to this company"); + expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); + expect(mockAgentService.createApiKey).not.toHaveBeenCalled(); + }); + + it("rejects cross-tenant board key revocation before touching the key", async () => { + const app = createApp({ + type: "board", + userId: "mallory", + companyIds: [], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("User does not have access to this company"); + expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); + expect(mockAgentService.getKeyById).not.toHaveBeenCalled(); + expect(mockAgentService.revokeKey).not.toHaveBeenCalled(); + }); + + it("requires the key to belong to the route agent before revocation", async () => { + mockAgentService.getKeyById.mockResolvedValue({ + ...baseKey, + agentId: "44444444-4444-4444-8444-444444444444", + }); + mockAccessService.canUser.mockResolvedValue(true); + + const app = createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("Key not found"); + expect(mockAgentService.getKeyById).toHaveBeenCalledWith(keyId); + expect(mockAgentService.revokeKey).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index 061d640..00bd1a5 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -9,6 +9,8 @@ const mockAgentService = vi.hoisted(() => ({ const mockHeartbeatService = vi.hoisted(() => ({ getRunIssueSummary: vi.fn(), getActiveRunIssueSummaryForAgent: vi.fn(), + getRunLogAccess: vi.fn(), + readLog: vi.fn(), })); const mockIssueService = vi.hoisted(() => ({ @@ -16,7 +18,32 @@ const mockIssueService = vi.hoisted(() => ({ getByIdentifier: vi.fn(), })); +const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(), + getExperimental: vi.fn(), + getGeneral: vi.fn(), + listCompanyIds: vi.fn(), +})); + function registerModuleMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + vi.doMock("../services/index.js", () => ({ agentService: () => mockAgentService, agentInstructionsService: () => ({}), @@ -67,7 +94,11 @@ async function createApp() { describe("agent live run routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/heartbeat.js"); vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/instance-settings.js"); + vi.doUnmock("../services/issues.js"); vi.doUnmock("../adapters/index.js"); vi.doUnmock("../routes/agents.js"); vi.doUnmock("../routes/authz.js"); @@ -88,6 +119,19 @@ describe("agent live run routes", () => { name: "Builder", adapterType: "codex_local", }); + mockInstanceSettingsService.get.mockResolvedValue({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + }); + mockInstanceSettingsService.getExperimental.mockResolvedValue({}); + mockInstanceSettingsService.getGeneral.mockResolvedValue({ + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }); + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); mockHeartbeatService.getRunIssueSummary.mockResolvedValue({ id: "run-1", status: "running", @@ -100,6 +144,19 @@ describe("agent live run routes", () => { issueId: "issue-1", }); mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue(null); + mockHeartbeatService.getRunLogAccess.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + logStore: "local_file", + logRef: "logs/run-1.ndjson", + }); + mockHeartbeatService.readLog.mockResolvedValue({ + runId: "run-1", + store: "local_file", + logRef: "logs/run-1.ndjson", + content: "chunk", + nextOffset: 5, + }); }); it("returns a compact active run payload for issue polling", async () => { @@ -124,7 +181,7 @@ describe("agent live run routes", () => { expect(res.body).not.toHaveProperty("resultJson"); expect(res.body).not.toHaveProperty("contextSnapshot"); expect(res.body).not.toHaveProperty("logRef"); - }); + }, 10_000); it("ignores a stale execution run from another issue and falls back to the assignee's matching run", async () => { mockHeartbeatService.getRunIssueSummary.mockResolvedValue({ @@ -163,4 +220,27 @@ describe("agent live run routes", () => { adapterType: "codex_local", }); }); + + it("uses narrow run log metadata lookups for log polling", async () => { + const res = await request(await createApp()).get("/api/heartbeat-runs/run-1/log?offset=12&limitBytes=64"); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockHeartbeatService.getRunLogAccess).toHaveBeenCalledWith("run-1"); + expect(mockHeartbeatService.readLog).toHaveBeenCalledWith({ + id: "run-1", + companyId: "company-1", + logStore: "local_file", + logRef: "logs/run-1.ndjson", + }, { + offset: 12, + limitBytes: 64, + }); + expect(res.body).toEqual({ + runId: "run-1", + store: "local_file", + logRef: "logs/run-1.ndjson", + content: "chunk", + nextOffset: 5, + }); + }); }); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 108679c..6c072cd 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -34,6 +34,8 @@ const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), list: vi.fn(), create: vi.fn(), + activatePendingApproval: vi.fn(), + update: vi.fn(), updatePermissions: vi.fn(), getChainOfCommand: vi.fn(), resolveByReference: vi.fn(), @@ -90,7 +92,16 @@ const mockTrackAgentCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn()); +const mockInstanceSettingsService = vi.hoisted(() => ({ + getGeneral: vi.fn(), +})); + function registerModuleMocks() { + vi.doMock("../routes/agents.js", async () => vi.importActual("../routes/agents.js")); + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + vi.doMock("../adapters/index.js", async () => vi.importActual("../adapters/index.js")); + vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js")); + vi.doMock("@taskcore/shared/telemetry", () => ({ trackAgentCreated: mockTrackAgentCreated, trackErrorHandlerCrash: vi.fn(), @@ -100,6 +111,59 @@ function registerModuleMocks() { getTelemetryClient: mockGetTelemetryClient, })); + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/approvals.js", () => ({ + approvalService: () => mockApprovalService, + })); + + vi.doMock("../services/company-skills.js", () => ({ + companySkillService: () => mockCompanySkillService, + })); + + vi.doMock("../services/budgets.js", () => ({ + budgetService: () => mockBudgetService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/issue-approvals.js", () => ({ + issueApprovalService: () => mockIssueApprovalService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/secrets.js", () => ({ + secretService: () => mockSecretService, + })); + + vi.doMock("../services/agent-instructions.js", () => ({ + agentInstructionsService: () => mockAgentInstructionsService, + syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath, + })); + + vi.doMock("../services/workspace-operations.js", () => ({ + workspaceOperationService: () => mockWorkspaceOperationService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + vi.doMock("../services/index.js", () => ({ agentService: () => mockAgentService, agentInstructionsService: () => mockAgentInstructionsService, @@ -108,6 +172,7 @@ function registerModuleMocks() { companySkillService: () => mockCompanySkillService, budgetService: () => mockBudgetService, heartbeatService: () => mockHeartbeatService, + ISSUE_LIST_DEFAULT_LIMIT: 500, issueApprovalService: () => mockIssueApprovalService, issueService: () => mockIssueService, logActivity: mockLogActivity, @@ -117,26 +182,28 @@ function registerModuleMocks() { })); } -function createDbStub() { +function createDbStub(options: { requireBoardApprovalForNewAgents?: boolean } = {}) { return { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ - then: vi.fn().mockResolvedValue([{ - id: companyId, - name: "Taskcore", - requireBoardApprovalForNewAgents: false, - }]), + then: vi.fn((resolve) => + Promise.resolve(resolve([{ + id: companyId, + name: "Taskcore", + requireBoardApprovalForNewAgents: options.requireBoardApprovalForNewAgents ?? false, + }])), + ), }), }), }), }; } -async function createApp(actor: Record) { +async function createApp(actor: Record, dbOptions: { requireBoardApprovalForNewAgents?: boolean } = {}) { const [{ errorHandler }, { agentRoutes }] = await Promise.all([ - vi.importActual("../middleware/index.js"), - vi.importActual("../routes/agents.js"), + import("../middleware/index.js"), + import("../routes/agents.js"), ]); const app = express(); app.use(express.json()); @@ -144,7 +211,7 @@ async function createApp(actor: Record) { (req as any).actor = actor; next(); }); - app.use("/api", agentRoutes(createDbStub() as any)); + app.use("/api", agentRoutes(createDbStub(dbOptions) as any)); app.use(errorHandler); return app; } @@ -154,11 +221,59 @@ describe("agent permission routes", () => { vi.resetModules(); vi.doUnmock("@taskcore/shared/telemetry"); vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agent-instructions.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/approvals.js"); + vi.doUnmock("../services/budgets.js"); + vi.doUnmock("../services/company-skills.js"); + vi.doUnmock("../services/heartbeat.js"); vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/instance-settings.js"); + vi.doUnmock("../services/issue-approvals.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/secrets.js"); + vi.doUnmock("../services/workspace-operations.js"); + vi.doUnmock("../adapters/index.js"); vi.doUnmock("../routes/agents.js"); + vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); + mockAgentService.getById.mockReset(); + mockAgentService.list.mockReset(); + mockAgentService.create.mockReset(); + mockAgentService.activatePendingApproval.mockReset(); + mockAgentService.update.mockReset(); + mockAgentService.updatePermissions.mockReset(); + mockAgentService.getChainOfCommand.mockReset(); + mockAgentService.resolveByReference.mockReset(); + mockAccessService.canUser.mockReset(); + mockAccessService.hasPermission.mockReset(); + mockAccessService.getMembership.mockReset(); + mockAccessService.ensureMembership.mockReset(); + mockAccessService.listPrincipalGrants.mockReset(); + mockAccessService.setPrincipalPermission.mockReset(); + mockApprovalService.create.mockReset(); + mockApprovalService.getById.mockReset(); + mockBudgetService.upsertPolicy.mockReset(); + mockHeartbeatService.listTaskSessions.mockReset(); + mockHeartbeatService.resetRuntimeSession.mockReset(); + mockHeartbeatService.getRun.mockReset(); + mockHeartbeatService.cancelRun.mockReset(); + mockIssueApprovalService.linkManyForApproval.mockReset(); + mockIssueService.list.mockReset(); + mockSecretService.normalizeAdapterConfigForPersistence.mockReset(); + mockSecretService.resolveAdapterConfigForRuntime.mockReset(); + mockAgentInstructionsService.materializeManagedBundle.mockReset(); + mockCompanySkillService.listRuntimeSkillEntries.mockReset(); + mockCompanySkillService.resolveRequestedSkillKeys.mockReset(); + mockLogActivity.mockReset(); + mockTrackAgentCreated.mockReset(); + mockGetTelemetryClient.mockReset(); + mockSyncInstructionsBundleConfigFromFilePath.mockReset(); + mockInstanceSettingsService.getGeneral.mockReset(); mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockAgentService.getById.mockResolvedValue(baseAgent); @@ -166,7 +281,14 @@ describe("agent permission routes", () => { mockAgentService.getChainOfCommand.mockResolvedValue([]); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent }); mockAgentService.create.mockResolvedValue(baseAgent); + mockAgentService.activatePendingApproval.mockResolvedValue({ + agent: baseAgent, + activated: false, + }); + mockAgentService.update.mockResolvedValue(baseAgent); mockAgentService.updatePermissions.mockResolvedValue(baseAgent); + mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.hasPermission.mockResolvedValue(false); mockAccessService.getMembership.mockResolvedValue({ id: "membership-1", companyId, @@ -202,9 +324,293 @@ describe("agent permission routes", () => { ); mockSecretService.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config); mockSecretService.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({ config })); + mockInstanceSettingsService.getGeneral.mockResolvedValue({ + censorUsernameInLogs: false, + }); mockLogActivity.mockResolvedValue(undefined); }); + it("redacts agent detail for authenticated company members without agent admin permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app).get(`/api/agents/${agentId}`); + + expect(res.status).toBe(200); + expect(res.body.adapterConfig).toEqual({}); + expect(res.body.runtimeConfig).toEqual({}); + }, 20_000); + + it("redacts company agent list for authenticated company members without agent admin permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app).get(`/api/companies/${companyId}/agents`); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + expect.objectContaining({ + id: agentId, + adapterConfig: {}, + runtimeConfig: {}, + }), + ]); + }); + + it("blocks agent updates for authenticated company members without agent admin permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ title: "Compromised" }); + + expect(res.status).toBe(403); + }); + + it("blocks api key creation for authenticated company members without agent admin permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/keys`) + .send({ name: "backdoor" }); + + expect(res.status).toBe(403); + }); + + it("blocks wakeups for authenticated company members without agent admin permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/wakeup`) + .send({}); + + expect(res.status).toBe(403); + }); + + it("blocks agent-authenticated self-updates that set host-executed workspace commands", async () => { + const app = await createApp({ + type: "agent", + agentId, + companyId, + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + adapterConfig: { + workspaceStrategy: { + type: "git_worktree", + provisionCommand: "touch /tmp/taskcore-rce", + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("blocks agent-authenticated self-updates that set instructions bundle roots", async () => { + const app = await createApp({ + type: "agent", + agentId, + companyId, + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + adapterConfig: { + instructionsRootPath: "/etc", + instructionsEntryFile: "passwd", + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("instructions path or bundle configuration"); + expect(mockLogActivity).not.toHaveBeenCalled(); + }, 15_000); + + it("blocks agent-authenticated instructions-path updates", async () => { + const app = await createApp({ + type: "agent", + agentId, + companyId, + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}/instructions-path`) + .send({ path: "/etc/passwd" }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("instructions path or bundle configuration"); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("blocks agent-authenticated hires that set instructions bundle config", async () => { + mockAccessService.hasPermission.mockResolvedValue(true); + + const app = await createApp({ + type: "agent", + agentId, + companyId, + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agent-hires`) + .send({ + name: "Injected", + role: "engineer", + adapterType: "codex_local", + adapterConfig: { + instructionsRootPath: "/etc", + instructionsEntryFile: "passwd", + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("instructions path or bundle configuration"); + expect(mockAgentService.create).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("blocks direct agent creation for authenticated company members without agent create permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "Backdoor", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("agents:create"); + expect(mockAgentService.create).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("allows direct agent creation for authenticated board users with agent create permission when approval is not required", async () => { + mockAccessService.canUser.mockResolvedValue(true); + + const app = await createApp({ + type: "board", + userId: "agent-admin-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "Builder", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + }); + + expect(res.status).toBe(201); + expect(mockAgentService.create).toHaveBeenCalledWith( + companyId, + expect.objectContaining({ + status: "idle", + }), + ); + expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith( + companyId, + "agent", + agentId, + "tasks:assign", + true, + "agent-admin-user", + ); + }); + + it("rejects direct agent creation when new agents require board approval", async () => { + const app = await createApp( + { + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }, + { requireBoardApprovalForNewAgents: true }, + ); + + const res = await request(app) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "Builder", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("/agent-hires"); + expect(mockAgentService.create).not.toHaveBeenCalled(); + expect(mockApprovalService.create).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + it("grants tasks:assign by default when board creates a new agent", async () => { const app = await createApp({ type: "board", @@ -239,7 +645,7 @@ describe("agent permission routes", () => { true, "board-user", ); - }); + }, 15_000); it("rejects unsupported query parameters on the agent list route", async () => { const app = await createApp({ @@ -290,6 +696,7 @@ describe("agent permission routes", () => { heartbeat: { enabled: false, intervalSec: 3600, + maxConcurrentRuns: 5, }, }, }), @@ -327,12 +734,73 @@ describe("agent permission routes", () => { heartbeat: { enabled: false, intervalSec: 3600, + maxConcurrentRuns: 5, }, }, }), ); }); + it("allows board users to directly approve pending agents", async () => { + const pendingAgent = { + ...baseAgent, + status: "pending_approval", + }; + const approvedAgent = { + ...baseAgent, + status: "idle", + }; + mockAgentService.getById.mockResolvedValue(pendingAgent); + mockAgentService.activatePendingApproval.mockResolvedValue({ + agent: approvedAgent, + activated: true, + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/approve`) + .send({}); + + expect(res.status).toBe(200); + expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith(agentId); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + companyId, + actorType: "user", + actorId: "board-user", + action: "agent.approved", + entityType: "agent", + entityId: agentId, + details: { source: "agent_detail" }, + })); + }); + + it("rejects direct approval for agents that are not pending approval", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/approve`) + .send({}); + + expect(res.status).toBe(409); + expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "agent.approved", + })); + }); + it("exposes explicit task assignment access on agent detail", async () => { mockAccessService.listPrincipalGrants.mockResolvedValue([ { @@ -361,7 +829,7 @@ describe("agent permission routes", () => { expect(res.status).toBe(200); expect(res.body.access.canAssignTasks).toBe(true); expect(res.body.access.taskAssignSource).toBe("explicit_grant"); - }); + }, 15_000); it("keeps task assignment enabled when agent creation privilege is enabled", async () => { mockAgentService.updatePermissions.mockResolvedValue({ @@ -425,6 +893,12 @@ describe("agent permission routes", () => { status: "todo", }, ]); + expect(mockIssueService.list).toHaveBeenCalledWith(companyId, { + touchedByUserId: "board-user", + inboxArchivedByUserId: "board-user", + status: "backlog,todo,in_progress,in_review,blocked,done", + limit: 500, + }); }); it("rejects heartbeat cancellation outside the caller company scope", async () => { diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 1bf9207..27e27d1 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -395,14 +395,6 @@ describe("agent skill routes", () => { }); expect([200, 201], JSON.stringify(res.body)).toContain(res.status); - expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( - expect.objectContaining({ - id: "11111111-1111-4111-8111-111111111111", - adapterType: "claude_local", - }), - { "AGENTS.md": "You are QA." }, - { entryFile: "AGENTS.md", replaceExisting: false }, - ); expect(mockAgentService.update).toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", expect.objectContaining({ @@ -466,10 +458,24 @@ describe("agent skill routes", () => { adapterType: "claude_local", }), expect.objectContaining({ - "AGENTS.md": expect.stringContaining("Keep the work moving until it's done."), + "AGENTS.md": expect.stringMatching(/Start actionable work in the same heartbeat\.[\s\S]*Keep the work moving until it is done\./), }), { entryFile: "AGENTS.md", replaceExisting: false }, ); + expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + "AGENTS.md": expect.stringContaining('kind: "request_confirmation"'), + }), + expect.any(Object), + ); + expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + "AGENTS.md": expect.stringContaining("confirmation:{issueId}:plan:{revisionId}"), + }), + expect.any(Object), + ); }); }); diff --git a/server/src/__tests__/app-private-hostname-gate.test.ts b/server/src/__tests__/app-private-hostname-gate.test.ts new file mode 100644 index 0000000..cf60a1b --- /dev/null +++ b/server/src/__tests__/app-private-hostname-gate.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { shouldEnablePrivateHostnameGuard } from "../app.ts"; + +describe("shouldEnablePrivateHostnameGuard", () => { + it("enables the hostname guard for local_trusted private deployments", () => { + expect(shouldEnablePrivateHostnameGuard({ + deploymentMode: "local_trusted", + deploymentExposure: "private", + })).toBe(true); + }); + + it("does not enable the hostname guard for local_trusted public deployments", () => { + expect(shouldEnablePrivateHostnameGuard({ + deploymentMode: "local_trusted", + deploymentExposure: "public", + })).toBe(false); + }); + + it("enables the hostname guard for authenticated private deployments", () => { + expect(shouldEnablePrivateHostnameGuard({ + deploymentMode: "authenticated", + deploymentExposure: "private", + })).toBe(true); + }); + + it("does not enable the hostname guard for authenticated public deployments", () => { + expect(shouldEnablePrivateHostnameGuard({ + deploymentMode: "authenticated", + deploymentExposure: "public", + })).toBe(false); + }); +}); diff --git a/server/src/__tests__/app-vite-dev-routing.test.ts b/server/src/__tests__/app-vite-dev-routing.test.ts new file mode 100644 index 0000000..cc13ec8 --- /dev/null +++ b/server/src/__tests__/app-vite-dev-routing.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import type { Request } from "express"; +import { shouldServeViteDevHtml } from "../app.js"; + +function createRequest(path: string, acceptsResult: string | false): Request { + return { + path, + accepts: () => acceptsResult, + } as unknown as Request; +} + +describe("shouldServeViteDevHtml", () => { + it("serves HTML shell for document requests", () => { + expect(shouldServeViteDevHtml(createRequest("/", "html"))).toBe(true); + expect(shouldServeViteDevHtml(createRequest("/issues/abc", "html"))).toBe(true); + }); + + it("skips public assets even when the client accepts */*", () => { + expect(shouldServeViteDevHtml(createRequest("/sw.js", "html"))).toBe(false); + expect(shouldServeViteDevHtml(createRequest("/site.webmanifest", "html"))).toBe(false); + }); + + it("skips vite asset requests", () => { + expect(shouldServeViteDevHtml(createRequest("/@vite/client", "html"))).toBe(false); + expect(shouldServeViteDevHtml(createRequest("/src/main.tsx", "html"))).toBe(false); + }); +}); diff --git a/server/src/__tests__/approval-routes-idempotency.test.ts b/server/src/__tests__/approval-routes-idempotency.test.ts index 338bfe3..29708bd 100644 --- a/server/src/__tests__/approval-routes-idempotency.test.ts +++ b/server/src/__tests__/approval-routes-idempotency.test.ts @@ -29,14 +29,6 @@ const mockSecretService = vi.hoisted(() => ({ const mockLogActivity = vi.hoisted(() => vi.fn()); -vi.mock("../services/index.js", () => ({ - approvalService: () => mockApprovalService, - heartbeatService: () => mockHeartbeatService, - issueApprovalService: () => mockIssueApprovalService, - logActivity: mockLogActivity, - secretService: () => mockSecretService, -})); - function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ approvalService: () => mockApprovalService, @@ -49,8 +41,8 @@ function registerModuleMocks() { async function createApp(actorOverrides: Record = {}) { const [{ errorHandler }, { approvalRoutes }] = await Promise.all([ - vi.importActual("../middleware/index.js"), - vi.importActual("../routes/approvals.js"), + import("../middleware/index.js"), + import("../routes/approvals.js"), ]); const app = express(); app.use(express.json()); @@ -72,8 +64,8 @@ async function createApp(actorOverrides: Record = {}) { async function createAgentApp() { const [{ errorHandler }, { approvalRoutes }] = await Promise.all([ - vi.importActual("../middleware/index.js"), - vi.importActual("../routes/approvals.js"), + import("../middleware/index.js"), + import("../routes/approvals.js"), ]); const app = express(); app.use(express.json()); @@ -95,10 +87,26 @@ async function createAgentApp() { describe("approval routes idempotent retries", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/approvals.js"); + vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.resetAllMocks(); + mockApprovalService.list.mockReset(); + mockApprovalService.getById.mockReset(); + mockApprovalService.create.mockReset(); + mockApprovalService.approve.mockReset(); + mockApprovalService.reject.mockReset(); + mockApprovalService.requestRevision.mockReset(); + mockApprovalService.resubmit.mockReset(); + mockApprovalService.listComments.mockReset(); + mockApprovalService.addComment.mockReset(); + mockHeartbeatService.wakeup.mockReset(); + mockIssueApprovalService.listIssuesForApproval.mockReset(); + mockIssueApprovalService.linkManyForApproval.mockReset(); + mockSecretService.normalizeHireApprovalPayloadForPersistence.mockReset(); + mockLogActivity.mockReset(); mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" }); mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]); mockLogActivity.mockResolvedValue(undefined); @@ -196,6 +204,90 @@ describe("approval routes idempotent retries", () => { expect(mockApprovalService.requestRevision).not.toHaveBeenCalled(); }); + it("derives approval attribution from the authenticated actor on approve", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-4", + companyId: "company-1", + type: "hire_agent", + status: "pending", + payload: {}, + requestedByAgentId: null, + }); + mockApprovalService.approve.mockResolvedValue({ + approval: { + id: "approval-4", + companyId: "company-1", + type: "hire_agent", + status: "approved", + payload: {}, + requestedByAgentId: null, + }, + applied: true, + }); + + const res = await request(await createApp()) + .post("/api/approvals/approval-4/approve") + .send({ decidedByUserId: "forged-user", decisionNote: "ship it" }); + + expect(res.status).toBe(200); + expect(mockApprovalService.approve).toHaveBeenCalledWith("approval-4", "user-1", "ship it"); + }); + + it("derives approval attribution from the authenticated actor on reject", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-5", + companyId: "company-1", + type: "hire_agent", + status: "pending", + payload: {}, + }); + mockApprovalService.reject.mockResolvedValue({ + approval: { + id: "approval-5", + companyId: "company-1", + type: "hire_agent", + status: "rejected", + payload: {}, + }, + applied: true, + }); + + const res = await request(await createApp()) + .post("/api/approvals/approval-5/reject") + .send({ decidedByUserId: "forged-user", decisionNote: "not now" }); + + expect(res.status).toBe(200); + expect(mockApprovalService.reject).toHaveBeenCalledWith("approval-5", "user-1", "not now"); + }); + + it("derives approval attribution from the authenticated actor on request revision", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-6", + companyId: "company-1", + type: "hire_agent", + status: "pending", + payload: {}, + }); + mockApprovalService.requestRevision.mockResolvedValue({ + id: "approval-6", + companyId: "company-1", + type: "hire_agent", + status: "revision_requested", + payload: {}, + }); + + const res = await request(await createApp()) + .post("/api/approvals/approval-6/request-revision") + .send({ decidedByUserId: "forged-user", decisionNote: "Need changes" }); + + expect(res.status).toBe(200); + expect(mockApprovalService.requestRevision).toHaveBeenCalledWith( + "approval-6", + "user-1", + "Need changes", + ); + }); + it("lets agents create generic issue-linked board approval requests", async () => { mockApprovalService.create.mockResolvedValue({ id: "approval-1", @@ -221,16 +313,13 @@ describe("approval routes idempotent retries", () => { }); expect([200, 201], JSON.stringify(res.body)).toContain(res.status); - expect(mockApprovalService.create).toHaveBeenCalledWith( - "company-1", - expect.objectContaining({ - type: "request_board_approval", - requestedByAgentId: "agent-1", - requestedByUserId: null, - status: "pending", - decisionNote: null, - }), - ); + expect(res.body).toMatchObject({ + companyId: "company-1", + type: "request_board_approval", + requestedByAgentId: "agent-1", + requestedByUserId: null, + status: "pending", + }); expect(mockSecretService.normalizeHireApprovalPayloadForPersistence).not.toHaveBeenCalled(); expect(mockIssueApprovalService.linkManyForApproval).toHaveBeenCalledWith( "approval-1", diff --git a/server/src/__tests__/assets.test.ts b/server/src/__tests__/assets.test.ts index f070ea7..601dbf8 100644 --- a/server/src/__tests__/assets.test.ts +++ b/server/src/__tests__/assets.test.ts @@ -10,15 +10,18 @@ const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() => logActivityMock: vi.fn(), })); -vi.mock("../services/index.js", () => ({ - assetService: vi.fn(() => ({ - create: createAssetMock, - getById: getAssetByIdMock, - })), - logActivity: logActivityMock, -})); - function registerModuleMocks() { + vi.doMock("../services/activity-log.js", () => ({ + logActivity: logActivityMock, + })); + + vi.doMock("../services/assets.js", () => ({ + assetService: vi.fn(() => ({ + create: createAssetMock, + getById: getAssetByIdMock, + })), + })); + vi.doMock("../services/index.js", () => ({ assetService: vi.fn(() => ({ create: createAssetMock, @@ -89,9 +92,7 @@ function createStorageService(contentType = "image/png"): TestStorageService { } async function createApp(storage: ReturnType) { - const { assetRoutes } = await vi.importActual( - "../routes/assets.js", - ); + const { assetRoutes } = await vi.importActual("../routes/assets.js"); const app = express(); app.use((req, _res, next) => { req.actor = { @@ -108,7 +109,12 @@ async function createApp(storage: ReturnType) { describe("POST /api/companies/:companyId/assets/images", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/assets.js"); + vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/assets.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.resetAllMocks(); createAssetMock.mockReset(); @@ -154,21 +160,19 @@ describe("POST /api/companies/:companyId/assets/images", () => { .field("namespace", "issues/drafts") .attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" }); - expect(res.status).toBe(201); - expect(text.__calls.putFileInputs[0]).toMatchObject({ - companyId: "company-1", - namespace: "assets/issues/drafts", - originalFilename: "note.txt", - contentType: "text/plain", - body: expect.any(Buffer), - }); + expect([200, 201]).toContain(res.status); + expect(res.body.contentPath).toBe("/api/assets/asset-1/content"); + expect(res.body.contentType).toBe("text/plain"); }); }); describe("POST /api/companies/:companyId/logo", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/assets.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.resetAllMocks(); createAssetMock.mockReset(); diff --git a/server/src/__tests__/auth-routes.test.ts b/server/src/__tests__/auth-routes.test.ts new file mode 100644 index 0000000..5828d04 --- /dev/null +++ b/server/src/__tests__/auth-routes.test.ts @@ -0,0 +1,170 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +function createSelectChain(rows: unknown[]) { + return { + from() { + return { + where() { + return Promise.resolve(rows); + }, + }; + }, + }; +} + +function createUpdateChain(row: unknown) { + return { + set(values: unknown) { + return { + where() { + return { + returning() { + return Promise.resolve([{ ...(row as Record), ...(values as Record) }]); + }, + }; + }, + }; + }, + }; +} + +function createDb(row: Record) { + return { + select: vi.fn(() => createSelectChain([row])), + update: vi.fn(() => createUpdateChain(row)), + } as any; +} + +async function createApp(actor: Express.Request["actor"], row: Record) { + const [{ authRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/auth.js"), + import("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api/auth", authRoutes(createDb(row))); + app.use(errorHandler); + return app; +} + +describe("auth routes", () => { + const baseUser = { + id: "user-1", + name: "Jane Example", + email: "jane@example.com", + image: "https://example.com/jane.png", + }; + + beforeEach(() => { + vi.resetModules(); + }); + + it("returns the persisted user profile in the session payload", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "session", + }, + baseUser, + ); + + const res = await request(app).get("/api/auth/get-session"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + session: { + id: "taskcore:session:user-1", + userId: "user-1", + }, + user: baseUser, + }); + }); + + it("updates the signed-in profile", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "local_implicit", + }, + baseUser, + ); + + const res = await request(app) + .patch("/api/auth/profile") + .send({ name: "Board Operator", image: "" }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: "user-1", + name: "Board Operator", + email: "jane@example.com", + image: null, + }); + }); + + it("preserves the existing avatar when updating only the profile name", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "local_implicit", + }, + baseUser, + ); + + const res = await request(app) + .patch("/api/auth/profile") + .send({ name: "Board Operator" }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: "user-1", + name: "Board Operator", + email: "jane@example.com", + image: "https://example.com/jane.png", + }); + }); + + it("accepts Taskcore asset paths for avatars", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "session", + }, + baseUser, + ); + + const res = await request(app) + .patch("/api/auth/profile") + .send({ name: "Jane Example", image: "/api/assets/asset-1/content" }); + + expect(res.status).toBe(200); + expect(res.body.image).toBe("/api/assets/asset-1/content"); + }); + + it("rejects invalid avatar image references", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "session", + }, + baseUser, + ); + + const res = await request(app) + .patch("/api/auth/profile") + .send({ name: "Jane Example", image: "not-a-url" }); + + expect(res.status).toBe(400); + }); +}); diff --git a/server/src/__tests__/auth-session-route.test.ts b/server/src/__tests__/auth-session-route.test.ts new file mode 100644 index 0000000..91eeb8a --- /dev/null +++ b/server/src/__tests__/auth-session-route.test.ts @@ -0,0 +1,61 @@ +import express from "express"; +import request from "supertest"; +import { describe, expect, it, vi } from "vitest"; +import { actorMiddleware } from "../middleware/auth.js"; + +function createSelectChain(rows: unknown[]) { + return { + from() { + return { + where() { + return Promise.resolve(rows); + }, + }; + }, + }; +} + +function createDb() { + return { + select: vi + .fn() + .mockImplementationOnce(() => createSelectChain([])) + .mockImplementationOnce(() => createSelectChain([])), + } as any; +} + +describe("actorMiddleware authenticated session profile", () => { + it("preserves the signed-in user name and email on the board actor", async () => { + const app = express(); + app.use( + actorMiddleware(createDb(), { + deploymentMode: "authenticated", + resolveSession: async () => ({ + session: { id: "session-1", userId: "user-1" }, + user: { + id: "user-1", + name: "User One", + email: "user@example.com", + }, + }), + }), + ); + app.get("/actor", (req, res) => { + res.json(req.actor); + }); + + const res = await request(app).get("/actor"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + type: "board", + userId: "user-1", + userName: "User One", + userEmail: "user@example.com", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: false, + }); + }); +}); diff --git a/server/src/__tests__/authz-company-access.test.ts b/server/src/__tests__/authz-company-access.test.ts new file mode 100644 index 0000000..b4b7135 --- /dev/null +++ b/server/src/__tests__/authz-company-access.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { assertBoardOrgAccess, assertCompanyAccess, hasBoardOrgAccess } from "../routes/authz.js"; + +function makeReq(input: { + method?: string; + actor: Express.Request["actor"]; +}) { + return { + method: input.method ?? "GET", + actor: input.actor, + } as Express.Request; +} + +describe("assertCompanyAccess", () => { + it("allows viewer memberships to read", () => { + const req = makeReq({ + method: "GET", + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [ + { companyId: "company-1", membershipRole: "viewer", status: "active" }, + ], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).not.toThrow(); + }); + + it("rejects viewer memberships for writes", () => { + const req = makeReq({ + method: "PATCH", + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [ + { companyId: "company-1", membershipRole: "viewer", status: "active" }, + ], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).toThrow("Viewer access is read-only"); + }); + + it("rejects writes when membership details are present but omit the target company", () => { + const req = makeReq({ + method: "POST", + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).toThrow("User does not have active company access"); + }); + + it("allows legacy board actors that only provide company ids", () => { + const req = makeReq({ + method: "POST", + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).not.toThrow(); + }); + + it("rejects signed-in instance admins without explicit company access", () => { + const req = makeReq({ + method: "GET", + actor: { + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + companyIds: [], + memberships: [], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).toThrow("User does not have access to this company"); + }); + + it("allows local trusted board access without explicit membership", () => { + const req = makeReq({ + method: "GET", + actor: { + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).not.toThrow(); + }); +}); + +describe("assertBoardOrgAccess", () => { + it("allows signed-in board users with active company access", () => { + const req = makeReq({ + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }], + isInstanceAdmin: false, + }, + }); + + expect(hasBoardOrgAccess(req)).toBe(true); + expect(() => assertBoardOrgAccess(req)).not.toThrow(); + }); + + it("allows instance admins without company memberships", () => { + const req = makeReq({ + actor: { + type: "board", + userId: "admin-1", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: true, + }, + }); + + expect(hasBoardOrgAccess(req)).toBe(true); + expect(() => assertBoardOrgAccess(req)).not.toThrow(); + }); + + it("rejects signed-in users without company access or instance admin rights", () => { + const req = makeReq({ + actor: { + type: "board", + userId: "outsider-1", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: false, + }, + }); + + expect(hasBoardOrgAccess(req)).toBe(false); + expect(() => assertBoardOrgAccess(req)).toThrow("Company membership or instance admin access required"); + }); +}); diff --git a/server/src/__tests__/better-auth.test.ts b/server/src/__tests__/better-auth.test.ts new file mode 100644 index 0000000..05fc014 --- /dev/null +++ b/server/src/__tests__/better-auth.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { BetterAuthOptions } from "better-auth"; +import { getCookies } from "better-auth/cookies"; +import { + buildBetterAuthAdvancedOptions, + deriveAuthCookiePrefix, +} from "../auth/better-auth.js"; + +const ORIGINAL_INSTANCE_ID = process.env.TASKCORE_INSTANCE_ID; + +afterEach(() => { + if (ORIGINAL_INSTANCE_ID === undefined) delete process.env.TASKCORE_INSTANCE_ID; + else process.env.TASKCORE_INSTANCE_ID = ORIGINAL_INSTANCE_ID; +}); + +describe("Better Auth cookie scoping", () => { + it("derives an instance-scoped cookie prefix", () => { + expect(deriveAuthCookiePrefix("default")).toBe("taskcore-default"); + expect(deriveAuthCookiePrefix("PAP-1601-worktree")).toBe("taskcore-PAP-1601-worktree"); + }); + + it("uses TASKCORE_INSTANCE_ID for the Better Auth cookie prefix", () => { + process.env.TASKCORE_INSTANCE_ID = "sat-worktree"; + + const advanced = buildBetterAuthAdvancedOptions({ disableSecureCookies: false }); + + expect(advanced).toEqual({ + cookiePrefix: "taskcore-sat-worktree", + }); + expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toBe( + "taskcore-sat-worktree.session_token", + ); + }); + + it("keeps local http auth cookies non-secure while preserving the scoped prefix", () => { + process.env.TASKCORE_INSTANCE_ID = "pap-worktree"; + + expect(buildBetterAuthAdvancedOptions({ disableSecureCookies: true })).toEqual({ + cookiePrefix: "taskcore-pap-worktree", + useSecureCookies: false, + }); + }); +}); diff --git a/server/src/__tests__/claude-local-adapter.test.ts b/server/src/__tests__/claude-local-adapter.test.ts index 30701af..9c820f1 100644 --- a/server/src/__tests__/claude-local-adapter.test.ts +++ b/server/src/__tests__/claude-local-adapter.test.ts @@ -110,7 +110,7 @@ function stripAnsi(value: string) { describe("claude_local cli formatter", () => { it("prints the user-visible and background transcript events from stream-json output", () => { - const spy = vi.spyOn(console, "log").mockImplementation(() => { }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); try { printClaudeStreamEvent( diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index 28613e6..e9e2bb5 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -15,8 +15,6 @@ const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null; const instructionsIndex = argv.indexOf("--append-system-prompt-file"); const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null; const capturePath = process.env.TASKCORE_TEST_CAPTURE_PATH; -const promptFileFlagIndex = process.argv.indexOf("--append-system-prompt-file"); -const appendedSystemPromptFilePath = promptFileFlagIndex >= 0 ? process.argv[promptFileFlagIndex + 1] : null; const payload = { argv, prompt: fs.readFileSync(0, "utf8"), @@ -25,8 +23,6 @@ const payload = { instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null, skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [], claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null, - appendedSystemPromptFilePath, - appendedSystemPromptFileContents: appendedSystemPromptFilePath ? fs.readFileSync(appendedSystemPromptFilePath, "utf8") : null, }; if (capturePath) { fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8"); @@ -146,8 +142,8 @@ describe("claude execute", () => { }, context: {}, authToken: "tok", - onLog: async () => { }, - onMeta: async () => { }, + onLog: async () => {}, + onMeta: async () => {}, }); const captured = JSON.parse(await fs.readFile(capturePath, "utf-8")); expect(captured.argv).toContain("--append-system-prompt-file"); @@ -176,8 +172,8 @@ describe("claude execute", () => { }, context: {}, authToken: "tok", - onLog: async () => { }, - onMeta: async () => { }, + onLog: async () => {}, + onMeta: async () => {}, }); const captured = JSON.parse(await fs.readFile(capturePath, "utf-8")); expect(captured.argv).not.toContain("--append-system-prompt-file"); @@ -214,7 +210,7 @@ describe("claude execute", () => { }, context: {}, authToken: "tok", - onLog: async () => { }, + onLog: async () => {}, onMeta: async (meta) => { capturedNotes = (meta.commandNotes as string[]) ?? []; }, }); expect(capturedNotes.some((n) => n.includes("--append-system-prompt-file"))).toBe(true); @@ -244,7 +240,7 @@ describe("claude execute", () => { }, context: {}, authToken: "tok", - onLog: async () => { }, + onLog: async () => {}, onMeta: async (meta) => { capturedNotes = (meta.commandNotes as string[]) ?? []; }, }); expect(capturedNotes).toHaveLength(0); @@ -279,7 +275,7 @@ describe("claude execute", () => { }, context: {}, authToken: "tok", - onLog: async () => { }, + onLog: async () => {}, onMeta: async (meta) => { metaEvents.push({ commandArgs: ((meta.commandArgs as string[]) ?? []).slice(), @@ -364,7 +360,7 @@ describe("claude execute", () => { }, context: {}, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, onMeta: async (meta) => { loggedCommand = meta.command; loggedEnv = meta.env ?? {}; @@ -432,7 +428,7 @@ describe("claude execute", () => { }, context: {}, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, }); expect(first.exitCode).toBe(0); @@ -503,7 +499,7 @@ describe("claude execute", () => { }, }, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, }); expect(second.exitCode).toBe(0); @@ -587,7 +583,7 @@ describe("claude execute", () => { }, context: {}, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, }); await fs.writeFile(instructionsPath, "Version two instructions.\n", "utf8"); diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index fcc69aa..5d0c088 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -35,6 +35,8 @@ vi.mock("../services/index.js", () => ({ })); function registerModuleMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, @@ -45,7 +47,7 @@ function registerModuleMocks() { })); } -async function createApp(actor: any) { +async function createApp(actor: any, db: any = {} as any) { const [{ accessRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/access.js"), vi.importActual("../middleware/index.js"), @@ -58,7 +60,7 @@ async function createApp(actor: any) { }); app.use( "/api", - accessRoutes({} as any, { + accessRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "private", bindHost: "127.0.0.1", @@ -72,6 +74,8 @@ async function createApp(actor: any) { describe("cli auth routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/authz.js"); vi.doUnmock("../routes/access.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); @@ -101,14 +105,56 @@ describe("cli auth routes", () => { expect(res.body).toMatchObject({ id: "challenge-1", token: "pcp_cli_auth_secret", - boardApiToken: "pcp_board_token", approvalPath: "/cli-auth/challenge-1?token=pcp_cli_auth_secret", pollPath: "/cli-auth/challenges/challenge-1", expiresAt: "2026-03-23T13:00:00.000Z", }); + expect(res.body.boardApiToken).toBe("pcp_board_token"); expect(res.body.approvalUrl).toContain("/cli-auth/challenge-1?token=pcp_cli_auth_secret"); }); + it("rejects anonymous access to generic skill documents", async () => { + const app = await createApp({ type: "none", source: "none" }); + const [indexRes, skillRes] = await Promise.all([ + request(app).get("/api/skills/index"), + request(app).get("/api/skills/taskcore"), + ]); + + expect(indexRes.status).toBe(401); + expect(skillRes.status).toBe(401); + }); + + it("serves the invite-scoped taskcore skill anonymously for active invites", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "agent", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date(Date.now() + 60_000), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + const db = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([invite]), + })), + })), + }; + + const app = await createApp({ type: "none", source: "none" }, db); + const res = await request(app).get("/api/invites/token-123/skills/taskcore"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/markdown"); + expect(res.text).toContain("# Taskcore Skill"); + }); + it("marks challenge status as requiring sign-in for anonymous viewers", async () => { mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "challenge-1", diff --git a/server/src/__tests__/codex-local-adapter.test.ts b/server/src/__tests__/codex-local-adapter.test.ts index 7f19339..274a74a 100644 --- a/server/src/__tests__/codex-local-adapter.test.ts +++ b/server/src/__tests__/codex-local-adapter.test.ts @@ -177,7 +177,7 @@ function stripAnsi(value: string): string { describe("codex_local cli formatter", () => { it("prints lifecycle, command execution, file change, and error events", () => { - const spy = vi.spyOn(console, "log").mockImplementation(() => { }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); try { printCodexStreamEvent(JSON.stringify({ type: "turn.started" }), false); diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 00920b3..6532d8b 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -29,6 +29,15 @@ console.log(JSON.stringify({ type: "turn.completed", usage: { input_tokens: 1, c await fs.chmod(commandPath, 0o755); } +async function writeFailingCodexCommand(commandPath: string, errorMessage: string): Promise { + const script = `#!/usr/bin/env node +console.log(JSON.stringify({ type: "error", message: ${JSON.stringify(errorMessage)} })); +process.exit(1); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + type CapturePayload = { argv: string[]; prompt: string; @@ -179,7 +188,7 @@ describe("codex execute", () => { }, context: {}, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, onMeta: async (meta) => { commandNotes = Array.isArray(meta.commandNotes) ? meta.commandNotes : []; }, @@ -240,7 +249,7 @@ describe("codex execute", () => { }, context: {}, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, onMeta: async (meta) => { loggedCommand = meta.command; loggedEnv = meta.env ?? {}; @@ -340,7 +349,7 @@ describe("codex execute", () => { }, }, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, }); expect(result.exitCode).toBe(0); @@ -369,6 +378,131 @@ describe("codex execute", () => { } }); + it("classifies remote-compaction high-demand failures as retryable transient upstream errors", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "taskcore-codex-execute-transient-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "codex"); + await fs.mkdir(workspace, { recursive: true }); + await writeFailingCodexCommand( + commandPath, + "Error running remote compact task: We're currently experiencing high demand, which may cause temporary errors.", + ); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + try { + const result = await execute({ + runId: "run-transient-error", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex Coder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + promptTemplate: "Follow the taskcore heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("codex_transient_upstream"); + expect(result.errorMessage).toContain("high demand"); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("uses safer invocation settings and a fresh-session handoff for codex transient fallback retries", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "taskcore-codex-execute-fallback-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "codex"); + const capturePath = path.join(root, "capture.json"); + await fs.mkdir(workspace, { recursive: true }); + await writeFakeCodexCommand(commandPath); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + let commandNotes: string[] = []; + try { + const result = await execute({ + runId: "run-fallback", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex Coder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: { + sessionId: "codex-session-stale", + cwd: workspace, + }, + sessionDisplayId: "codex-session-stale", + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + fastMode: true, + model: "gpt-5.4", + env: { + TASKCORE_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the taskcore heartbeat.", + }, + context: { + codexTransientFallbackMode: "fresh_session_safer_invocation", + taskcoreContinuationSummary: { + key: "continuation-summary", + title: "Continuation Summary", + body: "Issue continuation summary for the next fresh session.", + updatedAt: "2026-04-21T01:00:00.000Z", + }, + }, + authToken: "run-jwt-token", + onLog: async () => {}, + onMeta: async (meta) => { + commandNotes = meta.commandNotes ?? []; + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(capture.argv).toEqual(expect.arrayContaining(["exec", "--json", "-"])); + expect(capture.argv).not.toContain("resume"); + expect(capture.argv).not.toContain('service_tier="fast"'); + expect(capture.argv).not.toContain("features.fast_mode=true"); + expect(capture.prompt).toContain("Taskcore session handoff:"); + expect(capture.prompt).toContain("Issue continuation summary for the next fresh session."); + expect(commandNotes).toContain("Codex transient fallback requested safer invocation settings for this retry."); + expect(commandNotes).toContain("Codex transient fallback forced a fresh session with a continuation handoff."); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("renders execution-stage wake instructions for reviewer and executor roles", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "taskcore-codex-execute-stage-wake-")); const workspace = path.join(root, "workspace"); @@ -439,7 +573,7 @@ describe("codex execute", () => { }, }, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, }); expect(result.exitCode).toBe(0); @@ -508,7 +642,7 @@ describe("codex execute", () => { }, }, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, }); expect(executorResult.exitCode).toBe(0); @@ -585,7 +719,7 @@ describe("codex execute", () => { }, }, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, }); expect(result.exitCode).toBe(0); @@ -699,7 +833,7 @@ describe("codex execute", () => { }, }, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, onMeta: async (meta) => { invocationPrompt = meta.prompt ?? ""; invocationNotes = meta.commandNotes ?? []; @@ -729,7 +863,6 @@ describe("codex execute", () => { await fs.rm(root, { recursive: true, force: true }); } }); - it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "taskcore-codex-execute-")); const workspace = path.join(root, "workspace"); @@ -898,7 +1031,7 @@ describe("codex execute", () => { }, context: {}, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, }); expect(result.exitCode).toBe(0); diff --git a/server/src/__tests__/codex-local-skill-injection.test.ts b/server/src/__tests__/codex-local-skill-injection.test.ts index 38c79be..640f237 100644 --- a/server/src/__tests__/codex-local-skill-injection.test.ts +++ b/server/src/__tests__/codex-local-skill-injection.test.ts @@ -89,7 +89,7 @@ describe("codex local adapter skill injection", () => { await createCustomSkill(customRoot, "taskcore"); await fs.symlink(path.join(customRoot, "custom", "taskcore"), path.join(skillsHome, "taskcore")); - await ensureCodexSkillsInjected(async () => { }, { + await ensureCodexSkillsInjected(async () => {}, { skillsHome, skillsEntries: [{ key: taskcoreKey, @@ -156,7 +156,7 @@ describe("codex local adapter skill injection", () => { path.join(skillsHome, "agent-browser"), ); - await ensureCodexSkillsInjected(async () => { }, { + await ensureCodexSkillsInjected(async () => {}, { skillsHome, skillsEntries: [{ key: taskcoreKey, diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index 9ffa88b..e494749 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -39,17 +39,37 @@ const mockFeedbackService = vi.hoisted(() => ({ saveIssueVote: vi.fn(), })); -vi.mock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - budgetService: () => mockBudgetService, - companyPortabilityService: () => mockCompanyPortabilityService, - companyService: () => mockCompanyService, - feedbackService: () => mockFeedbackService, - logActivity: mockLogActivity, -})); - function registerModuleMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/budgets.js", () => ({ + budgetService: () => mockBudgetService, + })); + + vi.doMock("../services/companies.js", () => ({ + companyService: () => mockCompanyService, + })); + + vi.doMock("../services/company-portability.js", () => ({ + companyPortabilityService: () => mockCompanyPortabilityService, + })); + + vi.doMock("../services/feedback.js", () => ({ + feedbackService: () => mockFeedbackService, + })); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, @@ -77,9 +97,43 @@ async function createApp(actor: Record) { return app; } +const companyId = "11111111-1111-4111-8111-111111111111"; + +const exportRequest = { + include: { company: true, agents: true, projects: true }, +}; + +function createExportResult() { + return { + rootPath: "taskcore", + manifest: { + agents: [], + skills: [], + projects: [], + issues: [], + envInputs: [], + includes: { company: true, agents: true, projects: true, issues: false, skills: false }, + company: null, + schemaVersion: 1, + generatedAt: "2026-01-01T00:00:00.000Z", + source: null, + }, + files: {}, + warnings: [], + }; +} + describe("company portability routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/budgets.js"); + vi.doUnmock("../services/companies.js"); + vi.doUnmock("../services/company-portability.js"); + vi.doUnmock("../services/feedback.js"); + vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/companies.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); @@ -90,30 +144,53 @@ describe("company portability routes", () => { it("rejects non-CEO agents from CEO-safe export preview routes", async () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", - companyId: "11111111-1111-4111-8111-111111111111", + companyId, role: "engineer", }); const app = await createApp({ type: "agent", agentId: "agent-1", - companyId: "11111111-1111-4111-8111-111111111111", + companyId, source: "agent_key", runId: "run-1", }); const res = await request(app) - .post("/api/companies/11111111-1111-4111-8111-111111111111/exports/preview") - .send({ include: { company: true, agents: true, projects: true } }); + .post(`/api/companies/${companyId}/exports/preview`) + .send(exportRequest); expect(res.status).toBe(403); expect(res.body.error).toContain("Only CEO agents"); expect(mockCompanyPortabilityService.previewExport).not.toHaveBeenCalled(); }); + it("rejects non-CEO agents from legacy and CEO-safe export bundle routes", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId, + role: "engineer", + }); + const app = await createApp({ + type: "agent", + agentId: "agent-1", + companyId, + source: "agent_key", + runId: "run-1", + }); + + for (const path of [`/api/companies/${companyId}/export`, `/api/companies/${companyId}/exports`]) { + const res = await request(app).post(path).send(exportRequest); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Only CEO agents"); + } + expect(mockCompanyPortabilityService.exportBundle).not.toHaveBeenCalled(); + }); + it("allows CEO agents to use company-scoped export preview routes", async () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", - companyId: "11111111-1111-4111-8111-111111111111", + companyId, role: "ceo", }); mockCompanyPortabilityService.previewExport.mockResolvedValue({ @@ -128,19 +205,64 @@ describe("company portability routes", () => { const app = await createApp({ type: "agent", agentId: "agent-1", - companyId: "11111111-1111-4111-8111-111111111111", + companyId, source: "agent_key", runId: "run-1", }); const res = await request(app) - .post("/api/companies/11111111-1111-4111-8111-111111111111/exports/preview") - .send({ include: { company: true, agents: true, projects: true } }); + .post(`/api/companies/${companyId}/exports/preview`) + .send(exportRequest); expect(res.status).toBe(200); expect(res.body.rootPath).toBe("taskcore"); }); + it("allows CEO agents to export through legacy and CEO-safe bundle routes", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId, + role: "ceo", + }); + mockCompanyPortabilityService.exportBundle.mockResolvedValue(createExportResult()); + const app = await createApp({ + type: "agent", + agentId: "agent-1", + companyId, + source: "agent_key", + runId: "run-1", + }); + + for (const path of [`/api/companies/${companyId}/export`, `/api/companies/${companyId}/exports`]) { + const res = await request(app).post(path).send(exportRequest); + + expect(res.status).toBe(200); + expect(res.body.rootPath).toBe("taskcore"); + } + expect(mockCompanyPortabilityService.exportBundle).toHaveBeenCalledTimes(2); + expect(mockCompanyPortabilityService.exportBundle).toHaveBeenNthCalledWith(1, companyId, exportRequest); + expect(mockCompanyPortabilityService.exportBundle).toHaveBeenNthCalledWith(2, companyId, exportRequest); + }); + + it("allows board users to export through legacy and CEO-safe bundle routes", async () => { + mockCompanyPortabilityService.exportBundle.mockResolvedValue(createExportResult()); + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + for (const path of [`/api/companies/${companyId}/export`, `/api/companies/${companyId}/exports`]) { + const res = await request(app).post(path).send(exportRequest); + + expect(res.status).toBe(200); + expect(res.body.rootPath).toBe("taskcore"); + } + expect(mockCompanyPortabilityService.exportBundle).toHaveBeenCalledTimes(2); + }); + it("rejects replace collision strategy on CEO-safe import routes", async () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 492697c..8e38efd 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -59,6 +59,11 @@ const assetSvc = { create: vi.fn(), }; +const secretSvc = { + normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config), + resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record) => ({ config, secretKeys: new Set() })), +}; + const agentInstructionsSvc = { exportFiles: vi.fn(), materializeManagedBundle: vi.fn(), @@ -96,6 +101,10 @@ vi.mock("../services/assets.js", () => ({ assetService: () => assetSvc, })); +vi.mock("../services/secrets.js", () => ({ + secretService: () => secretSvc, +})); + vi.mock("../services/agent-instructions.js", () => ({ agentInstructionsService: () => agentInstructionsSvc, })); @@ -117,6 +126,11 @@ describe("company portability", () => { beforeEach(() => { vi.clearAllMocks(); + secretSvc.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config); + secretSvc.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({ + config, + secretKeys: new Set(), + })); companySvc.getById.mockResolvedValue({ id: "company-1", name: "Taskcore", @@ -127,6 +141,11 @@ describe("company portability", () => { logoUrl: null, requireBoardApprovalForNewAgents: true, }); + companySvc.create.mockResolvedValue({ + id: "company-imported", + name: "Imported Taskcore", + requireBoardApprovalForNewAgents: true, + }); agentSvc.list.mockResolvedValue([ { id: "agent-1", @@ -1509,7 +1528,7 @@ describe("company portability", () => { }), ]); - await portability.importBundle({ + const result = await portability.importBundle({ source: { type: "inline", rootPath: "taskcore-demo", files }, include: { company: true, agents: true, projects: true, issues: true, skills: false }, target: { mode: "new_company", newCompanyName: "Imported Taskcore" }, @@ -1526,6 +1545,9 @@ describe("company portability", () => { concurrencyPolicy: "always_enqueue", catchUpPolicy: "enqueue_missed_with_cap", }), expect.any(Object)); + expect(result.warnings).not.toContain( + "Task monday-review assignee claudecoder is pending_approval; imported work was left unassigned.", + ); expect(routineSvc.createTrigger).toHaveBeenCalledTimes(2); expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({ kind: "schedule", @@ -2110,6 +2132,7 @@ describe("company portability", () => { runtimeConfig: { heartbeat: { enabled: false, + maxConcurrentRuns: 5, }, }, }); @@ -2188,6 +2211,7 @@ describe("company portability", () => { runtimeConfig: { heartbeat: { enabled: false, + maxConcurrentRuns: 5, }, }, })); @@ -2418,4 +2442,331 @@ describe("company portability", () => { expect(nestedMaterializedFiles?.["AGENTS.md"]).not.toMatch(/^---\n/); expect(nestedMaterializedFiles?.["AGENTS.md"]).not.toContain('name: "ClaudeCoder"'); }); + + it("rejects dangerous adapter types on agent-safe imports", async () => { + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + agentSvc.list.mockResolvedValue([]); + + await expect(portability.importBundle({ + source: { + type: "inline", + rootPath: exported.rootPath, + files: exported.files, + }, + include: { + company: false, + agents: true, + projects: false, + issues: false, + }, + target: { + mode: "existing_company", + companyId: "company-1", + }, + agents: ["claudecoder"], + collisionStrategy: "rename", + adapterOverrides: { + claudecoder: { + adapterType: "process", + adapterConfig: { + command: "/bin/sh", + args: ["-c", "id"], + }, + }, + }, + }, "user-1", { + mode: "agent_safe", + sourceCompanyId: "company-1", + })).rejects.toThrow('Adapter type "process" is not allowed in safe imports'); + + expect(agentSvc.create).not.toHaveBeenCalled(); + }); + + it("imports new agents as active while preserving future hire approval settings", async () => { + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + agentSvc.list.mockResolvedValue([]); + secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({ + normalized: true, + env: { + OPENAI_API_KEY: { + type: "secret_ref", + secretId: "secret-1", + version: "latest", + }, + }, + }); + agentSvc.create.mockImplementation(async (_companyId: string, input: Record) => ({ + id: "agent-created", + name: String(input.name), + adapterType: input.adapterType, + adapterConfig: input.adapterConfig, + status: input.status, + })); + + await portability.importBundle({ + source: { + type: "inline", + rootPath: exported.rootPath, + files: exported.files, + }, + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + target: { + mode: "new_company", + newCompanyName: "Imported Taskcore", + }, + agents: ["claudecoder"], + collisionStrategy: "rename", + }, "user-1"); + + expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith( + "company-imported", + expect.any(Object), + { strictMode: false }, + ); + expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ + adapterType: "claude_local", + adapterConfig: expect.objectContaining({ + normalized: true, + }), + status: "idle", + })); + expect(companySvc.create).toHaveBeenCalledWith(expect.objectContaining({ + requireBoardApprovalForNewAgents: true, + })); + }); + + it("normalizes adapter config on replace imports before updating existing agents", async () => { + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({ + normalized: "updated", + }); + agentSvc.update.mockImplementation(async (id: string, patch: Record) => ({ + id, + name: "ClaudeCoder", + adapterType: patch.adapterType, + adapterConfig: patch.adapterConfig, + })); + + await portability.importBundle({ + source: { + type: "inline", + rootPath: exported.rootPath, + files: exported.files, + }, + include: { + company: false, + agents: true, + projects: false, + issues: false, + }, + target: { + mode: "existing_company", + companyId: "company-1", + }, + agents: ["claudecoder"], + collisionStrategy: "replace", + adapterOverrides: { + claudecoder: { + adapterType: "codex_local", + adapterConfig: { + model: "gpt-5.4", + }, + }, + }, + }, "user-1"); + + expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith( + "company-1", + expect.any(Object), + { strictMode: false }, + ); + expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({ + adapterType: "codex_local", + adapterConfig: { + normalized: "updated", + }, + })); + }); + + it("nameOverrides applied after collision detection do not re-validate uniqueness", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { company: false, agents: true, projects: false, issues: false }, + }); + + // Simulate existing agents so collision detection triggers rename + agentSvc.list.mockResolvedValue([ + { id: "existing-1", name: "ClaudeCoder", status: "idle", role: "engineer", adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null }, + ]); + + const preview = await portability.previewImport({ + source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, + include: { company: false, agents: true, projects: false, issues: false }, + target: { mode: "existing_company", companyId: "company-1" }, + agents: ["claudecoder"], + collisionStrategy: "rename", + nameOverrides: { claudecoder: "ClaudeCoder" }, + }); + + // The override reverts the renamed agent back to its original collision name. + // This is a known limitation: nameOverrides bypass collision checks. + const plan = preview.plan.agentPlans.find((p) => p.slug === "claudecoder"); + expect(plan).toBeDefined(); + expect(plan!.action).toBe("create"); + expect(plan!.plannedName).toBe("ClaudeCoder"); + }); + + it("handles circular reportsTo chains without infinite recursion during export", async () => { + const portability = companyPortabilityService({} as any); + + agentSvc.list.mockResolvedValue([ + { + id: "agent-a", name: "AgentA", status: "idle", role: "engineer", title: null, icon: null, + reportsTo: "agent-b", capabilities: null, adapterType: "claude_local", + adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null, + }, + { + id: "agent-b", name: "AgentB", status: "idle", role: "manager", title: null, icon: null, + reportsTo: "agent-a", capabilities: null, adapterType: "claude_local", + adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null, + }, + ]); + agentInstructionsSvc.exportFiles.mockResolvedValue({ + files: { "AGENTS.md": "Instructions" }, entryFile: "AGENTS.md", warnings: [], + }); + + // Export should complete without infinite recursion in org chart building + const exported = await portability.exportBundle("company-1", { + include: { company: true, agents: true, projects: false, issues: false }, + }); + + expect(exported.manifest.agents).toHaveLength(2); + // Both agents should appear in the export + const slugs = exported.manifest.agents.map((a) => a.slug); + expect(slugs).toContain("agenta"); + expect(slugs).toContain("agentb"); + }); + + it("resolves issue assignee to existing agent when agent is skipped", async () => { + const portability = companyPortabilityService({} as any); + + projectSvc.list.mockResolvedValue([{ + id: "project-1", companyId: "company-1", name: "TestProject", urlKey: "testproject", + description: null, leadAgentId: null, targetDate: null, color: null, status: "planned", + executionWorkspacePolicy: null, archivedAt: null, workspaces: [], + }]); + issueSvc.list.mockResolvedValue([{ + id: "issue-1", companyId: "company-1", title: "Test task", identifier: "PAP-1", + description: "A test task", status: "todo", priority: "medium", + assigneeAgentId: "agent-1", projectId: "project-1", projectWorkspaceId: null, + goalId: null, parentId: null, billingCode: null, labelIds: [], + executionWorkspaceSettings: null, assigneeAdapterOverrides: null, metadata: null, + }]); + + const exported = await portability.exportBundle("company-1", { + include: { company: false, agents: true, projects: true, issues: true }, + }); + + // Re-import into same company with skip collision strategy + // Both agents exist so both will be skipped; the existing agent should resolve for issue assignment + agentSvc.list.mockResolvedValue([ + { id: "agent-1", name: "ClaudeCoder", status: "idle", role: "engineer", adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null }, + { id: "agent-2", name: "CMO", status: "idle", role: "cmo", adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null }, + ]); + projectSvc.list.mockResolvedValue([]); + issueSvc.list.mockResolvedValue([]); + projectSvc.create.mockResolvedValue({ id: "project-new", companyId: "company-1", urlKey: "testproject" }); + issueSvc.create.mockResolvedValue({ id: "issue-new", identifier: "PAP-100" }); + + const result = await portability.importBundle({ + source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, + include: { company: false, agents: true, projects: true, issues: true }, + target: { mode: "existing_company", companyId: "company-1" }, + agents: "all", + collisionStrategy: "skip", + }, "user-1"); + + // Both agents should be skipped (already exist) + const agentResult = result.agents.find((a) => a.slug === "claudecoder"); + expect(agentResult).toBeDefined(); + expect(agentResult!.action).toBe("skipped"); + + // Issue should still be created and reference the existing agent + expect(issueSvc.create).toHaveBeenCalled(); + const issueCreateCall = issueSvc.create.mock.calls[0]; + // The assigneeAgentId should resolve to the existing agent via existingSlugToAgentId + expect(issueCreateCall[1]).toEqual(expect.objectContaining({ + assigneeAgentId: "agent-1", + })); + }); + + it("handles a package with only skills (no agents or projects)", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { company: false, agents: false, projects: false, issues: false, skills: true }, + expandReferencedSkills: true, + }); + + expect(exported.manifest.agents).toHaveLength(0); + expect(exported.manifest.projects).toHaveLength(0); + expect(exported.manifest.issues).toHaveLength(0); + // Skills should still be exported + expect(exported.manifest.skills.length).toBeGreaterThanOrEqual(0); + }); + + it("preview import detects no agents to import when agents are excluded", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { company: true, agents: true, projects: false, issues: false }, + }); + + agentSvc.list.mockResolvedValue([]); + + const preview = await portability.previewImport({ + source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, + include: { company: false, agents: false, projects: false, issues: false }, + target: { mode: "existing_company", companyId: "company-1" }, + agents: "all", + collisionStrategy: "rename", + }); + + expect(preview.plan.agentPlans).toHaveLength(0); + expect(preview.plan.projectPlans).toHaveLength(0); + expect(preview.plan.issuePlans).toHaveLength(0); + }); }); diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 80d45ad..a63e487 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -20,21 +20,41 @@ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackSkillImported = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); -vi.mock("@taskcore/shared/telemetry", () => ({ - trackSkillImported: mockTrackSkillImported, - trackErrorHandlerCrash: vi.fn(), -})); +function registerModuleMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); -vi.mock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, -})); + vi.doMock("@taskcore/shared/telemetry", () => ({ + trackSkillImported: mockTrackSkillImported, + trackErrorHandlerCrash: vi.fn(), + })); -vi.mock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - companySkillService: () => mockCompanySkillService, - logActivity: mockLogActivity, -})); + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, + })); + + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/company-skills.js", () => ({ + companySkillService: () => mockCompanySkillService, + })); + + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + companySkillService: () => mockCompanySkillService, + logActivity: mockLogActivity, + })); +} async function createApp(actor: Record) { const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([ @@ -55,9 +75,17 @@ async function createApp(actor: Record) { describe("company skill mutation permissions", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("@taskcore/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/company-skills.js"); + vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/company-skills.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockCompanySkillService.importFromSource.mockResolvedValue({ @@ -86,10 +114,10 @@ describe("company skill mutation permissions", () => { .send({ source: "https://github.com/vercel-labs/agent-browser" }); expect([200, 201], JSON.stringify(res.body)).toContain(res.status); - expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( - "company-1", - "https://github.com/vercel-labs/agent-browser", - ); + expect(res.body).toEqual({ + imported: [], + warnings: [], + }); }); it("tracks public GitHub skill imports with an explicit skill reference", async () => { diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts new file mode 100644 index 0000000..5dc361d --- /dev/null +++ b/server/src/__tests__/company-skills-service.test.ts @@ -0,0 +1,92 @@ +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { companies, companySkills, createDb } from "@taskcore/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { companySkillService } from "../services/company-skills.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company skill service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("companySkillService.list", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + const cleanupDirs = new Set(); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-company-skills-service-"); + db = createDb(tempDb.connectionString); + svc = companySkillService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(companySkills); + await db.delete(companies); + await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("lists skills without exposing markdown content", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "taskcore-heavy-skill-")); + cleanupDirs.add(skillDir); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Heavy Skill\n", "utf8"); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: `company/${companyId}/heavy-skill`, + slug: "heavy-skill", + name: "Heavy Skill", + description: "Large skill used for list projection regression coverage.", + markdown: `# Heavy Skill\n\n${"x".repeat(250_000)}`, + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "local_path" }, + }); + + const listed = await svc.list(companyId); + const skill = listed.find((entry) => entry.id === skillId); + + expect(skill).toBeDefined(); + expect(skill).not.toHaveProperty("markdown"); + expect(skill).toMatchObject({ + id: skillId, + key: `company/${companyId}/heavy-skill`, + slug: "heavy-skill", + name: "Heavy Skill", + sourceType: "local_path", + sourceLocator: skillDir, + attachedAgentCount: 0, + sourceBadge: "local", + editable: true, + }); + }); +}); diff --git a/server/src/__tests__/company-skills.test.ts b/server/src/__tests__/company-skills.test.ts index 6b92ab3..8467a84 100644 --- a/server/src/__tests__/company-skills.test.ts +++ b/server/src/__tests__/company-skills.test.ts @@ -179,7 +179,7 @@ describe("project workspace skill discovery", () => { sources: [ { kind: "github-dir", - repo: "khulnasoft/taskcore", + repo: "taskcore/taskcore", path: "skills/taskcore", }, ], diff --git a/server/src/__tests__/company-user-directory-route.test.ts b/server/src/__tests__/company-user-directory-route.test.ts new file mode 100644 index 0000000..c56ea4e --- /dev/null +++ b/server/src/__tests__/company-user-directory-route.test.ts @@ -0,0 +1,132 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: vi.fn(), + notifyHireApproved: vi.fn(), +})); + +function createDbStub() { + const activeMemberships = [ + { principalId: "user-2", status: "active" as const }, + { principalId: "user-1", status: "active" as const }, + ]; + const users = [ + { id: "user-1", name: "Dotta", email: "dotta@example.com", image: "https://example.com/dotta.png" }, + { id: "user-2", name: null, email: "alex@example.com", image: null }, + ]; + + const isCompanyMembershipsTable = (table: unknown) => + !!table && + typeof table === "object" && + "membershipRole" in table && + "principalType" in table && + "principalId" in table; + const isAuthUsersTable = (table: unknown) => + !!table && + typeof table === "object" && + "emailVerified" in table && + "createdAt" in table && + "updatedAt" in table; + + return { + select() { + return { + from(table: unknown) { + if (isCompanyMembershipsTable(table)) { + const query = { + where() { + return query; + }, + orderBy() { + return Promise.resolve(activeMemberships); + }, + }; + return query; + } + if (isAuthUsersTable(table)) { + return { + where() { + return Promise.resolve(users); + }, + }; + } + throw new Error("Unexpected table"); + }, + }; + }, + }; +} + +function createApp(actor: Express.Request["actor"]) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use( + "/api", + accessRoutes(createDbStub() as never, { + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("GET /companies/:companyId/user-directory", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns active human users for operators without manage-permissions access", async () => { + const app = createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }], + }); + + const res = await request(app).get("/api/companies/company-1/user-directory"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + users: [ + { + principalId: "user-2", + status: "active", + user: { id: "user-2", name: null, email: "alex@example.com", image: null }, + }, + { + principalId: "user-1", + status: "active", + user: { id: "user-1", name: "Dotta", email: "dotta@example.com", image: "https://example.com/dotta.png" }, + }, + ], + }); + }); +}); diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index e198874..447b7d0 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -1,6 +1,15 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll } from "vitest"; +import { randomUUID } from "node:crypto"; +import { createDb, companies, agents, costEvents, financeEvents, projects } from "@taskcore/db"; +import { costService } from "../services/costs.ts"; +import { financeService } from "../services/finance.ts"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; function makeDb(overrides: Record = {}) { const selectChain = { @@ -138,6 +147,13 @@ beforeEach(() => { budgetMonthlyCents: 100, spentMonthlyCents: 0, }); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + name: "Budget Agent", + budgetMonthlyCents: 100, + spentMonthlyCents: 0, + }); mockAgentService.update.mockResolvedValue({ id: "agent-1", companyId: "company-1", @@ -176,7 +192,13 @@ describe("cost routes", () => { .get("/api/companies/company-1/costs/finance-summary") .query({ from: "2026-02-01T00:00:00.000Z", to: "2026-02-28T23:59:59.999Z" }); expect(res.status).toBe(200); - expect(mockFinanceService.summary).toHaveBeenCalled(); + expect(res.body).toEqual({ + debitCents: 0, + creditCents: 0, + netCents: 0, + estimatedDebitCents: 0, + eventCount: 0, + }); }); it("returns 400 for invalid finance event list limits", async () => { @@ -207,11 +229,66 @@ describe("cost routes", () => { }); it("rejects agent budget updates for board users outside the agent company", async () => { - mockAgentService.getById.mockResolvedValue({ + const app = await createAppWithActor({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-2"], + }); + + const res = await request(app) + .patch("/api/agents/agent-1/budgets") + .send({ budgetMonthlyCents: 2500 }); + + expect(res.status).toBe(403); + expect(mockAgentService.update).not.toHaveBeenCalled(); + }); + + it("rejects agent budget updates from the target agent without changing the budget policy", async () => { + const app = await createAppWithActor({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + runId: "run-1", + }); + + const res = await request(app) + .patch("/api/agents/agent-1/budgets") + .send({ budgetMonthlyCents: 2500 }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: "Board access required" }); + expect(mockAgentService.update).not.toHaveBeenCalled(); + expect(mockBudgetService.upsertPolicy).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("rejects agent budget updates from another same-company agent without changing the budget policy", async () => { + const app = await createAppWithActor({ + type: "agent", + agentId: "agent-2", + companyId: "company-1", + runId: "run-2", + }); + + const res = await request(app) + .patch("/api/agents/agent-1/budgets") + .send({ budgetMonthlyCents: 2500 }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: "Board access required" }); + expect(mockAgentService.update).not.toHaveBeenCalled(); + expect(mockBudgetService.upsertPolicy).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("allows authorized board users to update an agent budget and budget policy", async () => { + mockAgentService.update.mockResolvedValueOnce({ id: "agent-1", companyId: "company-1", name: "Budget Agent", - budgetMonthlyCents: 100, + budgetMonthlyCents: 2500, spentMonthlyCents: 0, }); const app = await createAppWithActor({ @@ -219,14 +296,189 @@ describe("cost routes", () => { userId: "board-user", source: "session", isInstanceAdmin: false, - companyIds: ["company-2"], + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }], }); const res = await request(app) .patch("/api/agents/agent-1/budgets") .send({ budgetMonthlyCents: 2500 }); - expect(res.status).toBe(403); - expect(mockAgentService.update).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + expect(mockAgentService.update).toHaveBeenCalledWith("agent-1", { budgetMonthlyCents: 2500 }); + expect(mockBudgetService.upsertPolicy).toHaveBeenCalledWith( + "company-1", + { + scopeType: "agent", + scopeId: "agent-1", + amount: 2500, + windowKind: "calendar_month_utc", + }, + "board-user", + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + companyId: "company-1", + actorType: "user", + actorId: "board-user", + agentId: null, + action: "agent.budget_updated", + entityType: "agent", + entityId: "agent-1", + details: { budgetMonthlyCents: 2500 }, + }), + ); + }); +}); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { + let db!: ReturnType; + let costs!: ReturnType; + let finance!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-costs-service-"); + db = createDb(tempDb.connectionString); + costs = costService(db); + finance = financeService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(financeEvents); + await db.delete(costEvents); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("aggregates cost event sums above int32 without raising Postgres integer overflow", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const projectId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Cost Agent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Overflow Project", + status: "active", + }); + + await db.insert(costEvents).values([ + { + companyId, + agentId, + projectId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 2_000_000_000, + cachedInputTokens: 0, + outputTokens: 200_000_000, + costCents: 2_000_000_000, + occurredAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + companyId, + agentId, + projectId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 2_000_000_000, + cachedInputTokens: 10, + outputTokens: 200_000_000, + costCents: 2_000_000_000, + occurredAt: new Date("2026-04-11T00:00:00.000Z"), + }, + ]); + + const range = { + from: new Date("2026-04-01T00:00:00.000Z"), + to: new Date("2026-04-15T23:59:59.999Z"), + }; + + const [byAgentRow] = await costs.byAgent(companyId, range); + const [byProjectRow] = await costs.byProject(companyId, range); + const [byAgentModelRow] = await costs.byAgentModel(companyId, range); + + expect(byAgentRow?.costCents).toBe(4_000_000_000); + expect(byAgentRow?.inputTokens).toBe(4_000_000_000); + expect(byProjectRow?.costCents).toBe(4_000_000_000); + expect(byAgentModelRow?.costCents).toBe(4_000_000_000); + }); + + it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => { + const companyId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(financeEvents).values([ + { + companyId, + biller: "openai", + eventKind: "invoice", + amountCents: 2_000_000_000, + currency: "USD", + direction: "debit", + estimated: false, + occurredAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + companyId, + biller: "openai", + eventKind: "invoice", + amountCents: 2_000_000_000, + currency: "USD", + direction: "debit", + estimated: true, + occurredAt: new Date("2026-04-11T00:00:00.000Z"), + }, + ]); + + const range = { + from: new Date("2026-04-01T00:00:00.000Z"), + to: new Date("2026-04-15T23:59:59.999Z"), + }; + + const summary = await finance.summary(companyId, range); + const [byKindRow] = await finance.byKind(companyId, range); + + expect(summary.debitCents).toBe(4_000_000_000); + expect(summary.estimatedDebitCents).toBe(2_000_000_000); + expect(byKindRow?.debitCents).toBe(4_000_000_000); + expect(byKindRow?.netCents).toBe(4_000_000_000); }); }); diff --git a/server/src/__tests__/cursor-local-adapter.test.ts b/server/src/__tests__/cursor-local-adapter.test.ts index ad8d5f4..0c8ec54 100644 --- a/server/src/__tests__/cursor-local-adapter.test.ts +++ b/server/src/__tests__/cursor-local-adapter.test.ts @@ -289,7 +289,7 @@ function stripAnsi(value: string): string { describe("cursor cli formatter", () => { it("prints init, user, assistant, tool, and result events", () => { - const spy = vi.spyOn(console, "log").mockImplementation(() => { }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); try { printCursorStreamEvent( diff --git a/server/src/__tests__/cursor-local-execute.test.ts b/server/src/__tests__/cursor-local-execute.test.ts index 7f954c2..b984324 100644 --- a/server/src/__tests__/cursor-local-execute.test.ts +++ b/server/src/__tests__/cursor-local-execute.test.ts @@ -93,7 +93,7 @@ describe("cursor execute", () => { }, context: {}, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, onMeta: async (meta) => { invocationPrompt = meta.prompt ?? ""; }, @@ -168,7 +168,7 @@ describe("cursor execute", () => { }, context: {}, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, }); expect(result.exitCode).toBe(0); @@ -240,8 +240,8 @@ describe("cursor execute", () => { }, context: {}, authToken: "run-jwt-token", - onLog: async () => { }, - onMeta: async () => { }, + onLog: async () => {}, + onMeta: async () => {}, }); expect(result.exitCode).toBe(0); diff --git a/server/src/__tests__/cursor-local-skill-injection.test.ts b/server/src/__tests__/cursor-local-skill-injection.test.ts index d6e3b09..80a46bf 100644 --- a/server/src/__tests__/cursor-local-skill-injection.test.ts +++ b/server/src/__tests__/cursor-local-skill-injection.test.ts @@ -63,7 +63,7 @@ describe("cursor local adapter skill injection", () => { await fs.mkdir(existingTarget, { recursive: true }); await fs.writeFile(path.join(existingTarget, "keep.txt"), "keep", "utf8"); - await ensureCursorSkillsInjected(async () => { }, { skillsDir, skillsHome }); + await ensureCursorSkillsInjected(async () => {}, { skillsDir, skillsHome }); expect((await fs.lstat(existingTarget)).isDirectory()).toBe(true); expect(await fs.readFile(path.join(existingTarget, "keep.txt"), "utf8")).toBe("keep"); diff --git a/server/src/__tests__/dashboard-service.test.ts b/server/src/__tests__/dashboard-service.test.ts new file mode 100644 index 0000000..4d5beea --- /dev/null +++ b/server/src/__tests__/dashboard-service.test.ts @@ -0,0 +1,169 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { agents, companies, createDb, heartbeatRuns } from "@taskcore/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { dashboardService, getUtcMonthStart } from "../services/dashboard.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres dashboard service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +function utcDay(offsetDays: number): Date { + const now = new Date(); + const day = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + offsetDays, 12); + return new Date(day); +} + +function utcDateKey(date: Date): string { + return date.toISOString().slice(0, 10); +} + +describe("getUtcMonthStart", () => { + it("anchors the monthly spend window to UTC month boundaries", () => { + expect(getUtcMonthStart(new Date("2026-03-31T20:30:00.000-05:00")).toISOString()).toBe( + "2026-04-01T00:00:00.000Z", + ); + expect(getUtcMonthStart(new Date("2026-04-01T00:30:00.000+14:00")).toISOString()).toBe( + "2026-03-01T00:00:00.000Z", + ); + }); +}); + +describeEmbeddedPostgres("dashboard service", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-dashboard-service-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(heartbeatRuns); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("aggregates the full 14-day run activity window without recent-run truncation", async () => { + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const agentId = randomUUID(); + const otherAgentId = randomUUID(); + const today = utcDay(0); + const weekAgo = utcDay(-7); + + await db.insert(companies).values([ + { + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }, + { + id: otherCompanyId, + name: "Other", + issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }, + ]); + + await db.insert(agents).values([ + { + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "running", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: otherAgentId, + companyId: otherCompanyId, + name: "OtherAgent", + role: "engineer", + status: "running", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + await db.insert(heartbeatRuns).values([ + ...Array.from({ length: 105 }, () => ({ + id: randomUUID(), + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + createdAt: today, + })), + { + id: randomUUID(), + companyId, + agentId, + invocationSource: "assignment", + status: "failed", + createdAt: weekAgo, + }, + { + id: randomUUID(), + companyId, + agentId, + invocationSource: "assignment", + status: "timed_out", + createdAt: weekAgo, + }, + { + id: randomUUID(), + companyId, + agentId, + invocationSource: "assignment", + status: "cancelled", + createdAt: weekAgo, + }, + { + id: randomUUID(), + companyId: otherCompanyId, + agentId: otherAgentId, + invocationSource: "assignment", + status: "succeeded", + createdAt: weekAgo, + }, + ]); + + const summary = await dashboardService(db).summary(companyId); + + expect(summary.runActivity).toHaveLength(14); + const todayBucket = summary.runActivity.find((bucket) => bucket.date === utcDateKey(today)); + const weekAgoBucket = summary.runActivity.find((bucket) => bucket.date === utcDateKey(weekAgo)); + + expect(todayBucket).toMatchObject({ + succeeded: 105, + failed: 0, + other: 0, + total: 105, + }); + expect(weekAgoBucket).toMatchObject({ + succeeded: 0, + failed: 2, + other: 1, + total: 3, + }); + }); +}); diff --git a/server/src/__tests__/dev-runner-paths.test.ts b/server/src/__tests__/dev-runner-paths.test.ts index 874064b..3ad105b 100644 --- a/server/src/__tests__/dev-runner-paths.test.ts +++ b/server/src/__tests__/dev-runner-paths.test.ts @@ -2,12 +2,15 @@ import { describe, expect, it } from "vitest"; import { shouldTrackDevServerPath } from "../../../scripts/dev-runner-paths.mjs"; describe("shouldTrackDevServerPath", () => { - it("ignores repo-local Taskcore state and common test file paths", () => { + it("ignores generated state, diagnostic reports, and common test file paths", () => { expect( shouldTrackDevServerPath( ".taskcore/worktrees/PAP-712-for-project-configuration-get-rid-of-the-overview-tab-for-now/.agents/skills/taskcore", ), ).toBe(false); + expect(shouldTrackDevServerPath("server/report.20260416.154629.4965.0.001.json")).toBe(false); + expect(shouldTrackDevServerPath("server/report.20260416.154636.4725.0.001.json")).toBe(false); + expect(shouldTrackDevServerPath("server/report.20260416.154636.4965.0.002.json")).toBe(false); expect(shouldTrackDevServerPath("server/src/__tests__/health.test.ts")).toBe(false); expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.test.ts")).toBe(false); expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.spec.tsx")).toBe(false); diff --git a/server/src/__tests__/documents-service.test.ts b/server/src/__tests__/documents-service.test.ts new file mode 100644 index 0000000..b13feb9 --- /dev/null +++ b/server/src/__tests__/documents-service.test.ts @@ -0,0 +1,115 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + companies, + createDb, + documentRevisions, + documents, + issueDocuments, + issues, +} from "@taskcore/db"; +import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@taskcore/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { documentService } from "../services/documents.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres document service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("documentService system issue documents", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-documents-service-"); + db = createDb(tempDb.connectionString); + svc = documentService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(documentRevisions); + await db.delete(issueDocuments); + await db.delete(documents); + await db.delete(issues); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function createIssueWithDocuments() { + const companyId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + identifier: "PAP-1600", + title: "System document filtering", + description: "Validate document filtering", + status: "in_progress", + priority: "medium", + }); + + await svc.upsertIssueDocument({ + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "# Plan", + }); + await svc.upsertIssueDocument({ + issueId, + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + title: "Continuation Summary", + format: "markdown", + body: "# Handoff", + }); + + return { issueId }; + } + + it("filters continuation summaries from default document lists and issue payload summaries", async () => { + const { issueId } = await createIssueWithDocuments(); + + const defaultDocuments = await svc.listIssueDocuments(issueId); + expect(defaultDocuments.map((doc) => doc.key)).toEqual(["plan"]); + + const payload = await svc.getIssueDocumentPayload({ id: issueId, description: null }); + expect(payload.planDocument?.key).toBe("plan"); + expect(payload.documentSummaries.map((doc) => doc.key)).toEqual(["plan"]); + }); + + it("keeps system documents available for includeSystem and direct fetch callers", async () => { + const { issueId } = await createIssueWithDocuments(); + + const debugDocuments = await svc.listIssueDocuments(issueId, { includeSystem: true }); + expect(debugDocuments.map((doc) => doc.key)).toEqual([ + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + "plan", + ]); + + const directHandoff = await svc.getIssueDocumentByKey(issueId, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY); + expect(directHandoff).toEqual(expect.objectContaining({ + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + body: "# Handoff", + })); + }); +}); diff --git a/server/src/__tests__/execution-workspaces-routes.test.ts b/server/src/__tests__/execution-workspaces-routes.test.ts new file mode 100644 index 0000000..a5295fe --- /dev/null +++ b/server/src/__tests__/execution-workspaces-routes.test.ts @@ -0,0 +1,90 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + list: vi.fn(), + listSummaries: vi.fn(), + getById: vi.fn(), + getCloseReadiness: vi.fn(), + update: vi.fn(), +})); + +const mockWorkspaceOperationService = vi.hoisted(() => ({ + listForExecutionWorkspace: vi.fn(), + createRecorder: vi.fn(), +})); + +function registerServiceMocks() { + vi.doMock("../services/index.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, + logActivity: vi.fn(async () => undefined), + workspaceOperationService: () => mockWorkspaceOperationService, + })); +} + +async function createApp() { + const [{ executionWorkspaceRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/execution-workspaces.js"), + vi.importActual("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", executionWorkspaceRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("execution workspace routes", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/execution-workspaces.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerServiceMocks(); + vi.resetAllMocks(); + mockExecutionWorkspaceService.list.mockResolvedValue([]); + mockExecutionWorkspaceService.listSummaries.mockResolvedValue([ + { + id: "workspace-1", + name: "Alpha", + mode: "isolated_workspace", + projectWorkspaceId: null, + }, + ]); + }); + + it("uses summary mode for lightweight workspace lookups", async () => { + const res = await request(await createApp()) + .get("/api/companies/company-1/execution-workspaces?summary=true&reuseEligible=true"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + { + id: "workspace-1", + name: "Alpha", + mode: "isolated_workspace", + projectWorkspaceId: null, + }, + ]); + expect(mockExecutionWorkspaceService.listSummaries).toHaveBeenCalledWith("company-1", { + projectId: undefined, + projectWorkspaceId: undefined, + issueId: undefined, + status: undefined, + reuseEligible: true, + }); + expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 85b24c7..6b66c50 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -12,7 +12,6 @@ import { issues, projectWorkspaces, projects, - workspaceRuntimeServices, } from "@taskcore/db"; import { getEmbeddedPostgresTestSupport, @@ -136,7 +135,6 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { afterEach(async () => { await db.delete(issues); - await db.delete(workspaceRuntimeServices); await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects); @@ -326,136 +324,4 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { "git_branch_delete", ])); }, 20_000); - - it("shows inherited shared project runtime services on shared execution workspaces without duplicating old history", async () => { - const companyId = randomUUID(); - const projectId = randomUUID(); - const projectWorkspaceId = randomUUID(); - const executionWorkspaceId = randomUUID(); - const olderServiceId = randomUUID(); - const currentServiceId = randomUUID(); - const reuseKey = `project_workspace:${projectWorkspaceId}:taskcore-dev`; - const startedAt = new Date("2026-04-04T17:00:00.000Z"); - const stoppedAt = new Date("2026-04-04T17:05:00.000Z"); - const runningAt = new Date("2026-04-04T17:10:00.000Z"); - - await db.insert(companies).values({ - id: companyId, - name: "Taskcore", - issuePrefix: "PAP", - requireBoardApprovalForNewAgents: false, - }); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Workspaces", - status: "in_progress", - executionWorkspacePolicy: { - enabled: true, - }, - }); - await db.insert(projectWorkspaces).values({ - id: projectWorkspaceId, - companyId, - projectId, - name: "Primary", - sourceType: "local_path", - isPrimary: true, - cwd: "/tmp/taskcore-primary", - metadata: { - runtimeConfig: { - desiredState: "running", - workspaceRuntime: { - services: [{ name: "taskcore-dev", command: "pnpm dev" }], - }, - }, - }, - }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - projectWorkspaceId, - mode: "shared_workspace", - strategyType: "project_primary", - name: "Shared workspace", - status: "active", - providerType: "local_fs", - cwd: "/tmp/taskcore-primary", - }); - await db.insert(workspaceRuntimeServices).values([ - { - id: olderServiceId, - companyId, - projectId, - projectWorkspaceId, - executionWorkspaceId: null, - issueId: null, - scopeType: "project_workspace", - scopeId: projectWorkspaceId, - serviceName: "taskcore-dev", - status: "stopped", - lifecycle: "shared", - reuseKey, - command: "pnpm dev", - cwd: "/tmp/taskcore-primary", - port: 49195, - url: "http://127.0.0.1:49195", - provider: "local_process", - providerRef: "11111", - ownerAgentId: null, - startedByRunId: null, - lastUsedAt: stoppedAt, - startedAt, - stoppedAt, - stopPolicy: { type: "manual" }, - healthStatus: "unknown", - createdAt: startedAt, - updatedAt: stoppedAt, - }, - { - id: currentServiceId, - companyId, - projectId, - projectWorkspaceId, - executionWorkspaceId: null, - issueId: null, - scopeType: "project_workspace", - scopeId: projectWorkspaceId, - serviceName: "taskcore-dev", - status: "running", - lifecycle: "shared", - reuseKey, - command: "pnpm dev", - cwd: "/tmp/taskcore-primary", - port: 49222, - url: "http://127.0.0.1:49222", - provider: "local_process", - providerRef: "22222", - ownerAgentId: null, - startedByRunId: null, - lastUsedAt: runningAt, - startedAt: runningAt, - stoppedAt: null, - stopPolicy: { type: "manual" }, - healthStatus: "healthy", - createdAt: runningAt, - updatedAt: runningAt, - }, - ]); - - const workspace = await svc.getById(executionWorkspaceId); - const listed = await svc.list(companyId, { projectId }); - - expect(workspace?.runtimeServices).toHaveLength(1); - expect(workspace?.runtimeServices?.[0]).toMatchObject({ - id: currentServiceId, - status: "running", - projectWorkspaceId, - executionWorkspaceId: null, - url: "http://127.0.0.1:49222", - }); - expect(listed[0]?.runtimeServices).toHaveLength(1); - expect(listed[0]?.runtimeServices?.[0]?.id).toBe(currentServiceId); - }); }); diff --git a/server/src/__tests__/feedback-service.test.ts b/server/src/__tests__/feedback-service.test.ts index a9ace8e..8a1997a 100644 --- a/server/src/__tests__/feedback-service.test.ts +++ b/server/src/__tests__/feedback-service.test.ts @@ -79,8 +79,8 @@ async function startTempDatabase() { port, persistent: true, initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => { }, - onError: () => { }, + onLog: () => {}, + onError: () => {}, }); await instance.initialise(); await instance.start(); diff --git a/server/src/__tests__/gemini-local-adapter.test.ts b/server/src/__tests__/gemini-local-adapter.test.ts index 89adb50..a3e75f6 100644 --- a/server/src/__tests__/gemini-local-adapter.test.ts +++ b/server/src/__tests__/gemini-local-adapter.test.ts @@ -144,7 +144,7 @@ function stripAnsi(value: string): string { describe("gemini_local cli formatter", () => { it("prints init, assistant, result, and error events", () => { - const spy = vi.spyOn(console, "log").mockImplementation(() => { }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); let joined = ""; try { diff --git a/server/src/__tests__/gemini-local-execute.test.ts b/server/src/__tests__/gemini-local-execute.test.ts index 2da0ff0..5a96722 100644 --- a/server/src/__tests__/gemini-local-execute.test.ts +++ b/server/src/__tests__/gemini-local-execute.test.ts @@ -84,7 +84,7 @@ describe("gemini execute", () => { }, context: {}, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, onMeta: async (meta) => { invocationPrompt = meta.prompt ?? ""; }, @@ -150,7 +150,7 @@ describe("gemini execute", () => { }, context: {}, authToken: "t", - onLog: async () => { }, + onLog: async () => {}, }); const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; @@ -241,7 +241,7 @@ describe("gemini execute", () => { }, }, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, }); expect(result.exitCode).toBe(0); diff --git a/server/src/__tests__/health-dev-server-token.test.ts b/server/src/__tests__/health-dev-server-token.test.ts new file mode 100644 index 0000000..a1e256c --- /dev/null +++ b/server/src/__tests__/health-dev-server-token.test.ts @@ -0,0 +1,128 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import express from "express"; +import request from "supertest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Db } from "@taskcore/db"; +import { healthRoutes } from "../routes/health.js"; + +const tempDirs: string[] = []; + +function createDevServerStatusFile(payload: unknown) { + const dir = mkdtempSync(path.join(os.tmpdir(), "taskcore-health-dev-server-")); + tempDirs.push(dir); + const filePath = path.join(dir, "dev-server-status.json"); + writeFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf8"); + return filePath; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("GET /health dev-server supervisor access", () => { + it("exposes dev-server metadata to the supervising dev runner in authenticated mode", async () => { + const previousFile = process.env.TASKCORE_DEV_SERVER_STATUS_FILE; + const previousToken = process.env.TASKCORE_DEV_SERVER_STATUS_TOKEN; + process.env.TASKCORE_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({ + dirty: true, + lastChangedAt: "2026-03-20T12:00:00.000Z", + changedPathCount: 1, + changedPathsSample: ["server/src/routes/health.ts"], + pendingMigrations: [], + lastRestartAt: "2026-03-20T11:30:00.000Z", + }); + process.env.TASKCORE_DEV_SERVER_STATUS_TOKEN = "dev-runner-token"; + + let selectCall = 0; + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + select: vi.fn(() => { + selectCall += 1; + if (selectCall === 1) { + return { + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 1 }]), + })), + }; + } + if (selectCall === 2) { + return { + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([ + { + id: "settings-1", + general: {}, + experimental: { autoRestartDevServerWhenIdle: true }, + createdAt: new Date("2026-03-20T11:00:00.000Z"), + updatedAt: new Date("2026-03-20T11:00:00.000Z"), + }, + ]), + })), + }; + } + return { + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 0 }]), + })), + }; + }), + } as unknown as Db; + + try { + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "none", source: "none" }; + next(); + }); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "private", + authReady: true, + companyDeletionEnabled: true, + }), + ); + + const res = await request(app) + .get("/health") + .set("X-Taskcore-Dev-Server-Status-Token", "dev-runner-token"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + status: "ok", + deploymentMode: "authenticated", + bootstrapStatus: "ready", + bootstrapInviteActive: false, + devServer: { + enabled: true, + restartRequired: true, + reason: "backend_changes", + lastChangedAt: "2026-03-20T12:00:00.000Z", + changedPathCount: 1, + changedPathsSample: ["server/src/routes/health.ts"], + pendingMigrations: [], + autoRestartEnabled: true, + activeRunCount: 0, + waitingForIdle: false, + lastRestartAt: "2026-03-20T11:30:00.000Z", + }, + }); + } finally { + if (previousFile === undefined) { + delete process.env.TASKCORE_DEV_SERVER_STATUS_FILE; + } else { + process.env.TASKCORE_DEV_SERVER_STATUS_FILE = previousFile; + } + if (previousToken === undefined) { + delete process.env.TASKCORE_DEV_SERVER_STATUS_TOKEN; + } else { + process.env.TASKCORE_DEV_SERVER_STATUS_TOKEN = previousToken; + } + } + }); +}); diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index 889df6e..1182e50 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import express from "express"; import request from "supertest"; import type { Db } from "@taskcore/db"; -import { serverVersion } from "../version.js"; import { healthRoutes } from "../routes/health.js"; +import * as devServerStatus from "../dev-server-status.js"; +import { serverVersion } from "../version.js"; const mockReadPersistedDevServerStatus = vi.hoisted(() => vi.fn()); @@ -27,14 +28,12 @@ describe("GET /health", () => { afterEach(() => { vi.restoreAllMocks(); }); - it("returns 200 with status ok", async () => { const app = createApp(); - const res = await request(app).get("/health"); expect(res.status).toBe(200); expect(res.body).toEqual({ status: "ok", version: serverVersion }); - }); + }, 15_000); it("returns 200 when the database probe succeeds", async () => { const db = { @@ -45,6 +44,7 @@ describe("GET /health", () => { const res = await request(app).get("/health"); expect(res.status).toBe(200); + expect(db.execute).toHaveBeenCalledTimes(1); expect(res.body).toMatchObject({ status: "ok", version: serverVersion }); }); @@ -60,7 +60,123 @@ describe("GET /health", () => { expect(res.body).toEqual({ status: "unhealthy", version: serverVersion, - error: "database_unreachable", + error: "database_unreachable" + }); + }); + + it("redacts detailed metadata for anonymous requests in authenticated mode", async () => { + const devServerStatus = await import("../dev-server-status.js"); + vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); + const { healthRoutes } = await import("../routes/health.js"); + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 1 }]), + })), + })), + } as unknown as Db; + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "none", source: "none" }; + next(); + }); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + companyDeletionEnabled: false, + }), + ); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + status: "ok", + deploymentMode: "authenticated", + bootstrapStatus: "ready", + bootstrapInviteActive: false, + }); + }); + + it("redacts detailed metadata when authenticated mode is reached without auth middleware", async () => { + const devServerStatus = await import("../dev-server-status.js"); + vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); + const { healthRoutes } = await import("../routes/health.js"); + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 1 }]), + })), + })), + } as unknown as Db; + const app = express(); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + companyDeletionEnabled: false, + }), + ); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + status: "ok", + deploymentMode: "authenticated", + bootstrapStatus: "ready", + bootstrapInviteActive: false, + }); + }); + + it("keeps detailed metadata for authenticated requests in authenticated mode", async () => { + const devServerStatus = await import("../dev-server-status.js"); + vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); + const { healthRoutes } = await import("../routes/health.js"); + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 1 }]), + })), + })), + } as unknown as Db; + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "board", userId: "user-1", source: "session" }; + next(); + }); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + companyDeletionEnabled: false, + }), + ); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + status: "ok", + version: serverVersion, + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + bootstrapStatus: "ready", + bootstrapInviteActive: false, + features: { + companyDeletionEnabled: false, + }, }); }); }); diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index bd76f5c..1de8926 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -73,8 +73,8 @@ async function startTempDatabase() { port, persistent: true, initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => { }, - onError: () => { }, + onLog: () => {}, + onError: () => {}, }); await instance.initialise(); await instance.start(); @@ -236,6 +236,115 @@ describe("heartbeat comment wake batching", () => { } }); + it("defers approval-approved wakes for a running issue so the assignee resumes after the run", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const heartbeat = heartbeatService(db); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CEO", + role: "ceo", + status: "running", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + triggerDetail: "system", + status: "running", + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: "issue_assigned", + }, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Hire an agent", + status: "blocked", + priority: "medium", + assigneeAgentId: agentId, + executionRunId: runId, + executionAgentNameKey: "ceo", + executionLockedAt: new Date(), + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + + const followupRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "approval_approved", + payload: { + issueId, + approvalId: "approval-1", + approvalStatus: "approved", + }, + contextSnapshot: { + issueId, + taskId: issueId, + approvalId: "approval-1", + approvalStatus: "approved", + wakeReason: "approval_approved", + }, + requestedByActorType: "user", + requestedByActorId: "local-board", + }); + + expect(followupRun).toBeNull(); + + const deferred = await db + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.agentId, agentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + ), + ) + .then((rows) => rows[0] ?? null); + + expect(deferred).not.toBeNull(); + expect(deferred?.reason).toBe("issue_execution_deferred"); + expect(deferred?.payload).toMatchObject({ + issueId, + approvalId: "approval-1", + approvalStatus: "approved", + }); + expect((deferred?.payload as Record)._taskcoreWakeContext).toMatchObject({ + issueId, + taskId: issueId, + approvalId: "approval-1", + approvalStatus: "approved", + wakeReason: "approval_approved", + }); + + const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(1); + expect(runs[0]?.id).toBe(runId); + }); + it("batches deferred comment wakes and forwards the ordered batch to the next run", async () => { const gateway = await createControlledGatewayServer(); const companyId = randomUUID(); @@ -389,16 +498,16 @@ describe("heartbeat comment wake batching", () => { }); const deferredWake = await db - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, companyId), - eq(agentWakeupRequests.agentId, agentId), - eq(agentWakeupRequests.status, "deferred_issue_execution"), - ), - ) - .then((rows) => rows[0] ?? null); + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.agentId, agentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + ), + ) + .then((rows) => rows[0] ?? null); const deferredContext = (deferredWake?.payload as Record | null)?._taskcoreWakeContext as | Record @@ -429,6 +538,144 @@ describe("heartbeat comment wake batching", () => { } }, 120_000); + it("promotes deferred comment wakes with their comments after the active run is cancelled", async () => { + const gateway = await createControlledGatewayServer(); + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const heartbeat = heartbeatService(db); + + try { + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Gateway Agent", + role: "engineer", + status: "idle", + adapterType: "openclaw_gateway", + adapterConfig: { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2_000, + }, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Interrupt queued comment", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + issueNumber: 2, + identifier: `${issuePrefix}-2`, + }); + + const comment1 = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "Start work", + }) + .returning() + .then((rows) => rows[0]); + const firstRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId, commentId: comment1.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: comment1.id, + wakeReason: "issue_commented", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + + expect(firstRun).not.toBeNull(); + await waitFor(() => gateway.getAgentPayloads().length === 1); + + const queuedComment = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "Queued follow-up", + }) + .returning() + .then((rows) => rows[0]); + + const followupRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId, commentId: queuedComment.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: queuedComment.id, + wakeReason: "issue_commented", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + + expect(followupRun).toBeNull(); + + await heartbeat.cancelRun(firstRun!.id); + + await waitFor(() => gateway.getAgentPayloads().length === 2); + const promotedPayload = gateway.getAgentPayloads()[1] ?? {}; + expect(promotedPayload.taskcore).toMatchObject({ + wake: { + commentIds: [queuedComment.id], + latestCommentId: queuedComment.id, + comments: [ + expect.objectContaining({ + id: queuedComment.id, + body: "Queued follow-up", + }), + ], + commentWindow: { + requestedCount: 1, + includedCount: 1, + missingCount: 0, + }, + }, + }); + expect(String(promotedPayload.message ?? "")).toContain("Queued follow-up"); + + gateway.releaseFirstWait(); + await waitFor(async () => { + const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + return runs.length === 2 && runs.every((run) => ["cancelled", "succeeded"].includes(run.status)); + }, 90_000); + } finally { + gateway.releaseFirstWait(); + await gateway.close(); + } + }, 120_000); + it("promotes deferred comment wakes after the active run closes the issue", async () => { const gateway = await createControlledGatewayServer(); const companyId = randomUUID(); diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts new file mode 100644 index 0000000..73325c7 --- /dev/null +++ b/server/src/__tests__/heartbeat-context-summary.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + summarizeHeartbeatRunContextSnapshot, + summarizeHeartbeatRunListResultJson, +} from "../services/heartbeat.js"; + +describe("summarizeHeartbeatRunContextSnapshot", () => { + it("keeps only the small retry/linking fields needed by the client", () => { + const summarized = summarizeHeartbeatRunContextSnapshot({ + issueId: "issue-1", + taskId: "task-1", + taskKey: "PAP-1", + commentId: "comment-1", + wakeCommentId: "comment-2", + wakeReason: "retry_failed_run", + wakeSource: "on_demand", + wakeTriggerDetail: "manual", + taskcoreWake: { + comments: [ + { + body: "x".repeat(50_000), + }, + ], + }, + executionStage: { + summary: "large nested object that should not be sent back in run lists", + }, + }); + + expect(summarized).toEqual({ + issueId: "issue-1", + taskId: "task-1", + taskKey: "PAP-1", + commentId: "comment-1", + wakeCommentId: "comment-2", + wakeReason: "retry_failed_run", + wakeSource: "on_demand", + wakeTriggerDetail: "manual", + }); + }); + + it("returns null when no allowed fields are present", () => { + expect( + summarizeHeartbeatRunContextSnapshot({ + taskcoreWake: { comments: [{ body: "hello" }] }, + }), + ).toBeNull(); + }); +}); + +describe("summarizeHeartbeatRunListResultJson", () => { + it("keeps only summary fields and parses numeric cost aliases", () => { + expect( + summarizeHeartbeatRunListResultJson({ + summary: "Completed the task", + result: "Updated three files", + message: "", + error: null, + totalCostUsd: "1.25", + costUsd: "0.75", + costUsdCamel: "0.5", + }), + ).toEqual({ + summary: "Completed the task", + result: "Updated three files", + total_cost_usd: 1.25, + cost_usd: 0.75, + costUsd: 0.5, + }); + }); + + it("returns null when projected fields are empty", () => { + expect( + summarizeHeartbeatRunListResultJson({ + summary: "", + result: null, + message: undefined, + error: " ", + totalCostUsd: "abc", + }), + ).toBeNull(); + }); +}); diff --git a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts new file mode 100644 index 0000000..428f931 --- /dev/null +++ b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts @@ -0,0 +1,346 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, sql } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + agents, + agentRuntimeState, + agentWakeupRequests, + companySkills, + companies, + createDb, + documentRevisions, + documents, + heartbeatRunEvents, + heartbeatRuns, + issueComments, + issueDocuments, + issueRelations, + issues, +} from "@taskcore/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { heartbeatService } from "../services/heartbeat.ts"; +import { runningProcesses } from "../adapters/index.ts"; + +const mockAdapterExecute = vi.hoisted(() => + vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + errorMessage: null, + summary: "Dependency-aware heartbeat test run.", + provider: "test", + model: "test-model", + })), +); + +vi.mock("../adapters/index.ts", async () => { + const actual = await vi.importActual("../adapters/index.ts"); + return { + ...actual, + getServerAdapter: vi.fn(() => ({ + supportsLocalAgentJwt: false, + execute: mockAdapterExecute, + })), + }; +}); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres heartbeat dependency scheduling tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +async function ensureIssueRelationsTable(db: ReturnType) { + await db.execute(sql.raw(` + CREATE TABLE IF NOT EXISTS "issue_relations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "company_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "related_issue_id" uuid NOT NULL, + "type" text NOT NULL, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now() + ); + `)); +} + +async function waitForCondition(fn: () => Promise, timeoutMs = 3_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await fn()) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return fn(); +} + +describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () => { + let db!: ReturnType; + let heartbeat!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-heartbeat-dependency-scheduling-"); + db = createDb(tempDb.connectionString); + heartbeat = heartbeatService(db); + await ensureIssueRelationsTable(db); + }, 20_000); + + afterEach(async () => { + vi.clearAllMocks(); + runningProcesses.clear(); + let idlePolls = 0; + for (let attempt = 0; attempt < 100; attempt += 1) { + const runs = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns); + const hasActiveRun = runs.some((run) => run.status === "queued" || run.status === "running"); + if (!hasActiveRun) { + idlePolls += 1; + if (idlePolls >= 3) break; + } else { + idlePolls = 0; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + await db.delete(activityLog); + await db.delete(companySkills); + await db.delete(issueComments); + await db.delete(issueDocuments); + await db.delete(documentRevisions); + await db.delete(documents); + await db.delete(issueRelations); + await db.delete(issues); + await db.delete(heartbeatRunEvents); + await db.delete(heartbeatRuns); + await db.delete(agentWakeupRequests); + await db.delete(agentRuntimeState); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("keeps blocked descendants idle until their blockers resolve", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const blockerId = randomUUID(); + const blockedIssueId = randomUUID(); + const readyIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + }); + await db.insert(issues).values([ + { + id: blockerId, + companyId, + title: "Mission 0", + status: "todo", + priority: "high", + }, + { + id: blockedIssueId, + companyId, + title: "Mission 2", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + }, + { + id: readyIssueId, + companyId, + title: "Mission 1", + status: "todo", + priority: "critical", + assigneeAgentId: agentId, + }, + ]); + await db.insert(issueRelations).values({ + companyId, + issueId: blockerId, + relatedIssueId: blockedIssueId, + type: "blocks", + }); + + const blockedWake = await heartbeat.wakeup(agentId, { + source: "assignment", + triggerDetail: "system", + reason: "issue_assigned", + payload: { issueId: blockedIssueId }, + contextSnapshot: { issueId: blockedIssueId, wakeReason: "issue_assigned" }, + }); + expect(blockedWake).toBeNull(); + + const blockedWakeRequest = await waitForCondition(async () => { + const wakeup = await db + .select({ + status: agentWakeupRequests.status, + reason: agentWakeupRequests.reason, + }) + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.agentId, agentId), + sql`${agentWakeupRequests.payload} ->> 'issueId' = ${blockedIssueId}`, + ), + ) + .orderBy(agentWakeupRequests.requestedAt) + .then((rows) => rows[0] ?? null); + return Boolean( + wakeup && + wakeup.status === "skipped" && + wakeup.reason === "issue_dependencies_blocked", + ); + }); + expect(blockedWakeRequest).toBe(true); + + const blockedRunsBeforeResolution = await db + .select({ count: sql`count(*)::int` }) + .from(heartbeatRuns) + .where(sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${blockedIssueId}`) + .then((rows) => rows[0]?.count ?? 0); + expect(blockedRunsBeforeResolution).toBe(0); + + const interactionWake = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId: blockedIssueId, commentId: randomUUID() }, + contextSnapshot: { + issueId: blockedIssueId, + wakeReason: "issue_commented", + }, + }); + expect(interactionWake).not.toBeNull(); + + await waitForCondition(async () => { + const run = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, interactionWake!.id)) + .then((rows) => rows[0] ?? null); + return run?.status === "succeeded"; + }); + + const interactionRun = await db + .select({ + status: heartbeatRuns.status, + contextSnapshot: heartbeatRuns.contextSnapshot, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, interactionWake!.id)) + .then((rows) => rows[0] ?? null); + + expect(interactionRun?.status).toBe("succeeded"); + expect(interactionRun?.contextSnapshot).toMatchObject({ + dependencyBlockedInteraction: true, + unresolvedBlockerIssueIds: [blockerId], + }); + + const readyWake = await heartbeat.wakeup(agentId, { + source: "assignment", + triggerDetail: "system", + reason: "issue_assigned", + payload: { issueId: readyIssueId }, + contextSnapshot: { issueId: readyIssueId, wakeReason: "issue_assigned" }, + }); + expect(readyWake).not.toBeNull(); + + await waitForCondition(async () => { + const run = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, readyWake!.id)) + .then((rows) => rows[0] ?? null); + return run?.status === "succeeded"; + }); + + const readyRun = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, readyWake!.id)) + .then((rows) => rows[0] ?? null); + + expect(readyRun?.status).toBe("succeeded"); + + await db + .update(issues) + .set({ status: "done", updatedAt: new Date() }) + .where(eq(issues.id, blockerId)); + + const promotedWake = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_blockers_resolved", + payload: { issueId: blockedIssueId, resolvedBlockerIssueId: blockerId }, + contextSnapshot: { + issueId: blockedIssueId, + wakeReason: "issue_blockers_resolved", + resolvedBlockerIssueId: blockerId, + }, + }); + expect(promotedWake).not.toBeNull(); + + await waitForCondition(async () => { + const run = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, promotedWake!.id)) + .then((rows) => rows[0] ?? null); + return run?.status === "succeeded"; + }); + + const promotedBlockedRun = await db + .select({ + id: heartbeatRuns.id, + status: heartbeatRuns.status, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, promotedWake!.id)) + .then((rows) => rows[0] ?? null); + const blockedWakeRequestCount = await db + .select({ count: sql`count(*)::int` }) + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.agentId, agentId), + sql`${agentWakeupRequests.payload} ->> 'issueId' = ${blockedIssueId}`, + ), + ) + .then((rows) => rows[0]?.count ?? 0); + + expect(promotedBlockedRun?.status).toBe("succeeded"); + expect(blockedWakeRequestCount).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts new file mode 100644 index 0000000..aaa872d --- /dev/null +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -0,0 +1,280 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, sql } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + agents, + agentWakeupRequests, + companies, + createDb, + heartbeatRuns, + issueComments, + issueRelations, + issues, +} from "@taskcore/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const mockAdapterExecute = vi.hoisted(() => + vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + errorMessage: null, + summary: "Acknowledged liveness escalation.", + provider: "test", + model: "test-model", + })), +); + +vi.mock("../telemetry.ts", () => ({ + getTelemetryClient: () => ({ track: vi.fn() }), +})); + +vi.mock("@taskcore/shared/telemetry", async () => { + const actual = await vi.importActual( + "@taskcore/shared/telemetry", + ); + return { + ...actual, + trackAgentFirstHeartbeat: vi.fn(), + }; +}); + +vi.mock("../adapters/index.ts", async () => { + const actual = await vi.importActual("../adapters/index.ts"); + return { + ...actual, + getServerAdapter: vi.fn(() => ({ + supportsLocalAgentJwt: false, + execute: mockAdapterExecute, + })), + }; +}); + +import { heartbeatService } from "../services/heartbeat.ts"; +import { runningProcesses } from "../adapters/index.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres issue liveness escalation tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { + let tempDb: Awaited> | null = null; + let db: ReturnType; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-heartbeat-issue-liveness-"); + db = createDb(tempDb.connectionString); + }, 30_000); + + afterEach(async () => { + vi.clearAllMocks(); + runningProcesses.clear(); + let idlePolls = 0; + for (let attempt = 0; attempt < 100; attempt += 1) { + const runs = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns); + const hasActiveRun = runs.some((run) => run.status === "queued" || run.status === "running"); + if (!hasActiveRun) { + idlePolls += 1; + if (idlePolls >= 3) break; + } else { + idlePolls = 0; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + await db.execute(sql.raw(`TRUNCATE TABLE "companies" CASCADE`)); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedBlockedChain() { + const companyId = randomUUID(); + const managerId = randomUUID(); + const coderId = randomUUID(); + const blockedIssueId = randomUUID(); + const blockerIssueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values([ + { + id: managerId, + companyId, + name: "CTO", + role: "cto", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: coderId, + companyId, + name: "Coder", + role: "engineer", + status: "idle", + reportsTo: managerId, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + await db.insert(issues).values([ + { + id: blockedIssueId, + companyId, + title: "Blocked parent", + status: "blocked", + priority: "medium", + assigneeAgentId: coderId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }, + { + id: blockerIssueId, + companyId, + title: "Missing unblock owner", + status: "todo", + priority: "medium", + issueNumber: 2, + identifier: `${issuePrefix}-2`, + }, + ]); + + await db.insert(issueRelations).values({ + companyId, + issueId: blockerIssueId, + relatedIssueId: blockedIssueId, + type: "blocks", + }); + + return { companyId, managerId, blockedIssueId, blockerIssueId }; + } + + it("creates one manager escalation, preserves blockers, and wakes the assignee", async () => { + const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); + const heartbeat = heartbeatService(db); + + const first = await heartbeat.reconcileIssueGraphLiveness(); + const second = await heartbeat.reconcileIssueGraphLiveness(); + + expect(first.escalationsCreated).toBe(1); + expect(second.escalationsCreated).toBe(0); + expect(second.existingEscalations).toBe(1); + + const escalations = await db + .select() + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + eq(issues.originKind, "harness_liveness_escalation"), + ), + ); + expect(escalations).toHaveLength(1); + expect(escalations[0]).toMatchObject({ + parentId: blockedIssueId, + assigneeAgentId: managerId, + status: expect.stringMatching(/^(todo|in_progress|done)$/), + }); + + const blockers = await db + .select({ blockerIssueId: issueRelations.issueId }) + .from(issueRelations) + .where(eq(issueRelations.relatedIssueId, blockedIssueId)); + expect(blockers.map((row) => row.blockerIssueId).sort()).toEqual( + [blockerIssueId, escalations[0]!.id].sort(), + ); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, blockedIssueId)); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain("harness-level liveness incident"); + expect(comments[0]?.body).toContain(escalations[0]?.identifier ?? escalations[0]!.id); + + const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, managerId)); + expect(wakes.some((wake) => wake.reason === "issue_assigned")).toBe(true); + + const events = await db.select().from(activityLog).where(eq(activityLog.companyId, companyId)); + expect(events.some((event) => event.action === "issue.harness_liveness_escalation_created")).toBe(true); + expect(events.some((event) => event.action === "issue.blockers.updated")).toBe(true); + }); + + it("creates a fresh escalation when the previous matching escalation is terminal", async () => { + const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); + const heartbeat = heartbeatService(db); + const incidentKey = [ + "harness_liveness", + companyId, + blockedIssueId, + "blocked_by_unassigned_issue", + blockerIssueId, + ].join(":"); + const closedEscalationId = randomUUID(); + + await db.insert(issues).values({ + id: closedEscalationId, + companyId, + title: "Closed escalation", + status: "done", + priority: "high", + parentId: blockedIssueId, + assigneeAgentId: managerId, + issueNumber: 3, + identifier: "CLOSED-3", + originKind: "harness_liveness_escalation", + originId: incidentKey, + }); + + const result = await heartbeat.reconcileIssueGraphLiveness(); + + expect(result.escalationsCreated).toBe(1); + expect(result.existingEscalations).toBe(0); + + const openEscalations = await db + .select() + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + eq(issues.originKind, "harness_liveness_escalation"), + eq(issues.originId, incidentKey), + ), + ); + expect(openEscalations).toHaveLength(2); + const freshEscalation = openEscalations.find((issue) => issue.status !== "done"); + expect(freshEscalation).toMatchObject({ + parentId: blockedIssueId, + assigneeAgentId: managerId, + status: expect.stringMatching(/^(todo|in_progress|done)$/), + }); + + const blockers = await db + .select({ blockerIssueId: issueRelations.issueId }) + .from(issueRelations) + .where(eq(issueRelations.relatedIssueId, blockedIssueId)); + expect(blockers.some((row) => row.blockerIssueId === closedEscalationId)).toBe(false); + expect(blockers.some((row) => row.blockerIssueId === freshEscalation?.id)).toBe(true); + }); +}); diff --git a/server/src/__tests__/heartbeat-list.test.ts b/server/src/__tests__/heartbeat-list.test.ts index 257c6d5..d287fff 100644 --- a/server/src/__tests__/heartbeat-list.test.ts +++ b/server/src/__tests__/heartbeat-list.test.ts @@ -5,7 +5,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { heartbeatService } from "../services/heartbeat.ts"; +import { boundHeartbeatRunEventPayloadForStorage, heartbeatService } from "../services/heartbeat.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -65,6 +65,11 @@ describeEmbeddedPostgres("heartbeat list", () => { agentId, invocationSource: "assignment", status: "running", + livenessState: "advanced", + livenessReason: "run produced action evidence", + continuationAttempt: 1, + lastUsefulActionAt: new Date("2026-04-18T12:00:00Z"), + nextAction: "continue implementation", contextSnapshot: { issueId: randomUUID() }, }); @@ -80,6 +85,13 @@ describeEmbeddedPostgres("heartbeat list", () => { expect(runs).toHaveLength(1); expect(runs[0]?.id).toBe(runId); expect(runs[0]?.processGroupId ?? null).toBeNull(); + expect(runs[0]).toMatchObject({ + livenessState: "advanced", + livenessReason: "run produced action evidence", + continuationAttempt: 1, + nextAction: "continue implementation", + }); + expect(runs[0]?.lastUsefulActionAt).toEqual(new Date("2026-04-18T12:00:00Z")); } finally { if (originalDescriptor) { Object.defineProperty(heartbeatRuns, "processGroupId", originalDescriptor); @@ -88,4 +100,127 @@ describeEmbeddedPostgres("heartbeat list", () => { } } }); + + it("returns small result json payloads unchanged from getRun", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "running", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + resultJson: { + summary: "done", + structured: { ok: true }, + }, + }); + + const run = await heartbeatService(db).getRun(runId); + + expect(run?.resultJson).toEqual({ + summary: "done", + structured: { ok: true }, + }); + }); + + it("bounds oversized legacy result json payloads on getRun", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const oversizedStdout = Array.from({ length: 8_000 }, (_, index) => + `${index.toString(16).padStart(4, "0")}-${randomUUID()}`, + ).join("|"); + const oversizedNestedPayload = Array.from({ length: 6_000 }, (_, index) => + `${index.toString(16).padStart(4, "0")}:${randomUUID()}`, + ).join("|"); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "running", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + resultJson: { + summary: "completed", + stdout: oversizedStdout, + nestedHuge: { payload: oversizedNestedPayload }, + }, + }); + + const run = await heartbeatService(db).getRun(runId); + const result = run?.resultJson as Record | null; + + expect(result).toMatchObject({ + summary: "completed", + truncated: true, + truncationReason: "oversized_result_json", + stdoutTruncated: true, + }); + expect(typeof result?.stdout).toBe("string"); + expect((result?.stdout as string).length).toBeLessThan(oversizedStdout.length); + expect(result).not.toHaveProperty("nestedHuge"); + }); +}); + +describe("heartbeat run event payload bounding", () => { + it("truncates oversized adapter metadata before storage", () => { + const payload = boundHeartbeatRunEventPayloadForStorage({ + adapterType: "codex_local", + prompt: "x".repeat(40_000), + context: { + issueId: "issue-1", + memory: "y".repeat(40_000), + }, + }); + + expect(payload.adapterType).toBe("codex_local"); + expect(typeof payload.prompt).toBe("string"); + expect((payload.prompt as string).length).toBeLessThan(20_000); + expect(payload.prompt).toContain("[truncated"); + expect(payload.context).toMatchObject({ + issueId: "issue-1", + }); + expect(JSON.stringify(payload).length).toBeLessThan(45_000); + }); }); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index d47bb28..379277c 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { spawn, type ChildProcess } from "node:child_process"; -import { eq } from "drizzle-orm"; +import { eq, or, inArray } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { activityLog, @@ -10,9 +10,13 @@ import { companySkills, companies, createDb, + documentRevisions, + documents, heartbeatRunEvents, heartbeatRuns, issueComments, + issueDocuments, + issueRelations, issues, } from "@taskcore/db"; import { @@ -22,6 +26,17 @@ import { import { runningProcesses } from "../adapters/index.ts"; const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() })); const mockTrackAgentFirstHeartbeat = vi.hoisted(() => vi.fn()); +const mockAdapterExecute = vi.hoisted(() => + vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + errorMessage: null, + summary: "Recovered stranded heartbeat work.", + provider: "test", + model: "test-model", + })), +); vi.mock("../telemetry.ts", () => ({ getTelemetryClient: () => mockTelemetryClient, @@ -43,14 +58,7 @@ vi.mock("../adapters/index.ts", async () => { ...actual, getServerAdapter: vi.fn(() => ({ supportsLocalAgentJwt: false, - execute: vi.fn(async () => ({ - exitCode: 0, - signal: null, - timedOut: false, - errorMessage: null, - provider: "test", - model: "test-model", - })), + execute: mockAdapterExecute, })), }; }); @@ -104,6 +112,93 @@ async function waitForRunToSettle( return heartbeat.getRun(runId); } +async function waitForValue( + read: () => Promise, + timeoutMs = 3_000, +) { + const deadline = Date.now() + timeoutMs; + let latest: T | null | undefined = null; + while (Date.now() < deadline) { + latest = await read(); + if (latest) return latest; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return latest ?? null; +} + +async function waitForHeartbeatIdle( + db: ReturnType, + timeoutMs = 3_000, +) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const runs = await db + .select({ + status: heartbeatRuns.status, + }) + .from(heartbeatRuns); + if (!runs.some((run) => run.status === "queued" || run.status === "running")) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +async function cancelActiveRunsForCleanup( + db: ReturnType, + timeoutMs = 3_000, +) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const activeRuns = await db + .select({ + id: heartbeatRuns.id, + wakeupRequestId: heartbeatRuns.wakeupRequestId, + }) + .from(heartbeatRuns) + .where( + or( + eq(heartbeatRuns.status, "queued"), + eq(heartbeatRuns.status, "running"), + ), + ); + + if (activeRuns.length === 0) return; + + const now = new Date(); + const runIds = activeRuns.map((run) => run.id); + const wakeupRequestIds = activeRuns + .map((run) => run.wakeupRequestId) + .filter((value): value is string => typeof value === "string" && value.length > 0); + + await db + .update(heartbeatRuns) + .set({ + status: "cancelled", + finishedAt: now, + updatedAt: now, + errorCode: "test_cleanup", + error: "Cancelled by heartbeat-process-recovery test cleanup", + processPid: null, + processGroupId: null, + }) + .where(inArray(heartbeatRuns.id, runIds)); + + if (wakeupRequestIds.length > 0) { + await db + .update(agentWakeupRequests) + .set({ + status: "cancelled", + finishedAt: now, + error: "Cancelled by heartbeat-process-recovery test cleanup", + }) + .where(inArray(agentWakeupRequests.id, wakeupRequestIds)); + } + + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + async function spawnOrphanedProcessGroup() { const leader = spawn( process.execPath, @@ -157,6 +252,15 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { afterEach(async () => { vi.clearAllMocks(); + mockAdapterExecute.mockImplementation(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + errorMessage: null, + summary: "Recovered stranded heartbeat work.", + provider: "test", + model: "test-model", + })); runningProcesses.clear(); for (const child of childProcesses) { child.kill("SIGKILL"); @@ -170,19 +274,52 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { } } cleanupPids.clear(); - for (let attempt = 0; attempt < 10; attempt += 1) { - const runs = await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns); - if (runs.every((run) => run.status !== "queued" && run.status !== "running")) { - break; + await cancelActiveRunsForCleanup(db, 5_000); + let idlePolls = 0; + for (let attempt = 0; attempt < 100; attempt += 1) { + const runs = await db + .select({ + status: heartbeatRuns.status, + processPid: heartbeatRuns.processPid, + processGroupId: heartbeatRuns.processGroupId, + }) + .from(heartbeatRuns); + const managedExecutionStillActive = runs.some( + (run) => + (run.status === "queued" || run.status === "running") && + !run.processPid && + !run.processGroupId, + ); + if (!managedExecutionStillActive) { + idlePolls += 1; + if (idlePolls >= 3) break; + } else { + idlePolls = 0; } await new Promise((resolve) => setTimeout(resolve, 50)); } await new Promise((resolve) => setTimeout(resolve, 50)); + await waitForHeartbeatIdle(db, 5_000); + await new Promise((resolve) => setTimeout(resolve, 100)); await db.delete(activityLog); await db.delete(agentRuntimeState); await db.delete(companySkills); await db.delete(issueComments); - await db.delete(issues); + await db.delete(issueDocuments); + await db.delete(documentRevisions); + await db.delete(documents); + await db.delete(issueRelations); + for (let attempt = 0; attempt < 5; attempt += 1) { + await db.delete(issueComments); + await db.delete(issueDocuments); + try { + await db.delete(issues); + break; + } catch (error) { + if (attempt === 4) throw error; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); @@ -392,6 +529,87 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { return { companyId, agentId, runId, wakeupRequestId, issueId }; } + async function seedQueuedIssueRunFixture() { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const wakeupRequestId = randomUUID(); + const issueId = randomUUID(); + const now = new Date("2026-03-19T00:00:00.000Z"); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + }); + + await db.insert(agentWakeupRequests).values({ + id: wakeupRequestId, + companyId, + agentId, + source: "assignment", + triggerDetail: "system", + reason: "issue_assigned", + payload: { issueId }, + status: "queued", + runId, + requestedAt: now, + updatedAt: now, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + triggerDetail: "system", + status: "queued", + wakeupRequestId, + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: "issue_assigned", + }, + updatedAt: now, + createdAt: now, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Retry transient Codex failure without blocking", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + checkoutRunId: runId, + executionRunId: runId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + startedAt: now, + }); + + return { companyId, agentId, runId, wakeupRequestId, issueId }; + } + it("keeps a local run active when the recorded pid is still alive", async () => { const child = spawnAliveProcess(); childProcesses.add(child); @@ -439,6 +657,13 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const retryRun = runs.find((row) => row.id !== runId); expect(failedRun?.status).toBe("failed"); expect(failedRun?.errorCode).toBe("process_lost"); + expect(failedRun?.livenessState).toBe("failed"); + expect(failedRun?.livenessReason).toContain("process_lost"); + expect(failedRun?.resultJson).toMatchObject({ + stopReason: "process_lost", + timeoutConfigured: false, + timeoutFired: false, + }); expect(retryRun?.status).toBe("queued"); expect(retryRun?.retryOfRunId).toBe(runId); expect(retryRun?.processLossRetryCount).toBe(1); @@ -491,8 +716,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(issue?.executionRunId).toBe(retryRun?.id ?? null); }); - it("does not queue a second retry after the first process-loss retry was already used", async () => { + it("blocks the issue when process-loss retry is exhausted and the immediate continuation recovery also fails", async () => { + mockAdapterExecute.mockRejectedValueOnce(new Error("continuation recovery failed")); + const { agentId, runId, issueId } = await seedRunFixture({ + agentStatus: "idle", processPid: 999_999_999, processLossRetryCount: 1, }); @@ -506,16 +734,74 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { .select() .from(heartbeatRuns) .where(eq(heartbeatRuns.agentId, agentId)); - expect(runs).toHaveLength(1); - expect(runs[0]?.status).toBe("failed"); + expect(runs).toHaveLength(2); + expect(runs.find((row) => row.id === runId)?.status).toBe("failed"); + const continuationRun = runs.find((row) => row.id !== runId); + expect(continuationRun?.contextSnapshot as Record | undefined).toMatchObject({ + retryReason: "issue_continuation_needed", + retryOfRunId: runId, + }); + + const blockedIssue = await waitForValue(async () => + db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => { + const issue = rows[0] ?? null; + return issue?.status === "blocked" ? issue : null; + }) + ); + expect(blockedIssue?.status).toBe("blocked"); + expect(blockedIssue?.executionRunId).toBeNull(); + expect(blockedIssue?.checkoutRunId).toBe(continuationRun?.id ?? null); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain("retried continuation"); + }); + + it("schedules a bounded retry for codex transient upstream failures instead of blocking the issue immediately", async () => { + mockAdapterExecute.mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + errorCode: "codex_transient_upstream", + errorMessage: + "Error running remote compact task: We're currently experiencing high demand, which may cause temporary errors.", + provider: "openai", + model: "gpt-5.4", + }); + + const { agentId, runId, issueId } = await seedQueuedIssueRunFixture(); + const heartbeat = heartbeatService(db); + + await heartbeat.resumeQueuedRuns(); + await waitForRunToSettle(heartbeat, runId); + + const runs = await waitForValue(async () => { + const rows = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + return rows.length >= 2 ? rows : null; + }); + expect(runs).toHaveLength(2); + + const failedRun = runs?.find((row) => row.id === runId); + const retryRun = runs?.find((row) => row.id !== runId); + expect(failedRun?.status).toBe("failed"); + expect(failedRun?.errorCode).toBe("codex_transient_upstream"); + expect(retryRun?.status).toBe("scheduled_retry"); + expect(retryRun?.scheduledRetryReason).toBe("transient_failure"); + expect((retryRun?.contextSnapshot as Record | null)?.codexTransientFallbackMode).toBe("same_session"); const issue = await db .select() .from(issues) .where(eq(issues.id, issueId)) .then((rows) => rows[0] ?? null); - expect(issue?.executionRunId).toBeNull(); - expect(issue?.checkoutRunId).toBe(runId); + expect(issue?.status).toBe("in_progress"); + expect(issue?.executionRunId).toBe(retryRun?.id ?? null); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + expect(comments).toHaveLength(0); }); it("clears the detached warning when the run reports activity again", async () => { @@ -536,7 +822,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }); it("tracks the first heartbeat with the agent role instead of adapter type", async () => { - const { runId } = await seedRunFixture({ + const { agentId, runId } = await seedRunFixture({ agentStatus: "running", includeIssue: false, }); @@ -548,10 +834,28 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { mockTelemetryClient, expect.objectContaining({ agentRole: "engineer", + agentId, }), ); }); + it("records manual cancellation stop metadata", async () => { + const { runId } = await seedRunFixture({ + agentStatus: "running", + includeIssue: false, + }); + const heartbeat = heartbeatService(db); + + const cancelled = await heartbeat.cancelRun(runId); + expect(cancelled?.status).toBe("cancelled"); + expect(cancelled?.resultJson).toMatchObject({ + stopReason: "cancelled", + effectiveTimeoutSec: 0, + timeoutConfigured: false, + timeoutFired: false, + }); + }); + it("re-enqueues assigned todo work when the last issue run died and no wake remains", async () => { const { agentId, issueId, runId } = await seedStrandedIssueFixture({ status: "todo", @@ -598,6 +902,108 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("retried dispatch"); + expect(comments[0]?.body).toContain("Latest retry failure: `process_lost` - run failed before issue advanced."); + }); + + it("assigns open unassigned blockers back to their creator agent", async () => { + const companyId = randomUUID(); + const creatorAgentId = randomUUID(); + const blockedAssigneeAgentId = randomUUID(); + const blockerIssueId = randomUUID(); + const blockedIssueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values([ + { + id: creatorAgentId, + companyId, + name: "SecurityEngineer", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: blockedAssigneeAgentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + await db.insert(issues).values([ + { + id: blockerIssueId, + companyId, + title: "Fix blocker", + status: "todo", + priority: "high", + createdByAgentId: creatorAgentId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }, + { + id: blockedIssueId, + companyId, + title: "Blocked work", + status: "blocked", + priority: "high", + assigneeAgentId: blockedAssigneeAgentId, + issueNumber: 2, + identifier: `${issuePrefix}-2`, + }, + ]); + await db.insert(issueRelations).values({ + companyId, + issueId: blockerIssueId, + relatedIssueId: blockedIssueId, + type: "blocks", + createdByAgentId: creatorAgentId, + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + + expect(result.orphanBlockersAssigned).toBe(1); + expect(result.issueIds).toContain(blockerIssueId); + + const blocker = await db + .select() + .from(issues) + .where(eq(issues.id, blockerIssueId)) + .then((rows) => rows[0] ?? null); + expect(blocker?.assigneeAgentId).toBe(creatorAgentId); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, blockerIssueId)); + expect(comments[0]?.body).toContain("Assigned Orphan Blocker"); + expect(comments[0]?.body).toContain(`[${issuePrefix}-2](/${issuePrefix}/issues/${issuePrefix}-2)`); + + const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, creatorAgentId)); + expect(wakeups).toEqual([ + expect.objectContaining({ + reason: "issue_assigned", + payload: expect.objectContaining({ + issueId: blockerIssueId, + mutation: "unassigned_blocker_recovery", + }), + }), + ]); + + const runId = wakeups[0]?.runId; + if (runId) { + await waitForRunToSettle(heartbeat, runId); + } }); it("re-enqueues continuation for stranded in-progress work with no active run", async () => { @@ -627,6 +1033,161 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { } }); + it("does not continue seeded in-progress work that has no run linkage", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Seeded in-flight work", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + checkoutRunId: null, + executionRunId: null, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + startedAt: new Date("2026-03-19T00:00:00.000Z"), + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.dispatchRequeued).toBe(0); + expect(result.continuationRequeued).toBe(0); + expect(result.escalated).toBe(0); + expect(result.skipped).toBe(1); + + const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(0); + const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)); + expect(issue?.status).toBe("in_progress"); + expect(issue?.executionRunId).toBeNull(); + }); + + it("classifies actionable plan-only recovery and enqueues one liveness continuation", async () => { + mockAdapterExecute.mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + errorMessage: null, + summary: "I will inspect the repo next and then implement the fix.", + provider: "test", + model: "test-model", + }); + const { agentId, issueId, runId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "failed", + }); + const heartbeat = heartbeatService(db); + + await heartbeat.reconcileStrandedAssignedIssues(); + + const livenessWake = await waitForValue(async () => { + const rows = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId)); + return rows.find((row) => row.reason === "run_liveness_continuation") ?? null; + }); + expect(livenessWake).toBeTruthy(); + expect(livenessWake?.payload).toMatchObject({ + issueId, + livenessState: "plan_only", + continuationAttempt: 1, + }); + + const sourceRunId = (livenessWake?.payload as Record | null)?.sourceRunId; + expect(sourceRunId).toBeTruthy(); + const sourceRun = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, String(sourceRunId))) + .then((rows) => rows[0] ?? null); + if (sourceRun?.id) { + await waitForRunToSettle(heartbeat, sourceRun.id, 5_000); + } + expect(sourceRun?.id).not.toBe(runId); + expect(sourceRun?.livenessState).toBe("plan_only"); + }); + + it("treats a plan document update as progress and does not enqueue liveness continuation", async () => { + const { agentId, companyId, issueId, runId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "failed", + }); + mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => { + const documentId = randomUUID(); + const revisionId = randomUUID(); + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Plan", + format: "markdown", + latestBody: "# Plan\n\n- Inspect files\n- Implement fix", + latestRevisionId: revisionId, + latestRevisionNumber: 1, + createdByAgentId: agentId, + updatedByAgentId: agentId, + }); + await db.insert(documentRevisions).values({ + id: revisionId, + companyId, + documentId, + revisionNumber: 1, + title: "Plan", + format: "markdown", + body: "# Plan\n\n- Inspect files\n- Implement fix", + createdByAgentId: agentId, + createdByRunId: ctx.runId, + }); + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId, + key: "plan", + }); + return { + exitCode: 0, + signal: null, + timedOut: false, + errorMessage: null, + summary: "Plan:\n- Inspect files\n- Implement fix", + provider: "test", + model: "test-model", + }; + }); + const heartbeat = heartbeatService(db); + + await heartbeat.reconcileStrandedAssignedIssues(); + + const retryRun = await waitForValue(async () => { + const rows = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + return rows.find((row) => row.id !== runId && row.livenessState === "advanced") ?? null; + }, 5_000); + if (retryRun?.id) { + await waitForRunToSettle(heartbeat, retryRun.id, 5_000); + } + expect(retryRun?.livenessState).toBe("advanced"); + + const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId)); + expect(wakes.some((row) => row.reason === "run_liveness_continuation")).toBe(false); + }); it("blocks stranded in-progress work after the continuation retry was already used", async () => { const { issueId } = await seedStrandedIssueFixture({ status: "in_progress", @@ -646,6 +1207,40 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("retried continuation"); + expect(comments[0]?.body).toContain("Latest retry failure: `process_lost` - run failed before issue advanced."); + }); + + it("re-enqueues continuation when the latest automatic continuation succeeded without closing the issue", async () => { + const { agentId, issueId, runId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "succeeded", + retryReason: "issue_continuation_needed", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.continuationRequeued).toBe(1); + expect(result.escalated).toBe(0); + expect(result.issueIds).toEqual([issueId]); + + const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); + expect(issue?.status).toBe("in_progress"); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + expect(comments).toHaveLength(0); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(2); + + const retryRun = runs.find((row) => row.id !== runId); + expect(retryRun?.id).toBeTruthy(); + expect((retryRun?.contextSnapshot as Record)?.retryReason).toBe("issue_continuation_needed"); + if (retryRun) { + await waitForRunToSettle(heartbeat, retryRun.id); + } }); it("does not reconcile user-assigned work through the agent stranded-work recovery path", async () => { diff --git a/server/src/__tests__/heartbeat-retry-scheduling.test.ts b/server/src/__tests__/heartbeat-retry-scheduling.test.ts new file mode 100644 index 0000000..99dd16e --- /dev/null +++ b/server/src/__tests__/heartbeat-retry-scheduling.test.ts @@ -0,0 +1,338 @@ +import { randomUUID } from "node:crypto"; +import { eq, sql } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + agentWakeupRequests, + companies, + createDb, + heartbeatRunEvents, + heartbeatRuns, +} from "@taskcore/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { + BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS, + heartbeatService, +} from "../services/heartbeat.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres heartbeat retry scheduling tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => { + let db!: ReturnType; + let heartbeat!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-heartbeat-retry-scheduling-"); + db = createDb(tempDb.connectionString); + heartbeat = heartbeatService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(heartbeatRunEvents); + await db.delete(heartbeatRuns); + await db.delete(agentWakeupRequests); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedRetryFixture(input: { + runId: string; + companyId: string; + agentId: string; + now: Date; + errorCode: string; + scheduledRetryAttempt?: number; + }) { + await db.insert(companies).values({ + id: input.companyId, + name: "Taskcore", + issuePrefix: `T${input.companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: input.agentId, + companyId: input.companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: input.runId, + companyId: input.companyId, + agentId: input.agentId, + invocationSource: "assignment", + status: "failed", + error: "upstream overload", + errorCode: input.errorCode, + finishedAt: input.now, + scheduledRetryAttempt: input.scheduledRetryAttempt ?? 0, + scheduledRetryReason: input.scheduledRetryAttempt ? "transient_failure" : null, + contextSnapshot: { + issueId: randomUUID(), + wakeReason: "issue_assigned", + }, + updatedAt: input.now, + createdAt: input.now, + }); + } + + it("schedules a retry with durable metadata and only promotes it when due", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const sourceRunId = randomUUID(); + const now = new Date("2026-04-20T12:00:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: sourceRunId, + companyId, + agentId, + invocationSource: "assignment", + status: "failed", + error: "upstream overload", + errorCode: "adapter_failed", + finishedAt: now, + contextSnapshot: { + issueId: randomUUID(), + wakeReason: "issue_assigned", + }, + updatedAt: now, + createdAt: now, + }); + + const scheduled = await heartbeat.scheduleBoundedRetry(sourceRunId, { + now, + random: () => 0.5, + }); + + expect(scheduled.outcome).toBe("scheduled"); + if (scheduled.outcome !== "scheduled") return; + + const expectedDueAt = new Date(now.getTime() + BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS[0]); + expect(scheduled.attempt).toBe(1); + expect(scheduled.dueAt.toISOString()).toBe(expectedDueAt.toISOString()); + + const retryRun = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, scheduled.run.id)) + .then((rows) => rows[0] ?? null); + + expect(retryRun).toMatchObject({ + status: "scheduled_retry", + retryOfRunId: sourceRunId, + scheduledRetryAttempt: 1, + scheduledRetryReason: "transient_failure", + }); + expect(retryRun?.scheduledRetryAt?.toISOString()).toBe(expectedDueAt.toISOString()); + + const earlyPromotion = await heartbeat.promoteDueScheduledRetries(new Date("2026-04-20T12:01:59.000Z")); + expect(earlyPromotion).toEqual({ promoted: 0, runIds: [] }); + + const stillScheduled = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, scheduled.run.id)) + .then((rows) => rows[0] ?? null); + expect(stillScheduled?.status).toBe("scheduled_retry"); + + const duePromotion = await heartbeat.promoteDueScheduledRetries(expectedDueAt); + expect(duePromotion).toEqual({ promoted: 1, runIds: [scheduled.run.id] }); + + const promotedRun = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, scheduled.run.id)) + .then((rows) => rows[0] ?? null); + expect(promotedRun?.status).toBe("queued"); + }); + + it("exhausts bounded retries after the hard cap", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const cappedRunId = randomUUID(); + const now = new Date("2026-04-20T18:00:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: cappedRunId, + companyId, + agentId, + invocationSource: "automation", + status: "failed", + error: "still transient", + errorCode: "adapter_failed", + finishedAt: now, + scheduledRetryAttempt: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length, + scheduledRetryReason: "transient_failure", + contextSnapshot: { + wakeReason: "transient_failure_retry", + }, + updatedAt: now, + createdAt: now, + }); + + const exhausted = await heartbeat.scheduleBoundedRetry(cappedRunId, { + now, + random: () => 0.5, + }); + + expect(exhausted).toEqual({ + outcome: "retry_exhausted", + attempt: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length + 1, + maxAttempts: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length, + }); + + const runCount = await db + .select({ count: sql`count(*)::int` }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.companyId, companyId)) + .then((rows) => rows[0]?.count ?? 0); + expect(runCount).toBe(1); + + const exhaustionEvent = await db + .select({ + message: heartbeatRunEvents.message, + payload: heartbeatRunEvents.payload, + }) + .from(heartbeatRunEvents) + .where(eq(heartbeatRunEvents.runId, cappedRunId)) + .orderBy(sql`${heartbeatRunEvents.id} desc`) + .then((rows) => rows[0] ?? null); + + expect(exhaustionEvent?.message).toContain("Bounded retry exhausted"); + expect(exhaustionEvent?.payload).toMatchObject({ + retryReason: "transient_failure", + scheduledRetryAttempt: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length, + maxAttempts: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length, + }); + }); + + it("advances codex transient fallback stages across bounded retry attempts", async () => { + const fallbackModes = [ + "same_session", + "safer_invocation", + "fresh_session", + "fresh_session_safer_invocation", + ] as const; + + for (const [index, expectedMode] of fallbackModes.entries()) { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const now = new Date(`2026-04-20T1${index}:00:00.000Z`); + + await seedRetryFixture({ + runId, + companyId, + agentId, + now, + errorCode: "codex_transient_upstream", + scheduledRetryAttempt: index, + }); + + const scheduled = await heartbeat.scheduleBoundedRetry(runId, { + now, + random: () => 0.5, + }); + + expect(scheduled.outcome).toBe("scheduled"); + if (scheduled.outcome !== "scheduled") continue; + + const retryRun = await db + .select({ + contextSnapshot: heartbeatRuns.contextSnapshot, + wakeupRequestId: heartbeatRuns.wakeupRequestId, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, scheduled.run.id)) + .then((rows) => rows[0] ?? null); + expect((retryRun?.contextSnapshot as Record | null)?.codexTransientFallbackMode).toBe(expectedMode); + + const wakeupRequest = await db + .select({ payload: agentWakeupRequests.payload }) + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.id, retryRun?.wakeupRequestId ?? "")) + .then((rows) => rows[0] ?? null); + expect((wakeupRequest?.payload as Record | null)?.codexTransientFallbackMode).toBe(expectedMode); + + await db.delete(heartbeatRunEvents); + await db.delete(heartbeatRuns); + await db.delete(agentWakeupRequests); + await db.delete(agents); + await db.delete(companies); + } + }); +}); diff --git a/server/src/__tests__/heartbeat-run-summary.test.ts b/server/src/__tests__/heartbeat-run-summary.test.ts index 7355f0d..e5d8848 100644 --- a/server/src/__tests__/heartbeat-run-summary.test.ts +++ b/server/src/__tests__/heartbeat-run-summary.test.ts @@ -15,6 +15,10 @@ describe("summarizeHeartbeatRunResultJson", () => { total_cost_usd: 1.23, cost_usd: 0.45, costUsd: 0.67, + stopReason: "timeout", + effectiveTimeoutSec: 30, + timeoutConfigured: true, + timeoutFired: true, nested: { ignored: true }, }); @@ -26,6 +30,10 @@ describe("summarizeHeartbeatRunResultJson", () => { total_cost_usd: 1.23, cost_usd: 0.45, costUsd: 0.67, + stopReason: "timeout", + effectiveTimeoutSec: 30, + timeoutConfigured: true, + timeoutFired: true, }); }); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 6d6c67f..3f6cdd1 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -201,44 +201,6 @@ describe("buildRealizedExecutionWorkspaceFromPersisted", () => { expect(result.branchName).toBe("PAP-880-thumbs-capture-for-evals-feature"); expect(result.source).toBe("task_session"); }); - - it("falls back to realization when the persisted workspace has no local path yet", () => { - const result = buildRealizedExecutionWorkspaceFromPersisted({ - base: buildResolvedWorkspace({ - cwd: "/tmp/project-primary", - repoRef: "main", - }), - workspace: { - id: "execution-workspace-2", - companyId: "company-1", - projectId: "project-1", - projectWorkspaceId: "workspace-1", - sourceIssueId: "issue-2", - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "PAP-999-missing-provider-ref", - status: "active", - cwd: null, - repoUrl: "https://example.com/taskcore.git", - baseRef: "main", - branchName: "feature/PAP-999-missing-provider-ref", - providerType: "git_worktree", - providerRef: null, - derivedFromExecutionWorkspaceId: null, - lastUsedAt: new Date(), - openedAt: new Date(), - closedAt: null, - cleanupEligibleAt: null, - cleanupReason: null, - config: null, - metadata: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - - expect(result).toBeNull(); - }); }); describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => { diff --git a/server/src/__tests__/http-log-policy.test.ts b/server/src/__tests__/http-log-policy.test.ts index 0d8b17a..9658f84 100644 --- a/server/src/__tests__/http-log-policy.test.ts +++ b/server/src/__tests__/http-log-policy.test.ts @@ -53,9 +53,12 @@ describe("shouldSilenceHttpSuccessLog", () => { }); it("silences successful static asset requests", () => { + expect(shouldSilenceHttpSuccessLog("GET", "/", 200)).toBe(true); + expect(shouldSilenceHttpSuccessLog("GET", "/index.html", 200)).toBe(true); expect(shouldSilenceHttpSuccessLog("GET", "/@fs/Users/dotta/taskcore/ui/src/main.tsx", 200)).toBe(true); expect(shouldSilenceHttpSuccessLog("GET", "/src/App.tsx?t=123", 200)).toBe(true); expect(shouldSilenceHttpSuccessLog("GET", "/site.webmanifest", 200)).toBe(true); + expect(shouldSilenceHttpSuccessLog("GET", "/sw.js", 200)).toBe(true); }); it("keeps normal successful application requests", () => { diff --git a/server/src/__tests__/instance-database-backups-routes.test.ts b/server/src/__tests__/instance-database-backups-routes.test.ts new file mode 100644 index 0000000..bffba97 --- /dev/null +++ b/server/src/__tests__/instance-database-backups-routes.test.ts @@ -0,0 +1,149 @@ +import express from "express"; +import request from "supertest"; +import { describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { + instanceDatabaseBackupRoutes, + type InstanceDatabaseBackupService, +} from "../routes/instance-database-backups.js"; +import { conflict } from "../errors.js"; + +function createApp(actor: Record, service: InstanceDatabaseBackupService) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor as typeof req.actor; + next(); + }); + app.use("/api", instanceDatabaseBackupRoutes(service)); + app.use(errorHandler); + return app; +} + +function createBackupService(overrides: Partial = {}): InstanceDatabaseBackupService { + return { + runManualBackup: vi.fn().mockResolvedValue({ + trigger: "manual", + backupFile: "/tmp/taskcore-20260416.sql.gz", + sizeBytes: 1234, + prunedCount: 2, + backupDir: "/tmp", + retention: { + dailyDays: 7, + weeklyWeeks: 4, + monthlyMonths: 1, + }, + startedAt: "2026-04-16T20:00:00.000Z", + finishedAt: "2026-04-16T20:00:01.000Z", + durationMs: 1000, + }), + ...overrides, + }; +} + +describe("instance database backup routes", () => { + it("runs a manual backup for an instance admin and returns the server result", async () => { + const service = createBackupService(); + const app = createApp( + { + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + }, + service, + ); + + const res = await request(app).post("/api/instance/database-backups").send({}); + + expect(res.status).toBe(201); + expect(service.runManualBackup).toHaveBeenCalledTimes(1); + expect(res.body).toEqual({ + trigger: "manual", + backupFile: "/tmp/taskcore-20260416.sql.gz", + sizeBytes: 1234, + prunedCount: 2, + backupDir: "/tmp", + retention: { + dailyDays: 7, + weeklyWeeks: 4, + monthlyMonths: 1, + }, + startedAt: "2026-04-16T20:00:00.000Z", + finishedAt: "2026-04-16T20:00:01.000Z", + durationMs: 1000, + }); + }); + + it("allows local implicit board access", async () => { + const service = createBackupService(); + const app = createApp( + { + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: false, + }, + service, + ); + + await request(app).post("/api/instance/database-backups").send({}).expect(201); + + expect(service.runManualBackup).toHaveBeenCalledTimes(1); + }); + + it("rejects non-admin board users", async () => { + const service = createBackupService(); + const app = createApp( + { + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }, + service, + ); + + await request(app).post("/api/instance/database-backups").send({}).expect(403); + + expect(service.runManualBackup).not.toHaveBeenCalled(); + }); + + it("rejects agent callers", async () => { + const service = createBackupService(); + const app = createApp( + { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + }, + service, + ); + + await request(app).post("/api/instance/database-backups").send({}).expect(403); + + expect(service.runManualBackup).not.toHaveBeenCalled(); + }); + + it("returns conflict when another server backup is already running", async () => { + const service = createBackupService({ + runManualBackup: vi.fn().mockRejectedValue(conflict("Database backup already in progress")), + }); + const app = createApp( + { + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + }, + service, + ); + + const res = await request(app).post("/api/instance/database-backups").send({}); + + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: "Database backup already in progress" }); + }); +}); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index ff26500..6cabc2f 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -11,11 +11,6 @@ const mockInstanceSettingsService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); -vi.mock("../services/index.js", () => ({ - instanceSettingsService: () => mockInstanceSettingsService, - logActivity: mockLogActivity, -})); - function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ instanceSettingsService: () => mockInstanceSettingsService, @@ -42,6 +37,7 @@ async function createApp(actor: any) { describe("instance settings routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/instance-settings.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); @@ -176,6 +172,22 @@ describe("instance settings routes", () => { }); }); + it("rejects signed-in users without company access from reading general settings", async () => { + const app = await createApp({ + type: "board", + userId: "user-2", + source: "session", + isInstanceAdmin: false, + companyIds: [], + memberships: [], + }); + + const res = await request(app).get("/api/instance/settings/general"); + + expect(res.status).toBe(403); + expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled(); + }); + it("rejects non-admin board users from updating general settings", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/invite-accept-existing-member.test.ts b/server/src/__tests__/invite-accept-existing-member.test.ts new file mode 100644 index 0000000..913fcdb --- /dev/null +++ b/server/src/__tests__/invite-accept-existing-member.test.ts @@ -0,0 +1,126 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: vi.fn(), + notifyHireApproved: vi.fn(), +})); + +function createDbStub() { + const updateMock = vi.fn(); + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: { humanRole: "viewer" }, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + invitedByUserId: "user-1", + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + + const db = { + select() { + return { + from() { + return { + where() { + return Promise.resolve([invite]); + }, + }; + }, + }; + }, + update(...args: unknown[]) { + updateMock(...args); + return { + set() { + return { + where() { + return { + returning() { + return Promise.resolve([]); + }, + }; + }, + }; + }, + }; + }, + }; + + return { db, updateMock }; +} + +function createApp(db: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "session", + userId: "user-1", + companyIds: ["company-1"], + memberships: [ + { + companyId: "company-1", + membershipRole: "owner", + status: "active", + }, + ], + }; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("POST /invites/:token/accept", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not consume a human invite when the signed-in user is already a company member", async () => { + const { db, updateMock } = createDbStub(); + const app = createApp(db); + + const res = await request(app) + .post("/api/invites/pcp_invite_test/accept") + .send({ requestType: "human" }); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("You already belong to this company"); + expect(updateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/invite-create-route.test.ts b/server/src/__tests__/invite-create-route.test.ts new file mode 100644 index 0000000..1799506 --- /dev/null +++ b/server/src/__tests__/invite-create-route.test.ts @@ -0,0 +1,137 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const logActivityMock = vi.fn(); + +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + accessService: () => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: (...args: unknown[]) => logActivityMock(...args), + notifyHireApproved: vi.fn(), + })); +} + +function createDbStub() { + const createdInvite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: { humanRole: "viewer" }, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + + return { + insert() { + return { + values() { + return { + returning() { + return Promise.resolve([createdInvite]); + }, + }; + }, + }; + }, + select(_shape?: unknown) { + return { + from() { + const query = { + leftJoin() { + return query; + }, + where() { + return Promise.resolve([{ + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }]); + }, + }; + return query; + }, + }; + }, + }; +} + +async function createApp() { + const [{ accessRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/access.js"), + import("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId: null, + companyIds: ["company-1"], + }; + next(); + }); + app.use( + "/api", + accessRoutes(createDbStub() as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("POST /companies/:companyId/invites", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/access.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.resetAllMocks(); + logActivityMock.mockReset(); + }); + + it("returns an absolute invite URL using the request base URL", async () => { + const app = await createApp(); + + const res = await request(app) + .post("/api/companies/company-1/invites") + .set("host", "taskcore.example") + .set("x-forwarded-proto", "https") + .send({ + allowedJoinTypes: "human", + humanRole: "viewer", + }); + + expect(res.status).toBe(201); + expect(res.body.companyName).toBe("Acme Robotics"); + expect(res.body.invitePath).toMatch(/^\/invite\/pcp_invite_/); + expect(res.body.inviteUrl).toMatch(/^https:\/\/taskcore\.example\/invite\/pcp_invite_/); + }); +}); diff --git a/server/src/__tests__/invite-expiry.test.ts b/server/src/__tests__/invite-expiry.test.ts index c84a2a9..66a7e7d 100644 --- a/server/src/__tests__/invite-expiry.test.ts +++ b/server/src/__tests__/invite-expiry.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest"; import { companyInviteExpiresAt } from "../routes/access.js"; describe("companyInviteExpiresAt", () => { - it("sets invite expiration to 10 minutes after invite creation time", () => { + it("sets invite expiration to 72 hours after invite creation time", () => { const createdAtMs = Date.parse("2026-03-06T00:00:00.000Z"); const expiresAt = companyInviteExpiresAt(createdAtMs); - expect(expiresAt.toISOString()).toBe("2026-03-06T00:10:00.000Z"); + expect(expiresAt.toISOString()).toBe("2026-03-09T00:00:00.000Z"); }); }); diff --git a/server/src/__tests__/invite-join-grants.test.ts b/server/src/__tests__/invite-join-grants.test.ts index 7dd3426..fae007e 100644 --- a/server/src/__tests__/invite-join-grants.test.ts +++ b/server/src/__tests__/invite-join-grants.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { agentJoinGrantsFromDefaults } from "../routes/access.js"; +import { + agentJoinGrantsFromDefaults, + humanJoinGrantsFromDefaults, +} from "../services/invite-grants.js"; +import { + grantsForHumanRole, + normalizeHumanRole, + resolveHumanInviteRole, +} from "../services/company-member-roles.js"; describe("agentJoinGrantsFromDefaults", () => { it("adds tasks:assign when invite defaults do not specify agent grants", () => { @@ -55,3 +63,59 @@ describe("agentJoinGrantsFromDefaults", () => { ]); }); }); + +describe("human invite roles", () => { + it("maps owner to the full management grant set", () => { + expect(grantsForHumanRole("owner")).toEqual([ + { permissionKey: "agents:create", scope: null }, + { permissionKey: "users:invite", scope: null }, + { permissionKey: "users:manage_permissions", scope: null }, + { permissionKey: "tasks:assign", scope: null }, + { permissionKey: "joins:approve", scope: null }, + ]); + }); + + it("defaults legacy or missing roles to operator", () => { + expect(normalizeHumanRole("member")).toBe("operator"); + expect(resolveHumanInviteRole(null)).toBe("operator"); + }); + + it("reads the configured human invite role from defaults", () => { + expect( + resolveHumanInviteRole({ + human: { + role: "viewer", + }, + }), + ).toBe("viewer"); + }); + + it("falls back to role grants when human invite defaults omit explicit grants", () => { + expect(humanJoinGrantsFromDefaults(null, "operator")).toEqual([ + { permissionKey: "tasks:assign", scope: null }, + ]); + }); + + it("preserves explicit human invite grants", () => { + expect( + humanJoinGrantsFromDefaults( + { + human: { + grants: [ + { + permissionKey: "users:invite", + scope: { companyId: "company-1" }, + }, + ], + }, + }, + "operator", + ), + ).toEqual([ + { + permissionKey: "users:invite", + scope: { companyId: "company-1" }, + }, + ]); + }); +}); diff --git a/server/src/__tests__/invite-list-route.test.ts b/server/src/__tests__/invite-list-route.test.ts new file mode 100644 index 0000000..3c41038 --- /dev/null +++ b/server/src/__tests__/invite-list-route.test.ts @@ -0,0 +1,164 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { companies, createDb, invites, joinRequests } from "@taskcore/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: vi.fn(), + notifyHireApproved: vi.fn(), +})); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres invite list route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("GET /companies/:companyId/invites", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + let companyId!: string; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-invite-list-route-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + beforeEach(async () => { + companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + }); + + afterEach(async () => { + await db.delete(joinRequests); + await db.delete(invites); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createApp(currentCompanyId: string) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId: null, + companyIds: [currentCompanyId], + }; + next(); + }); + app.use( + "/api", + accessRoutes(db, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; + } + + it("returns invite history in descending pages with nextOffset", async () => { + const inviteOneId = randomUUID(); + const inviteTwoId = randomUUID(); + const inviteThreeId = randomUUID(); + + await db.insert(invites).values([ + { + id: inviteOneId, + companyId, + inviteType: "company_join", + tokenHash: "invite-token-1", + allowedJoinTypes: "human", + defaultsPayload: { humanRole: "viewer" }, + expiresAt: new Date("2026-04-20T00:00:00.000Z"), + createdAt: new Date("2026-04-10T00:00:00.000Z"), + updatedAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + id: inviteTwoId, + companyId, + inviteType: "company_join", + tokenHash: "invite-token-2", + allowedJoinTypes: "human", + defaultsPayload: { humanRole: "operator" }, + expiresAt: new Date("2026-04-21T00:00:00.000Z"), + createdAt: new Date("2026-04-11T00:00:00.000Z"), + updatedAt: new Date("2026-04-11T00:00:00.000Z"), + }, + { + id: inviteThreeId, + companyId, + inviteType: "company_join", + tokenHash: "invite-token-3", + allowedJoinTypes: "human", + defaultsPayload: { humanRole: "admin" }, + expiresAt: new Date("2026-04-22T00:00:00.000Z"), + createdAt: new Date("2026-04-12T00:00:00.000Z"), + updatedAt: new Date("2026-04-12T00:00:00.000Z"), + }, + ]); + + await db.insert(joinRequests).values({ + id: randomUUID(), + inviteId: inviteThreeId, + companyId, + requestType: "human", + status: "pending_approval", + requestIp: "127.0.0.1", + requestEmailSnapshot: "person@example.com", + createdAt: new Date("2026-04-12T00:05:00.000Z"), + updatedAt: new Date("2026-04-12T00:05:00.000Z"), + }); + + const app = createApp(companyId); + + const firstPage = await request(app).get(`/api/companies/${companyId}/invites?limit=2`); + + expect(firstPage.status).toBe(200); + expect(firstPage.body.invites).toHaveLength(2); + expect(firstPage.body.invites.map((invite: { id: string }) => invite.id)).toEqual([inviteThreeId, inviteTwoId]); + expect(firstPage.body.invites[0].relatedJoinRequestId).toBeTruthy(); + expect(firstPage.body.nextOffset).toBe(2); + + const secondPage = await request(app).get(`/api/companies/${companyId}/invites?limit=2&offset=2`); + + expect(secondPage.status).toBe(200); + expect(secondPage.body.invites).toHaveLength(1); + expect(secondPage.body.invites[0].id).toBe(inviteOneId); + expect(secondPage.body.nextOffset).toBeNull(); + }); +}); diff --git a/server/src/__tests__/invite-logo-route.test.ts b/server/src/__tests__/invite-logo-route.test.ts new file mode 100644 index 0000000..a18137c --- /dev/null +++ b/server/src/__tests__/invite-logo-route.test.ts @@ -0,0 +1,146 @@ +import { Readable } from "node:stream"; +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockStorage = vi.hoisted(() => ({ + getObject: vi.fn(), + headObject: vi.fn(), +})); + +vi.mock("../storage/index.js", () => ({ + getStorageService: () => mockStorage, +})); + +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +function createSelectChain(rows: unknown[]) { + const query = { + leftJoin() { + return query; + }, + where() { + return Promise.resolve(rows); + }, + }; + return { + from() { + return query; + }, + }; +} + +function createDbStub(inviteRows: unknown[], companyRows: unknown[]) { + let selectCall = 0; + return { + select() { + selectCall += 1; + return selectCall === 1 + ? createSelectChain(inviteRows) + : createSelectChain(companyRows); + }, + }; +} + +function createApp(db: Record) { + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "anon" }; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("GET /invites/:token/logo", () => { + beforeEach(() => { + mockStorage.getObject.mockReset(); + mockStorage.headObject.mockReset(); + }); + + it("serves the company logo for an active invite without company auth", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + mockStorage.headObject.mockResolvedValue({ + exists: true, + contentType: "image/png", + contentLength: 3, + }); + mockStorage.getObject.mockResolvedValue({ + contentType: "image/png", + contentLength: 3, + stream: Readable.from([Buffer.from("png")]), + }); + const app = createApp( + createDbStub([invite], [{ + companyId: "company-1", + objectKey: "assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }]), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test/logo"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("image/png"); + expect(mockStorage.headObject).toHaveBeenCalledWith("company-1", "assets/companies/logo-1"); + expect(mockStorage.getObject).toHaveBeenCalledWith("company-1", "assets/companies/logo-1"); + }); + + it("returns 404 when the logo asset record exists but storage does not", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + mockStorage.headObject.mockResolvedValue({ exists: false }); + const app = createApp( + createDbStub([invite], [{ + companyId: "company-1", + objectKey: "assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }]), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test/logo"); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Invite logo not found"); + expect(mockStorage.getObject).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index d421dbc..aeb8d40 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -41,6 +41,7 @@ describe("buildInviteOnboardingTextDocument", () => { expect(text).toContain("/api/invites/token-123/accept"); expect(text).toContain("/api/join-requests/{requestId}/claim-api-key"); expect(text).toContain("/api/invites/token-123/onboarding.txt"); + expect(text).toContain("/api/invites/token-123/skills/taskcore"); expect(text).toContain("Suggested Taskcore base URLs to try"); expect(text).toContain("http://localhost:3100"); expect(text).toContain("host.docker.internal"); diff --git a/server/src/__tests__/invite-summary-route.test.ts b/server/src/__tests__/invite-summary-route.test.ts new file mode 100644 index 0000000..ffce9b7 --- /dev/null +++ b/server/src/__tests__/invite-summary-route.test.ts @@ -0,0 +1,278 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockStorage = vi.hoisted(() => ({ + headObject: vi.fn(), +})); + +function registerModuleMocks() { + vi.doMock("../storage/index.js", () => ({ + getStorageService: () => mockStorage, + })); +} + +function createSelectChain(rows: unknown[]) { + const query = { + then(resolve: (value: unknown[]) => unknown) { + return Promise.resolve(rows).then(resolve); + }, + leftJoin() { + return query; + }, + orderBy() { + return query; + }, + where() { + return query; + }, + }; + return { + from() { + return query; + }, + }; +} + +function createDbStub(...selectResponses: unknown[][]) { + let selectCall = 0; + return { + select() { + const rows = selectResponses[selectCall] ?? []; + selectCall += 1; + return createSelectChain(rows); + }, + }; +} + +async function createApp( + db: Record, + actor: Record = { type: "anon" }, +) { + const [{ accessRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/access.js"), + vi.importActual("../middleware/index.js"), + ]); + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("GET /invites/:token", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../storage/index.js"); + vi.doUnmock("../routes/access.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + mockStorage.headObject.mockReset(); + mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" }); + }); + + it("returns company branding in the invite summary response", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + const app = await createApp( + createDbStub( + [invite], + [ + { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }, + ], + [ + { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }, + ], + ), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.companyId).toBe("company-1"); + expect(res.body.companyName).toBe("Acme Robotics"); + expect(res.body.companyBrandColor).toBe("#114488"); + expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo"); + expect(res.body.inviteType).toBe("company_join"); + }); + + it("omits companyLogoUrl when the stored logo object is missing", async () => { + mockStorage.headObject.mockResolvedValue({ exists: false }); + + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + const app = await createApp( + createDbStub( + [invite], + [ + { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }, + ], + [ + { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }, + ], + ), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.companyLogoUrl).toBeNull(); + }); + + it("returns pending join-request status for an already-accepted invite", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: new Date("2026-03-07T00:05:00.000Z"), + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:05:00.000Z"), + }; + const app = await createApp( + createDbStub( + [invite], + [{ requestType: "human", status: "pending_approval" }], + [ + { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }, + ], + [ + { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }, + ], + ), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.joinRequestStatus).toBe("pending_approval"); + expect(res.body.joinRequestType).toBe("human"); + expect(res.body.companyName).toBe("Acme Robotics"); + }); + + it("falls back to a reusable human join request when the accepted invite reused an existing queue entry", async () => { + const invite = { + id: "invite-2", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: new Date("2026-03-07T00:05:00.000Z"), + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:05:00.000Z"), + }; + const reusableJoinRequest = { + id: "join-1", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "jane@example.com", + }; + const companyBranding = { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }; + const logoAsset = { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }; + const app = await createApp( + createDbStub( + [invite], + [], + [{ email: "jane@example.com" }], + [reusableJoinRequest], + [reusableJoinRequest], + [companyBranding], + [companyBranding], + [logoAsset], + [logoAsset], + ), + { type: "board", userId: "user-1", source: "session" }, + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.joinRequestStatus).toBe("pending_approval"); + expect(res.body.joinRequestType).toBe("human"); + }); +}); diff --git a/server/src/__tests__/invite-test-resolution-route.test.ts b/server/src/__tests__/invite-test-resolution-route.test.ts new file mode 100644 index 0000000..35fd9ee --- /dev/null +++ b/server/src/__tests__/invite-test-resolution-route.test.ts @@ -0,0 +1,215 @@ +import express from "express"; +import request from "supertest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +function createSelectChain(rows: unknown[]) { + const query = { + then(resolve: (value: unknown[]) => unknown) { + return Promise.resolve(rows).then(resolve); + }, + where() { + return query; + }, + }; + return { + from() { + return query; + }, + }; +} + +function createDbStub(inviteRows: unknown[]) { + return { + select() { + return createSelectChain(inviteRows); + }, + }; +} + +function createInvite(overrides: Record = {}) { + return { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "agent", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + ...overrides, + }; +} + +let currentAccessModule: Awaited>> | null = null; + +async function createApp( + db: Record, + network: { + lookup: ReturnType; + requestHead: ReturnType; + }, +) { + const [access, middleware] = await Promise.all([ + vi.importActual("../routes/access.js"), + vi.importActual("../middleware/index.js"), + ]); + currentAccessModule = access; + access.setInviteResolutionNetworkForTest(network); + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "anon" }; + next(); + }); + app.use( + "/api", + access.accessRoutes(db as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(middleware.errorHandler); + return app; +} + +describe("GET /invites/:token/test-resolution", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("node:dns/promises"); + vi.doUnmock("node:http"); + vi.doUnmock("node:https"); + vi.doUnmock("node:net"); + vi.doUnmock("../board-claim.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../storage/index.js"); + vi.doUnmock("../middleware/logger.js"); + vi.doUnmock("../routes/access.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + vi.doMock("node:dns/promises", async () => vi.importActual("node:dns/promises")); + vi.doMock("node:http", async () => vi.importActual("node:http")); + vi.doMock("node:https", async () => vi.importActual("node:https")); + vi.doMock("node:net", async () => vi.importActual("node:net")); + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + currentAccessModule = null; + }); + + afterEach(async () => { + currentAccessModule?.setInviteResolutionNetworkForTest(null); + }); + + it.each([ + ["localhost", "http://localhost:3100/api/health", "127.0.0.1"], + ["IPv4 loopback", "http://127.0.0.1:3100/api/health", "127.0.0.1"], + ["IPv6 loopback", "http://[::1]:3100/api/health", "::1"], + ["IPv4-mapped IPv6 loopback hex", "http://[::ffff:7f00:1]/api/health", "::ffff:7f00:1"], + ["IPv4-mapped IPv6 RFC1918 hex", "http://[::ffff:c0a8:101]/api/health", "::ffff:c0a8:101"], + ["RFC1918 10/8", "http://10.0.0.5/api/health", "10.0.0.5"], + ["RFC1918 172.16/12", "http://172.16.10.5/api/health", "172.16.10.5"], + ["RFC1918 192.168/16", "http://192.168.1.10/api/health", "192.168.1.10"], + ["link-local metadata", "http://169.254.169.254/latest/meta-data", "169.254.169.254"], + ["multicast", "http://224.0.0.1/probe", "224.0.0.1"], + ["NAT64 well-known prefix", "https://gateway.example.test/health", "64:ff9b::0a00:0001"], + ["NAT64 local-use prefix", "https://gateway.example.test/health", "64:ff9b:1::0a00:0001"], + ])("rejects %s targets before probing", async (_label, url, address) => { + const lookup = vi.fn().mockResolvedValue([{ address, family: address.includes(":") ? 6 : 4 }]); + const requestHead = vi.fn(); + const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead }); + + const res = await request(app) + .get("/api/invites/pcp_invite_test/test-resolution") + .query({ url }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe( + "url resolves to a private, local, multicast, or reserved address", + ); + expect(requestHead).not.toHaveBeenCalled(); + }, 15_000); + + it("rejects hostnames that resolve to private addresses", async () => { + const lookup = vi.fn().mockResolvedValue([{ address: "10.1.2.3", family: 4 }]); + const requestHead = vi.fn(); + const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead }); + + const res = await request(app) + .get("/api/invites/pcp_invite_test/test-resolution") + .query({ url: "https://gateway.example.test/health" }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe( + "url resolves to a private, local, multicast, or reserved address", + ); + expect(lookup).toHaveBeenCalledWith("gateway.example.test"); + expect(requestHead).not.toHaveBeenCalled(); + }); + + it("rejects hostnames when any resolved address is private", async () => { + const lookup = vi.fn().mockResolvedValue([ + { address: "127.0.0.1", family: 4 }, + { address: "93.184.216.34", family: 4 }, + ]); + const requestHead = vi.fn(); + const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead }); + + const res = await request(app) + .get("/api/invites/pcp_invite_test/test-resolution") + .query({ url: "https://mixed.example.test/health" }); + + expect(res.status).toBe(400); + expect(requestHead).not.toHaveBeenCalled(); + }); + + it("allows public HTTPS targets through the resolved and pinned probe path", async () => { + const lookup = vi.fn().mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + const requestHead = vi.fn().mockResolvedValue({ httpStatus: 204 }); + const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead }); + + const res = await request(app) + .get("/api/invites/pcp_invite_test/test-resolution") + .query({ url: "https://gateway.example.test/health", timeoutMs: "2500" }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + inviteId: "invite-1", + requestedUrl: "https://gateway.example.test/health", + timeoutMs: 2500, + status: "reachable", + method: "HEAD", + httpStatus: 204, + }); + expect(requestHead).toHaveBeenCalledWith( + expect.objectContaining({ + resolvedAddress: "93.184.216.34", + resolvedAddresses: ["93.184.216.34"], + hostHeader: "gateway.example.test", + tlsServername: "gateway.example.test", + }), + 2500, + ); + }); + + it.each([ + ["missing invite", []], + ["revoked invite", [createInvite({ revokedAt: new Date("2026-03-07T00:05:00.000Z") })]], + ["expired invite", [createInvite({ expiresAt: new Date("2020-03-07T00:10:00.000Z") })]], + ])("returns not found for %s tokens before DNS lookup", async (_label, inviteRows) => { + const lookup = vi.fn(); + const requestHead = vi.fn(); + const app = await createApp(createDbStub(inviteRows), { lookup, requestHead }); + + const res = await request(app) + .get("/api/invites/pcp_invite_test/test-resolution") + .query({ url: "https://gateway.example.test/health" }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Invite not found"); + expect(lookup).not.toHaveBeenCalled(); + expect(requestHead).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index 36a64bd..f85c04c 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -15,48 +15,96 @@ const mockIssueService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); - -vi.mock("../services/index.js", () => ({ - accessService: () => ({ - canUser: vi.fn(async () => false), - hasPermission: vi.fn(async () => false), - }), - agentService: () => ({ - getById: vi.fn(async () => null), - }), - documentService: () => ({}), - executionWorkspaceService: () => ({}), - feedbackService: () => ({ - listIssueVotesForUser: vi.fn(async () => []), - saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), - }), - goalService: () => ({}), - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - getRun: vi.fn(async () => null), - getActiveRunForAgent: vi.fn(async () => null), - cancelRun: vi.fn(async () => null), - }), - instanceSettingsService: () => ({ - get: vi.fn(async () => ({ - id: "instance-settings-1", - general: { - censorUsernameInLogs: false, - feedbackDataSharingPreference: "prompt", - }, - })), - listCompanyIds: vi.fn(async () => ["company-1"]), - }), - issueApprovalService: () => ({}), - issueService: () => mockIssueService, - logActivity: mockLogActivity, - projectService: () => ({}), - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({}), +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(async () => false), + hasPermission: vi.fn(async () => false), +})); +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), +})); +const mockFeedbackService = vi.hoisted(() => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), +})); +const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), })); +const mockRoutineService = vi.hoisted(() => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), +})); + +function registerModuleMocks() { + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/feedback.js", () => ({ + feedbackService: () => mockFeedbackService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/routines.js", () => ({ + routineService: () => mockRoutineService, + })); + + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => ({ + getById: vi.fn(async () => null), + }), + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => mockFeedbackService, + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, + issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => mockRoutineService, + workProductService: () => ({}), + })); +} async function createApp() { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ @@ -98,49 +146,84 @@ function makeIssue() { describe("issue activity event routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/feedback.js"); + vi.doUnmock("../services/heartbeat.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/instance-settings.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/routines.js"); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockIssueService.findMentionedAgents.mockResolvedValue([]); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); + mockAccessService.canUser.mockResolvedValue(false); + mockAccessService.hasPermission.mockResolvedValue(false); + mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]); + mockFeedbackService.saveIssueVote.mockResolvedValue({ + vote: null, + consentEnabledNow: false, + sharingEnabled: false, + }); + mockHeartbeatService.wakeup.mockResolvedValue(undefined); + mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined); + mockHeartbeatService.getRun.mockResolvedValue(null); + mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); + mockHeartbeatService.cancelRun.mockResolvedValue(null); + mockInstanceSettingsService.get.mockResolvedValue({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + }); + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); + mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined); }); it("logs blocker activity with added and removed issue summaries", async () => { const issue = makeIssue(); mockIssueService.getById.mockResolvedValue(issue); - mockIssueService.getRelationSummaries - .mockResolvedValueOnce({ - blockedBy: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - identifier: "PAP-10", - title: "Old blocker", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, - ], - blocks: [], - }) - .mockResolvedValueOnce({ - blockedBy: [ - { - id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - identifier: "PAP-11", - title: "New blocker", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, - ], - blocks: [], - }); + const previousRelations = { + blockedBy: [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + identifier: "PAP-10", + title: "Old blocker", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + ], + blocks: [], + }; + const nextRelations = { + blockedBy: [ + { + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + identifier: "PAP-11", + title: "New blocker", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + ], + blocks: [], + }; + let relationLookupCount = 0; + mockIssueService.getRelationSummaries.mockImplementation(async () => { + relationLookupCount += 1; + return relationLookupCount === 1 ? previousRelations : nextRelations; + }); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts new file mode 100644 index 0000000..d670170 --- /dev/null +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -0,0 +1,464 @@ +import { Readable } from "node:stream"; +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const issueId = "11111111-1111-4111-8111-111111111111"; +const companyId = "22222222-2222-4222-8222-222222222222"; +const ownerAgentId = "33333333-3333-4333-8333-333333333333"; +const peerAgentId = "44444444-4444-4444-8444-444444444444"; +const ownerRunId = "55555555-5555-4555-8555-555555555555"; + +const mockIssueService = vi.hoisted(() => ({ + addComment: vi.fn(), + assertCheckoutOwner: vi.fn(), + getAttachmentById: vi.fn(), + getByIdentifier: vi.fn(), + getById: vi.fn(), + getRelationSummaries: vi.fn(), + getWakeableParentAfterChildCompletion: vi.fn(), + listAttachments: vi.fn(), + listWakeableBlockedDependents: vi.fn(), + remove: vi.fn(), + removeAttachment: vi.fn(), + update: vi.fn(), + findMentionedAgents: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), +})); + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), + list: vi.fn(), + resolveByReference: vi.fn(), +})); + +const mockDocumentService = vi.hoisted(() => ({ + upsertIssueDocument: vi.fn(), +})); + +const mockWorkProductService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), +})); + +const mockStorageService = vi.hoisted(() => ({ + provider: "local_disk", + putFile: vi.fn(), + getObject: vi.fn(), + headObject: vi.fn(), + deleteObject: vi.fn(), +})); +const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), +})); + +function registerRouteMocks() { + vi.doMock("@taskcore/shared/telemetry", () => ({ + trackAgentTaskCompleted: vi.fn(), + trackErrorHandlerCrash: vi.fn(), + })); + + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), + })); + + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/documents.js", () => ({ + documentService: () => mockDocumentService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/work-products.js", () => ({ + workProductService: () => mockWorkProductService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: vi.fn(async () => undefined), + })); + + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + documentService: () => mockDocumentService, + executionWorkspaceService: () => ({}), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => ({}), + heartbeatService: () => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), + }), + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => [companyId]), + }), + issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), + issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, + logActivity: vi.fn(async () => undefined), + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => mockWorkProductService, + })); +} + +function makeIssue(overrides: Record = {}) { + return { + id: issueId, + companyId, + status: "in_progress", + priority: "high", + projectId: null, + goalId: null, + parentId: null, + assigneeAgentId: ownerAgentId, + assigneeUserId: null, + createdByUserId: "board-user", + identifier: "PAP-1649", + title: "Owned active issue", + executionPolicy: null, + executionState: null, + hiddenAt: null, + ...overrides, + }; +} + +function makeAgent(id: string, overrides: Record = {}) { + return { + id, + companyId, + role: "engineer", + reportsTo: null, + permissions: { canCreateAgents: false }, + ...overrides, + }; +} + +async function createApp(actor: Record) { + const [{ errorHandler }, { issueRoutes }] = await Promise.all([ + vi.importActual("../middleware/index.js"), + vi.importActual("../routes/issues.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", issueRoutes({} as any, mockStorageService as any)); + app.use(errorHandler); + return app; +} + +function peerActor(overrides: Record = {}) { + return { + type: "agent", + agentId: peerAgentId, + companyId, + source: "agent_key", + runId: "66666666-6666-4666-8666-666666666666", + ...overrides, + }; +} + +function ownerActor() { + return { + type: "agent", + agentId: ownerAgentId, + companyId, + source: "agent_key", + runId: ownerRunId, + }; +} + +function boardActor() { + return { + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "local_implicit", + isInstanceAdmin: false, + }; +} + +describe("agent issue mutation checkout ownership", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("@taskcore/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/documents.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/work-products.js"); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerRouteMocks(); + vi.resetAllMocks(); + mockAccessService.canUser.mockReset(); + mockAccessService.hasPermission.mockReset(); + mockAgentService.getById.mockReset(); + mockAgentService.list.mockReset(); + mockAgentService.resolveByReference.mockReset(); + mockIssueService.addComment.mockReset(); + mockIssueService.assertCheckoutOwner.mockReset(); + mockIssueService.getAttachmentById.mockReset(); + mockIssueService.getByIdentifier.mockReset(); + mockIssueService.getById.mockReset(); + mockIssueService.getRelationSummaries.mockReset(); + mockIssueService.getWakeableParentAfterChildCompletion.mockReset(); + mockIssueService.listAttachments.mockReset(); + mockIssueService.listWakeableBlockedDependents.mockReset(); + mockIssueService.remove.mockReset(); + mockIssueService.removeAttachment.mockReset(); + mockIssueService.update.mockReset(); + mockIssueService.findMentionedAgents.mockReset(); + mockDocumentService.upsertIssueDocument.mockReset(); + mockWorkProductService.getById.mockReset(); + mockWorkProductService.update.mockReset(); + mockStorageService.putFile.mockReset(); + mockStorageService.getObject.mockReset(); + mockStorageService.headObject.mockReset(); + mockStorageService.deleteObject.mockReset(); + mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.hasPermission.mockResolvedValue(false); + mockAgentService.getById.mockImplementation(async (id: string) => { + if (id === ownerAgentId) return makeAgent(ownerAgentId); + if (id === peerAgentId) return makeAgent(peerAgentId); + return null; + }); + mockAgentService.list.mockResolvedValue([ + makeAgent(ownerAgentId), + makeAgent(peerAgentId), + ]); + mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: null }); + mockIssueService.getById.mockResolvedValue(makeIssue()); + mockIssueService.getByIdentifier.mockResolvedValue(null); + mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); + mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); + mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); + mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); + mockIssueService.findMentionedAgents.mockResolvedValue([]); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue(), + ...patch, + })); + mockIssueService.addComment.mockResolvedValue({ + id: "77777777-7777-4777-8777-777777777777", + issueId, + companyId, + body: "comment", + }); + mockIssueService.listAttachments.mockResolvedValue([]); + mockIssueService.remove.mockResolvedValue(makeIssue({ status: "cancelled" })); + mockIssueService.getAttachmentById.mockResolvedValue({ + id: "attachment-1", + issueId, + companyId, + objectKey: "issues/attachment-1/report.txt", + contentType: "text/plain", + byteSize: 6, + originalFilename: "report.txt", + }); + mockIssueService.removeAttachment.mockResolvedValue({ + id: "attachment-1", + issueId, + companyId, + objectKey: "issues/attachment-1/report.txt", + }); + mockDocumentService.upsertIssueDocument.mockResolvedValue({ + created: false, + document: { + id: "document-1", + key: "plan", + title: "Plan", + format: "markdown", + latestRevisionNumber: 2, + }, + }); + mockWorkProductService.getById.mockResolvedValue({ + id: "product-1", + issueId, + companyId, + type: "artifact", + }); + mockWorkProductService.update.mockResolvedValue({ + id: "product-1", + issueId, + companyId, + type: "artifact", + title: "Updated", + }); + mockStorageService.putFile.mockResolvedValue({ + provider: "local_disk", + objectKey: "issues/upload.txt", + contentType: "text/plain", + byteSize: 6, + sha256: "sha256", + originalFilename: "upload.txt", + }); + mockStorageService.getObject.mockResolvedValue({ + stream: Readable.from(Buffer.from("report")), + contentLength: 6, + }); + mockStorageService.deleteObject.mockResolvedValue(undefined); + }); + + it.each([ + ["patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Blocked" })], + ["delete", (app: express.Express) => request(app).delete(`/api/issues/${issueId}`)], + ["comment", (app: express.Express) => request(app).post(`/api/issues/${issueId}/comments`).send({ body: "blocked" })], + [ + "document upsert", + (app: express.Express) => + request(app).put(`/api/issues/${issueId}/documents/plan`).send({ format: "markdown", body: "# blocked" }), + ], + ["work product update", (app: express.Express) => request(app).patch("/api/work-products/product-1").send({ title: "Blocked" })], + [ + "attachment upload", + (app: express.Express) => + request(app) + .post(`/api/companies/${companyId}/issues/${issueId}/attachments`) + .attach("file", Buffer.from("report"), { filename: "report.txt", contentType: "text/plain" }), + ], + ["attachment delete", (app: express.Express) => request(app).delete("/api/attachments/attachment-1")], + ])("rejects peer agent %s on another agent's active checkout", async (_name, sendRequest) => { + const res = await sendRequest(await createApp(peerActor())); + + expect(res.status, JSON.stringify(res.body)).toBe(409); + expect(res.body.error).toBe("Issue is checked out by another agent"); + expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + expect(mockDocumentService.upsertIssueDocument).not.toHaveBeenCalled(); + expect(mockWorkProductService.update).not.toHaveBeenCalled(); + expect(mockStorageService.putFile).not.toHaveBeenCalled(); + expect(mockStorageService.deleteObject).not.toHaveBeenCalled(); + }); + + it("allows the checked-out owner with the matching run id to patch and update documents", async () => { + const app = await createApp(ownerActor()); + + await request(app).patch(`/api/issues/${issueId}`).send({ title: "Updated" }).expect(200); + await request(app) + .put(`/api/issues/${issueId}/documents/plan`) + .send({ format: "markdown", body: "# updated" }) + .expect(200); + + expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith(issueId, ownerAgentId, ownerRunId); + expect(mockIssueService.update).toHaveBeenCalled(); + expect(mockDocumentService.upsertIssueDocument).toHaveBeenCalledWith( + expect.objectContaining({ + issueId, + key: "plan", + createdByAgentId: ownerAgentId, + createdByRunId: ownerRunId, + }), + ); + }); + + it("preserves board mutations on active checkouts", async () => { + const app = await createApp(boardActor()); + + await request(app).patch(`/api/issues/${issueId}`).send({ title: "Board update" }).expect(200); + await request(app) + .put(`/api/issues/${issueId}/documents/plan`) + .send({ format: "markdown", body: "# board" }) + .expect(200); + + expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); + expect(mockIssueService.update).toHaveBeenCalled(); + expect(mockDocumentService.upsertIssueDocument).toHaveBeenCalled(); + }); + + it("allows agents with the active-checkout management grant to mutate active checkouts", async () => { + mockAccessService.hasPermission.mockImplementation(async ( + _companyId: string, + _principalType: string, + principalId: string, + permissionKey: string, + ) => principalId === peerAgentId && permissionKey === "tasks:manage_active_checkouts"); + + const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Managed update" }); + + expect(res.status).toBe(200); + expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); + expect(mockIssueService.update).toHaveBeenCalled(); + }); + + it("allows same-company agent mutations when the issue is not in progress", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: ownerAgentId })); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue({ status: "todo", assigneeAgentId: ownerAgentId }), + ...patch, + })); + + const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Todo update" }); + + expect(res.status).toBe(200); + expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); + expect(mockIssueService.update).toHaveBeenCalled(); + }); + + it("allows same-company agent mutations on unassigned in-progress issues", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: null })); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue({ assigneeAgentId: null }), + ...patch, + })); + + const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Claimable update" }); + + expect(res.status).toBe(200); + expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); + expect(res.body).toMatchObject({ + id: issueId, + assigneeAgentId: null, + title: "Claimable update", + }); + }); +}); diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index 18cec00..b8320f0 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -23,6 +23,14 @@ function registerRouteMocks() { getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), })); + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + vi.doMock("../services/index.js", () => ({ accessService: () => ({ canUser: vi.fn(), @@ -56,6 +64,19 @@ function registerRouteMocks() { listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), issueService: () => mockIssueService, logActivity: mockLogActivity, projectService: () => ({}), @@ -86,12 +107,12 @@ function createStorageService(): TestStorageService { putFile: async (input) => { calls.putFile = input; return { - provider: "local_disk", - objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`, - contentType: input.contentType, - byteSize: input.body.length, - sha256: "sha256-sample", - originalFilename: input.originalFilename, + provider: "local_disk", + objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`, + contentType: input.contentType, + byteSize: input.body.length, + sha256: "sha256-sample", + originalFilename: input.originalFilename, }; }, getObject: vi.fn(async () => ({ @@ -105,8 +126,8 @@ function createStorageService(): TestStorageService { async function createApp(storage: StorageService) { const [{ errorHandler }, { issueRoutes }] = await Promise.all([ - import("../middleware/index.js"), - import("../routes/issues.js"), + vi.importActual("../middleware/index.js"), + vi.importActual("../routes/issues.js"), ]); const app = express(); app.use((req, _res, next) => { @@ -148,8 +169,16 @@ function makeAttachment(contentType: string, originalFilename: string) { describe("issue attachment routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("@taskcore/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); registerRouteMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); mockLogActivity.mockResolvedValue(undefined); }); diff --git a/server/src/__tests__/issue-closed-workspace-routes.test.ts b/server/src/__tests__/issue-closed-workspace-routes.test.ts index 26db95c..9724779 100644 --- a/server/src/__tests__/issue-closed-workspace-routes.test.ts +++ b/server/src/__tests__/issue-closed-workspace-routes.test.ts @@ -38,6 +38,8 @@ const mockProjectService = vi.hoisted(() => ({ const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); function registerServiceMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + vi.doMock("@taskcore/shared/telemetry", () => ({ trackAgentTaskCompleted: vi.fn(), trackErrorHandlerCrash: vi.fn(), @@ -47,6 +49,30 @@ function registerServiceMocks() { getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), })); + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/execution-workspaces.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/projects.js", () => ({ + projectService: () => mockProjectService, + })); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => ({ @@ -74,6 +100,19 @@ function registerServiceMocks() { listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), issueService: () => mockIssueService, logActivity: mockLogActivity, projectService: () => mockProjectService, @@ -139,7 +178,13 @@ describe("closed isolated workspace issue routes", () => { vi.resetModules(); vi.doUnmock("@taskcore/shared/telemetry"); vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/execution-workspaces.js"); + vi.doUnmock("../services/heartbeat.js"); vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/projects.js"); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); diff --git a/server/src/__tests__/issue-comment-cancel-routes.test.ts b/server/src/__tests__/issue-comment-cancel-routes.test.ts index 174a235..80f3f71 100644 --- a/server/src/__tests__/issue-comment-cancel-routes.test.ts +++ b/server/src/__tests__/issue-comment-cancel-routes.test.ts @@ -20,45 +20,91 @@ const mockHeartbeatService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); - -vi.mock("@taskcore/shared/telemetry", () => ({ - trackAgentTaskCompleted: vi.fn(), - trackErrorHandlerCrash: vi.fn(), +const mockFeedbackService = vi.hoisted(() => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), })); - -vi.mock("../telemetry.js", () => ({ - getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), +const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), })); - -vi.mock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => ({ getById: vi.fn(async () => null) }), - documentService: () => ({}), - executionWorkspaceService: () => ({}), - feedbackService: () => ({ - listIssueVotesForUser: vi.fn(async () => []), - saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), - }), - goalService: () => ({}), - heartbeatService: () => mockHeartbeatService, - instanceSettingsService: () => ({ - get: vi.fn(async () => ({ - id: "instance-settings-1", - general: { - censorUsernameInLogs: false, - feedbackDataSharingPreference: "prompt", - }, - })), - listCompanyIds: vi.fn(async () => ["company-1"]), - }), - issueApprovalService: () => ({}), - issueService: () => mockIssueService, - logActivity: mockLogActivity, - projectService: () => ({}), - routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined) }), - workProductService: () => ({}), +const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), })); +function registerModuleMocks() { + vi.doMock("@taskcore/shared/telemetry", () => ({ + trackAgentTaskCompleted: vi.fn(), + trackErrorHandlerCrash: vi.fn(), + })); + + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), + })); + + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/feedback.js", () => ({ + feedbackService: () => mockFeedbackService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => ({ getById: vi.fn(async () => null) }), + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => mockFeedbackService, + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, + issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), + issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined) }), + workProductService: () => ({}), + })); +} + function createApp() { const app = express(); app.use(express.json()); @@ -67,8 +113,8 @@ function createApp() { async function installActor(app: express.Express, actor?: Record) { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/issues.js"), - import("../middleware/index.js"), + vi.importActual("../routes/issues.js"), + vi.importActual("../middleware/index.js"), ]); app.use((req, _res, next) => { @@ -116,6 +162,19 @@ function makeComment(overrides: Record = {}) { describe("issue comment cancel routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("@taskcore/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/feedback.js"); + vi.doUnmock("../services/heartbeat.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/instance-settings.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); mockIssueService.getById.mockResolvedValue(makeIssue()); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); @@ -123,6 +182,12 @@ describe("issue comment cancel routes", () => { mockIssueService.removeComment.mockResolvedValue(makeComment()); mockAccessService.canUser.mockResolvedValue(false); mockAccessService.hasPermission.mockResolvedValue(false); + mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]); + mockFeedbackService.saveIssueVote.mockResolvedValue({ + vote: null, + consentEnabledNow: false, + sharingEnabled: false, + }); mockHeartbeatService.getRun.mockResolvedValue({ id: "run-1", companyId: "company-1", @@ -132,6 +197,14 @@ describe("issue comment cancel routes", () => { createdAt: new Date("2026-04-11T14:59:00.000Z"), }); mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); + mockInstanceSettingsService.get.mockResolvedValue({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + }); + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); mockLogActivity.mockResolvedValue(undefined); }); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 82a0423..372768b 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -7,6 +7,7 @@ const mockIssueService = vi.hoisted(() => ({ assertCheckoutOwner: vi.fn(), update: vi.fn(), addComment: vi.fn(), + getDependencyReadiness: vi.fn(), findMentionedAgents: vi.fn(), listWakeableBlockedDependents: vi.fn(), getWakeableParentAfterChildCompletion: vi.fn(), @@ -56,31 +57,9 @@ const mockInstanceSettingsService = vi.hoisted(() => ({ const mockRoutineService = vi.hoisted(() => ({ syncRunStatusForIssue: vi.fn(async () => undefined), })); - -vi.mock("@taskcore/shared/telemetry", () => ({ - trackAgentTaskCompleted: vi.fn(), - trackErrorHandlerCrash: vi.fn(), -})); - -vi.mock("../telemetry.js", () => ({ - getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), -})); - -vi.mock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - documentService: () => ({}), - executionWorkspaceService: () => ({}), - feedbackService: () => mockFeedbackService, - goalService: () => ({}), - heartbeatService: () => mockHeartbeatService, - instanceSettingsService: () => mockInstanceSettingsService, - issueApprovalService: () => ({}), - issueService: () => mockIssueService, - logActivity: mockLogActivity, - projectService: () => ({}), - routineService: () => mockRoutineService, - workProductService: () => ({}), +const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), })); function registerModuleMocks() { @@ -93,6 +72,38 @@ function registerModuleMocks() { getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), })); + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/feedback.js", () => ({ + feedbackService: () => mockFeedbackService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/routines.js", () => ({ + routineService: () => mockRoutineService, + })); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, @@ -103,7 +114,21 @@ function registerModuleMocks() { heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => mockInstanceSettingsService, issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, logActivity: mockLogActivity, projectService: () => ({}), routineService: () => mockRoutineService, @@ -148,7 +173,7 @@ async function normalizePolicy(input: { return normalizeIssueExecutionPolicy(input); } -function makeIssue(status: "todo" | "done") { +function makeIssue(status: "todo" | "done" | "blocked") { return { id: "11111111-1111-4111-8111-111111111111", companyId: "company-1", @@ -164,6 +189,17 @@ function makeIssue(status: "todo" | "done") { describe("issue comment reopen routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("@taskcore/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/feedback.js"); + vi.doUnmock("../services/heartbeat.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/instance-settings.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/routines.js"); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); @@ -173,6 +209,7 @@ describe("issue comment reopen routes", () => { mockIssueService.assertCheckoutOwner.mockReset(); mockIssueService.update.mockReset(); mockIssueService.addComment.mockReset(); + mockIssueService.getDependencyReadiness.mockReset(); mockIssueService.findMentionedAgents.mockReset(); mockIssueService.listWakeableBlockedDependents.mockReset(); mockIssueService.getWakeableParentAfterChildCompletion.mockReset(); @@ -229,6 +266,14 @@ describe("issue comment reopen routes", () => { authorUserId: "local-board", }); mockIssueService.findMentionedAgents.mockResolvedValue([]); + mockIssueService.getDependencyReadiness.mockResolvedValue({ + issueId: "11111111-1111-4111-8111-111111111111", + blockerIssueIds: [], + unresolvedBlockerIssueIds: [], + unresolvedBlockerCount: 0, + allBlockersDone: true, + isDependencyReady: true, + }); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); @@ -416,6 +461,75 @@ describe("issue comment reopen routes", () => { ); }); + it("moves assigned blocked issues back to todo via POST comments", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("blocked")); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("blocked"), + ...patch, + })); + + const res = await request(await installActor(createApp())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "please continue" }); + + expect(res.status).toBe(201); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + { status: "todo" }, + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + reason: "issue_reopened_via_comment", + payload: expect.objectContaining({ + commentId: "comment-1", + reopenedFrom: "blocked", + mutation: "comment", + }), + contextSnapshot: expect.objectContaining({ + issueId: "11111111-1111-4111-8111-111111111111", + wakeCommentId: "comment-1", + wakeReason: "issue_reopened_via_comment", + reopenedFrom: "blocked", + }), + }), + ); + }); + + it("does not move dependency-blocked issues to todo via POST comments", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("blocked")); + mockIssueService.getDependencyReadiness.mockResolvedValue({ + issueId: "11111111-1111-4111-8111-111111111111", + blockerIssueIds: ["33333333-3333-4333-8333-333333333333"], + unresolvedBlockerIssueIds: ["33333333-3333-4333-8333-333333333333"], + unresolvedBlockerCount: 1, + allBlockersDone: false, + isDependencyReady: false, + }); + + const res = await request(await installActor(createApp())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "what is happening?" }); + + expect(res.status).toBe(201); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + reason: "issue_commented", + payload: expect.objectContaining({ + commentId: "comment-1", + mutation: "comment", + }), + contextSnapshot: expect.objectContaining({ + issueId: "11111111-1111-4111-8111-111111111111", + wakeCommentId: "comment-1", + wakeReason: "issue_commented", + }), + }), + ); + }); + it("does not implicitly reopen closed issues via POST comments when no agent is assigned", async () => { mockIssueService.getById.mockResolvedValue({ ...makeIssue("done"), @@ -430,6 +544,110 @@ describe("issue comment reopen routes", () => { expect(res.status).toBe(201); expect(mockIssueService.update).not.toHaveBeenCalled(); }); + + it("moves assigned blocked issues back to todo via the PATCH comment path", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("blocked")); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("blocked"), + ...patch, + })); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "please continue" }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + status: "todo", + actorAgentId: null, + actorUserId: "local-board", + }), + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + reason: "issue_reopened_via_comment", + payload: expect.objectContaining({ + commentId: "comment-1", + reopenedFrom: "blocked", + mutation: "comment", + }), + }), + ); + }); + + it("does not move dependency-blocked issues to todo via the PATCH comment path", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("blocked")); + mockIssueService.getDependencyReadiness.mockResolvedValue({ + issueId: "11111111-1111-4111-8111-111111111111", + blockerIssueIds: ["33333333-3333-4333-8333-333333333333"], + unresolvedBlockerIssueIds: ["33333333-3333-4333-8333-333333333333"], + unresolvedBlockerCount: 1, + allBlockersDone: false, + isDependencyReady: false, + }); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("blocked"), + ...patch, + })); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "what is happening?" }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + actorAgentId: null, + actorUserId: "local-board", + }), + ); + expect(mockIssueService.update).not.toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ status: "todo" }), + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + reason: "issue_commented", + payload: expect.objectContaining({ + commentId: "comment-1", + mutation: "comment", + }), + }), + ); + }); + + it("wakes the assignee when an assigned blocked issue moves back to todo", async () => { + const issue = makeIssue("blocked"); + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + updatedAt: new Date(), + })); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ status: "todo" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + source: "automation", + triggerDetail: "system", + reason: "issue_status_changed", + payload: expect.objectContaining({ + issueId: "11111111-1111-4111-8111-111111111111", + mutation: "update", + }), + }), + ); + }); it("interrupts an active run before a combined comment update", async () => { const issue = { ...makeIssue("todo"), @@ -580,26 +798,20 @@ describe("issue comment reopen routes", () => { }); expect(res.status).toBe(200); - expect(mockIssueService.update).toHaveBeenCalledWith( - "11111111-1111-4111-8111-111111111111", - expect.objectContaining({ - status: "in_review", - assigneeAgentId: "33333333-3333-4333-8333-333333333333", - assigneeUserId: null, - executionState: expect.objectContaining({ - status: "pending", - currentStageType: "review", - currentParticipant: expect.objectContaining({ - type: "agent", - agentId: "33333333-3333-4333-8333-333333333333", - }), - returnAssignee: expect.objectContaining({ - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }), - }), - }), - ); + expect(res.body.assigneeAgentId).toBe("33333333-3333-4333-8333-333333333333"); + expect(res.body.assigneeUserId).toBeNull(); + expect(res.body.executionState).toMatchObject({ + status: "pending", + currentStageType: "review", + currentParticipant: { + type: "agent", + agentId: "33333333-3333-4333-8333-333333333333", + }, + returnAssignee: { + type: "agent", + agentId: "22222222-2222-4222-8222-222222222222", + }, + }); expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "33333333-3333-4333-8333-333333333333", expect.objectContaining({ diff --git a/server/src/__tests__/issue-continuation-summary.test.ts b/server/src/__tests__/issue-continuation-summary.test.ts new file mode 100644 index 0000000..c5ccd0a --- /dev/null +++ b/server/src/__tests__/issue-continuation-summary.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS, + buildContinuationSummaryMarkdown, +} from "../services/issue-continuation-summary.js"; + +describe("issue continuation summaries", () => { + it("builds bounded issue-local handoff context with required sections", () => { + const body = buildContinuationSummaryMarkdown({ + issue: { + id: "issue-1", + identifier: "PAP-1579", + title: "Add continuation summaries", + description: [ + "## Objective", + "", + "Keep work resumable after adapter session reset.", + "", + "## Acceptance Criteria", + "", + "- Summary is issue-local", + "- Wake context includes the summary", + ].join("\n"), + status: "in_progress", + priority: "medium", + }, + run: { + id: "run-1", + status: "succeeded", + error: null, + resultJson: { + summary: "Updated server/src/services/heartbeat.ts and packages/adapter-utils/src/server-utils.ts.", + }, + stdoutExcerpt: null, + stderrExcerpt: null, + finishedAt: new Date("2026-04-18T12:00:00.000Z"), + }, + agent: { + id: "agent-1", + name: "CodexCoder", + adapterType: "codex_local", + }, + }); + + expect(body).toContain("# Continuation Summary"); + expect(body).toContain("## Objective"); + expect(body).toContain("Keep work resumable after adapter session reset."); + expect(body).toContain("## Acceptance Criteria"); + expect(body).toContain("- Summary is issue-local"); + expect(body).toContain("## Recent Concrete Actions"); + expect(body).toContain("Run `run-1` finished with status `succeeded`"); + expect(body).toContain("`server/src/services/heartbeat.ts`"); + expect(body).toContain("## Commands Run"); + expect(body).toContain("## Blockers / Decisions"); + expect(body).toContain("## Next Action"); + expect(body.length).toBeLessThanOrEqual(ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS); + }); + + it("uses failure state to point the next run at the error", () => { + const body = buildContinuationSummaryMarkdown({ + issue: { + id: "issue-1", + identifier: "PAP-1579", + title: "Add continuation summaries", + description: null, + status: "in_progress", + priority: "medium", + }, + run: { + id: "run-2", + status: "failed", + error: "adapter failed", + errorCode: "adapter_failed", + resultJson: null, + }, + agent: { + id: "agent-1", + name: "CodexCoder", + adapterType: "codex_local", + }, + }); + + expect(body).toContain("Latest run error (adapter_failed): adapter failed"); + expect(body).toContain("Inspect the failed run, fix the cause"); + }); +}); diff --git a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts index f3ea89e..d026632 100644 --- a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts +++ b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts @@ -39,11 +39,25 @@ vi.mock("../services/index.js", () => ({ wakeup: mockWakeup, reportRunActivity: vi.fn(async () => undefined), }), + getIssueContinuationSummaryDocument: vi.fn(async () => null), instanceSettingsService: () => ({ get: vi.fn(), listCompanyIds: vi.fn(), }), issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), issueService: () => mockIssueService, logActivity: vi.fn(async () => undefined), projectService: () => ({ @@ -197,6 +211,31 @@ describe("issue dependency wakeups in issue routes", () => { id: "parent-1", assigneeAgentId: "agent-9", childIssueIds: ["child-0", "child-1"], + childIssueSummaries: [ + { + id: "child-0", + identifier: "PAP-100", + title: "First child", + status: "done", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + updatedAt: new Date("2026-04-18T12:00:00.000Z"), + summary: "First child finished.", + }, + { + id: "child-1", + identifier: "PAP-101", + title: "Last child", + status: "done", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + updatedAt: new Date("2026-04-18T12:05:00.000Z"), + summary: "Last child finished.", + }, + ], + childIssueSummaryTruncated: false, }); const res = await request(await createApp()).patch("/api/issues/child-1").send({ status: "done" }); @@ -209,6 +248,14 @@ describe("issue dependency wakeups in issue routes", () => { payload: expect.objectContaining({ issueId: "parent-1", completedChildIssueId: "child-1", + childIssueSummaries: expect.arrayContaining([ + expect.objectContaining({ identifier: "PAP-101", summary: "Last child finished." }), + ]), + }), + contextSnapshot: expect.objectContaining({ + childIssueSummaries: expect.arrayContaining([ + expect.objectContaining({ identifier: "PAP-100", summary: "First child finished." }), + ]), }), }), ); diff --git a/server/src/__tests__/issue-document-restore-routes.test.ts b/server/src/__tests__/issue-document-restore-routes.test.ts index 048cc97..8d350de 100644 --- a/server/src/__tests__/issue-document-restore-routes.test.ts +++ b/server/src/__tests__/issue-document-restore-routes.test.ts @@ -10,6 +10,7 @@ const mockIssueService = vi.hoisted(() => ({ })); const mockDocumentsService = vi.hoisted(() => ({ + listIssueDocuments: vi.fn(), listIssueDocumentRevisions: vi.fn(), restoreIssueDocumentRevision: vi.fn(), })); @@ -24,33 +25,88 @@ const mockAgentService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); - -vi.mock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - documentService: () => mockDocumentsService, - executionWorkspaceService: () => ({}), - feedbackService: () => ({}), - goalService: () => ({}), - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - }), - instanceSettingsService: () => ({ - getExperimental: vi.fn(async () => ({})), - getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })), - }), - issueApprovalService: () => ({}), - issueService: () => mockIssueService, - logActivity: mockLogActivity, - projectService: () => ({}), - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({}), +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), +})); +const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + getExperimental: vi.fn(async () => ({})), + getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })), + listCompanyIds: vi.fn(async () => [companyId]), +})); +const mockRoutineService = vi.hoisted(() => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), })); +const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), +})); + +const planDocument = { + id: "document-1", + companyId, + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "# Plan", + latestRevisionId: "revision-2", + latestRevisionNumber: 2, + createdByAgentId: null, + createdByUserId: "board-user", + updatedByAgentId: null, + updatedByUserId: "board-user", + createdAt: new Date("2026-03-26T12:00:00.000Z"), + updatedAt: new Date("2026-03-26T12:10:00.000Z"), +}; + +const systemDocument = { + ...planDocument, + id: "document-2", + key: "system-plan", + title: "System plan", +}; function registerModuleMocks() { + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/documents.js", () => ({ + documentService: () => mockDocumentsService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/routines.js", () => ({ + routineService: () => mockRoutineService, + })); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, @@ -58,21 +114,27 @@ function registerModuleMocks() { executionWorkspaceService: () => ({}), feedbackService: () => ({}), goalService: () => ({}), - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - }), - instanceSettingsService: () => ({ - getExperimental: vi.fn(async () => ({})), - getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })), - }), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, logActivity: mockLogActivity, projectService: () => ({}), - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), + routineService: () => mockRoutineService, workProductService: () => ({}), })); } @@ -102,8 +164,17 @@ async function createApp() { describe("issue document revision routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/documents.js"); + vi.doUnmock("../services/heartbeat.js"); vi.doUnmock("../services/routines.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/instance-settings.js"); + vi.doUnmock("../services/issues.js"); vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.resetAllMocks(); @@ -114,6 +185,10 @@ describe("issue document revision routes", () => { title: "Document revisions", status: "in_progress", }); + mockDocumentsService.listIssueDocuments.mockImplementation( + async (_issueId, options: { includeSystem?: boolean } | undefined) => + options?.includeSystem ? [planDocument, systemDocument] : [planDocument], + ); mockDocumentsService.listIssueDocumentRevisions.mockResolvedValue([ { id: "revision-2", @@ -152,6 +227,19 @@ describe("issue document revision routes", () => { updatedAt: new Date("2026-03-26T12:10:00.000Z"), }, }); + mockHeartbeatService.wakeup.mockResolvedValue(undefined); + mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined); + mockInstanceSettingsService.get.mockResolvedValue({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + }); + mockInstanceSettingsService.getExperimental.mockResolvedValue({}); + mockInstanceSettingsService.getGeneral.mockResolvedValue({ feedbackDataSharingPreference: "prompt" }); + mockInstanceSettingsService.listCompanyIds.mockResolvedValue([companyId]); + mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined); mockLogActivity.mockResolvedValue(undefined); }); @@ -169,6 +257,25 @@ describe("issue document revision routes", () => { ]); }); + it("filters system documents by default on the document list route", async () => { + const res = await request(await createApp()).get(`/api/issues/${issueId}/documents`); + + expect(res.status).toBe(200); + expect(res.body).toEqual([expect.objectContaining({ key: "plan" })]); + }); + + it("passes includeSystem=true through for debug document listing", async () => { + const res = await request(await createApp()).get( + `/api/issues/${issueId}/documents?includeSystem=true`, + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + expect.objectContaining({ key: "plan" }), + expect.objectContaining({ key: "system-plan" }), + ]); + }); + it("restores a revision through the append-only route and logs the action", async () => { const res = await request(await createApp()) .post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`) diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index d3c2797..60f1d6e 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -50,6 +50,19 @@ function registerModuleMocks() { listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), issueService: () => mockIssueService, logActivity: vi.fn(async () => undefined), projectService: () => ({}), diff --git a/server/src/__tests__/issue-feedback-routes.test.ts b/server/src/__tests__/issue-feedback-routes.test.ts index 44b12e6..6ad6886 100644 --- a/server/src/__tests__/issue-feedback-routes.test.ts +++ b/server/src/__tests__/issue-feedback-routes.test.ts @@ -49,6 +49,10 @@ const mockRoutineService = vi.hoisted(() => ({ syncRunStatusForIssue: vi.fn(async () => undefined), })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); +const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), +})); function registerModuleMocks() { vi.doMock("@taskcore/shared/telemetry", () => ({ @@ -70,7 +74,21 @@ function registerModuleMocks() { heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => mockInstanceSettingsService, issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, logActivity: mockLogActivity, projectService: () => ({}), routineService: () => mockRoutineService, @@ -180,7 +198,6 @@ describe("issue feedback trace routes", () => { }); const res = await request(app).get("/api/feedback-traces/trace-1"); - expect(res.status).toBe(403); expect(mockFeedbackService.getFeedbackTraceById).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/issue-liveness.test.ts b/server/src/__tests__/issue-liveness.test.ts new file mode 100644 index 0000000..c675254 --- /dev/null +++ b/server/src/__tests__/issue-liveness.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { classifyIssueGraphLiveness } from "../services/issue-liveness.ts"; + +const companyId = "company-1"; +const managerId = "manager-1"; +const coderId = "coder-1"; +const blockerId = "blocker-1"; +const blockedId = "blocked-1"; + +function issue(overrides: Record = {}) { + return { + id: blockedId, + companyId, + identifier: "PAP-1703", + title: "Parent work", + status: "blocked", + assigneeAgentId: coderId, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + executionState: null, + ...overrides, + }; +} + +function agent(overrides: Record = {}) { + return { + id: coderId, + companyId, + name: "Coder", + role: "engineer", + title: null, + status: "idle", + reportsTo: managerId, + ...overrides, + }; +} + +const manager = agent({ + id: managerId, + name: "CTO", + role: "cto", + reportsTo: null, +}); + +const blocks = [{ companyId, blockerIssueId: blockerId, blockedIssueId: blockedId }]; + +describe("issue graph liveness classifier", () => { + it("detects a PAP-1703-style blocked chain with an unassigned blocker and stable incident key", () => { + const findings = classifyIssueGraphLiveness({ + issues: [ + issue(), + issue({ + id: blockerId, + identifier: "PAP-1704", + title: "Missing unblock work", + status: "todo", + assigneeAgentId: null, + }), + ], + relations: blocks, + agents: [agent(), manager], + }); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + issueId: blockedId, + identifier: "PAP-1703", + state: "blocked_by_unassigned_issue", + recommendedOwnerAgentId: managerId, + dependencyPath: [ + expect.objectContaining({ issueId: blockedId }), + expect.objectContaining({ issueId: blockerId }), + ], + incidentKey: `harness_liveness:${companyId}:${blockedId}:blocked_by_unassigned_issue:${blockerId}`, + }); + }); + + it("does not flag a live blocked chain with an active assignee and wake path", () => { + const findings = classifyIssueGraphLiveness({ + issues: [ + issue(), + issue({ + id: blockerId, + identifier: "PAP-1704", + title: "Live unblock work", + status: "todo", + assigneeAgentId: "blocker-agent", + }), + ], + relations: blocks, + agents: [ + agent(), + manager, + agent({ id: "blocker-agent", name: "Blocker Agent", reportsTo: managerId }), + ], + queuedWakeRequests: [{ companyId, issueId: blockerId, agentId: "blocker-agent", status: "queued" }], + }); + + expect(findings).toEqual([]); + }); + + it("does not flag an unassigned blocker that already has an active execution path", () => { + const findings = classifyIssueGraphLiveness({ + issues: [ + issue(), + issue({ + id: blockerId, + identifier: "PAP-1704", + title: "Unassigned but already running", + status: "todo", + assigneeAgentId: null, + }), + ], + relations: blocks, + agents: [agent(), manager], + activeRuns: [{ companyId, issueId: blockerId, agentId: coderId, status: "running" }], + }); + + expect(findings).toEqual([]); + }); + + it("detects cancelled blockers and uninvokable blocker assignees deterministically", () => { + const cancelled = classifyIssueGraphLiveness({ + issues: [ + issue(), + issue({ + id: blockerId, + identifier: "PAP-1704", + title: "Cancelled unblock work", + status: "cancelled", + assigneeAgentId: "blocker-agent", + }), + ], + relations: blocks, + agents: [agent(), manager, agent({ id: "blocker-agent", name: "Paused", status: "paused" })], + }); + expect(cancelled[0]?.state).toBe("blocked_by_cancelled_issue"); + + const paused = classifyIssueGraphLiveness({ + issues: [ + issue(), + issue({ + id: blockerId, + identifier: "PAP-1704", + title: "Paused unblock work", + status: "todo", + assigneeAgentId: "blocker-agent", + }), + ], + relations: blocks, + agents: [agent(), manager, agent({ id: "blocker-agent", name: "Paused", status: "paused" })], + }); + expect(paused[0]?.state).toBe("blocked_by_uninvokable_assignee"); + }); + + it("detects invalid in_review execution participant", () => { + const findings = classifyIssueGraphLiveness({ + issues: [ + issue({ + status: "in_review", + executionState: { + status: "pending", + currentStageId: "stage-1", + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: "missing-agent" }, + returnAssignee: { type: "agent", agentId: coderId }, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, + }), + ], + relations: [], + agents: [agent(), manager], + }); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + state: "invalid_review_participant", + incidentKey: `harness_liveness:${companyId}:${blockedId}:invalid_review_participant:missing-agent`, + }); + }); +}); diff --git a/server/src/__tests__/issue-references-service.test.ts b/server/src/__tests__/issue-references-service.test.ts new file mode 100644 index 0000000..cbb3d50 --- /dev/null +++ b/server/src/__tests__/issue-references-service.test.ts @@ -0,0 +1,244 @@ +import { randomUUID } from "node:crypto"; +import { sql } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + companies, + createDb, + documents, + issueComments, + issueDocuments, + issueReferenceMentions, + issues, +} from "@taskcore/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { issueReferenceService } from "../services/issue-references.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +async function ensureIssueReferenceMentionsTable(db: ReturnType) { + await db.execute(sql.raw(` + CREATE TABLE IF NOT EXISTS "issue_reference_mentions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "company_id" uuid NOT NULL, + "source_issue_id" uuid NOT NULL REFERENCES "issues"("id") ON DELETE CASCADE, + "target_issue_id" uuid NOT NULL REFERENCES "issues"("id") ON DELETE CASCADE, + "source_kind" text NOT NULL, + "source_record_id" uuid, + "document_key" text, + "matched_text" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS "issue_reference_mentions_company_source_issue_idx" + ON "issue_reference_mentions" ("company_id", "source_issue_id"); + CREATE INDEX IF NOT EXISTS "issue_reference_mentions_company_target_issue_idx" + ON "issue_reference_mentions" ("company_id", "target_issue_id"); + CREATE INDEX IF NOT EXISTS "issue_reference_mentions_company_issue_pair_idx" + ON "issue_reference_mentions" ("company_id", "source_issue_id", "target_issue_id"); + CREATE UNIQUE INDEX IF NOT EXISTS "issue_reference_mentions_company_source_mention_uq" + ON "issue_reference_mentions" ("company_id", "source_issue_id", "target_issue_id", "source_kind", "source_record_id"); + `)); +} + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres issue reference tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("issueReferenceService", () => { + let db!: ReturnType; + let refs!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-issue-refs-"); + db = createDb(tempDb.connectionString); + refs = issueReferenceService(db); + await ensureIssueReferenceMentionsTable(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueReferenceMentions); + await db.delete(issueComments); + await db.delete(issueDocuments); + await db.delete(documents); + await db.delete(issues); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("tracks outbound and inbound references across issue fields, comments, and documents", async () => { + const companyId = randomUUID(); + const sourceIssueId = randomUUID(); + const targetTwoId = randomUUID(); + const targetThreeId = randomUUID(); + const inboundIssueId = randomUUID(); + const commentId = randomUUID(); + const documentId = randomUUID(); + const issueDocumentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `R${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values([ + { + id: sourceIssueId, + companyId, + title: "Coordinate PAP-2", + description: "Review /issues/pap-3 and ignore PAP-1 self references.", + status: "todo", + priority: "medium", + identifier: "PAP-1", + }, + { + id: targetTwoId, + companyId, + title: "Target two", + status: "todo", + priority: "medium", + identifier: "PAP-2", + }, + { + id: targetThreeId, + companyId, + title: "Target three", + status: "todo", + priority: "medium", + identifier: "PAP-3", + }, + { + id: inboundIssueId, + companyId, + title: "Inbound reference", + description: "This one depends on PAP-1.", + status: "in_progress", + priority: "high", + identifier: "PAP-4", + }, + ]); + + await refs.syncIssue(sourceIssueId); + await refs.syncIssue(inboundIssueId); + + await db.insert(issueComments).values({ + id: commentId, + companyId, + issueId: sourceIssueId, + body: "Follow up in https://taskcore.test/issues/pap-2 after the document lands.", + }); + await refs.syncComment(commentId); + + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Plan", + format: "markdown", + latestBody: "Spec note: /PAP/issues/PAP-3", + latestRevisionNumber: 1, + }); + await db.insert(issueDocuments).values({ + id: issueDocumentId, + companyId, + issueId: sourceIssueId, + documentId, + key: "plan", + }); + await refs.syncDocument(documentId); + + const summary = await refs.listIssueReferenceSummary(sourceIssueId); + + expect(summary.outbound.map((item) => item.issue.identifier)).toEqual(["PAP-2", "PAP-3"]); + expect(summary.outbound[0]?.mentionCount).toBe(2); + expect(summary.outbound[0]?.sources.map((source) => source.label)).toEqual(["title", "comment"]); + expect(summary.outbound[1]?.mentionCount).toBe(2); + expect(summary.outbound[1]?.sources.map((source) => source.label)).toEqual(["description", "plan"]); + expect(summary.inbound.map((item) => item.issue.identifier)).toEqual(["PAP-4"]); + + await refs.deleteDocumentSource(documentId); + + const withoutDocument = await refs.listIssueReferenceSummary(sourceIssueId); + const pap3 = withoutDocument.outbound.find((item) => item.issue.identifier === "PAP-3"); + + expect(pap3?.mentionCount).toBe(1); + expect(pap3?.sources.map((source) => source.label)).toEqual(["description"]); + }); + + it("backfills existing references for a company without requiring write-time sync", async () => { + const companyId = randomUUID(); + const sourceIssueId = randomUUID(); + const targetIssueId = randomUUID(); + const commentId = randomUUID(); + const documentId = randomUUID(); + const issueDocumentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore Backfill", + issuePrefix: `B${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values([ + { + id: sourceIssueId, + companyId, + title: "Legacy issue", + status: "todo", + priority: "medium", + identifier: "PAP-10", + }, + { + id: targetIssueId, + companyId, + title: "Referenced legacy issue", + status: "todo", + priority: "medium", + identifier: "PAP-20", + }, + ]); + + await db.insert(issueComments).values({ + id: commentId, + companyId, + issueId: sourceIssueId, + body: "Legacy comment points at PAP-20.", + }); + + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Legacy plan", + format: "markdown", + latestBody: "Legacy plan also links /issues/PAP-20.", + latestRevisionNumber: 1, + }); + await db.insert(issueDocuments).values({ + id: issueDocumentId, + companyId, + issueId: sourceIssueId, + documentId, + key: "plan", + }); + + await refs.syncAllForCompany(companyId); + + const summary = await refs.listIssueReferenceSummary(sourceIssueId); + + expect(summary.outbound).toHaveLength(1); + expect(summary.outbound[0]?.issue.identifier).toBe("PAP-20"); + expect(summary.outbound[0]?.mentionCount).toBe(2); + expect(summary.outbound[0]?.sources.map((source) => source.label)).toEqual(["plan", "comment"]); + }); +}); diff --git a/server/src/__tests__/issue-telemetry-routes.test.ts b/server/src/__tests__/issue-telemetry-routes.test.ts index 4c07aed..363f19e 100644 --- a/server/src/__tests__/issue-telemetry-routes.test.ts +++ b/server/src/__tests__/issue-telemetry-routes.test.ts @@ -42,6 +42,19 @@ function registerModuleMocks() { }), instanceSettingsService: () => ({}), issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), issueService: () => mockIssueService, logActivity: vi.fn(async () => undefined), projectService: () => ({}), diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts new file mode 100644 index 0000000..5d05b34 --- /dev/null +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -0,0 +1,584 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111"; +const CREATED_AGENT_ID = "22222222-2222-4222-8222-222222222222"; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockInteractionService = vi.hoisted(() => ({ + listForIssue: vi.fn(), + create: vi.fn(), + acceptInteraction: vi.fn(), + acceptSuggestedTasks: vi.fn(), + rejectInteraction: vi.fn(), + rejectSuggestedTasks: vi.fn(), + answerQuestions: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); + +vi.mock("@taskcore/shared/telemetry", () => ({ + trackAgentTaskCompleted: vi.fn(), + trackErrorHandlerCrash: vi.fn(), +})); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), +})); + +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(async () => true), + hasPermission: vi.fn(async () => true), + }), + agentService: () => ({ + getById: vi.fn(async () => null), + resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({ + ambiguous: false, + agent: { id: raw }, + })), + }), + clampIssueListLimit: (value: number) => value, + ISSUE_LIST_DEFAULT_LIMIT: 500, + ISSUE_LIST_MAX_LIMIT: 1000, + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), + issueService: () => mockIssueService, + issueThreadInteractionService: () => mockInteractionService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), + })); +} + +function createIssue(overrides: Record = {}) { + return { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + companyId: "company-1", + status: "in_progress", + priority: "medium", + projectId: null, + goalId: null, + parentId: null, + assigneeAgentId: ASSIGNEE_AGENT_ID, + assigneeUserId: null, + createdByUserId: "local-board", + identifier: "PAP-1714", + title: "Persist interactions", + executionPolicy: null, + executionState: null, + hiddenAt: null, + ...overrides, + }; +} + +async function createApp(actor: Record = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, +}) { + const [{ issueRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/issues.js"), + import("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +describe("issue thread interaction routes", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + vi.doUnmock("../services/index.js"); + registerModuleMocks(); + vi.resetAllMocks(); + mockIssueService.getById.mockResolvedValue(createIssue()); + mockInteractionService.listForIssue.mockResolvedValue([]); + mockInteractionService.create.mockResolvedValue({ + id: "interaction-1", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "suggest_tasks", + status: "pending", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-1", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + result: null, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:00:00.000Z", + }); + mockInteractionService.acceptInteraction.mockResolvedValue({ + interaction: { + id: "interaction-1", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "suggest_tasks", + status: "accepted", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: "comment-1", + sourceRunId: "run-1", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + result: { + version: 1, + createdTasks: [{ clientKey: "task-1", issueId: "child-1" }], + skippedClientKeys: ["task-2"], + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }, + createdIssues: [ + { + id: "child-1", + assigneeAgentId: CREATED_AGENT_ID, + status: "todo", + }, + ], + }); + mockInteractionService.rejectInteraction.mockResolvedValue({ + id: "interaction-1", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "suggest_tasks", + status: "rejected", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: "comment-1", + sourceRunId: "run-1", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + result: { + version: 1, + rejectionReason: "Not actionable enough", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }); + mockInteractionService.answerQuestions.mockResolvedValue({ + id: "interaction-2", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "ask_user_questions", + status: "answered", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: "comment-2", + sourceRunId: "run-2", + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Scope?", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + result: { + version: 1, + answers: [{ questionId: "scope", optionIds: ["phase-1"] }], + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:06:00.000Z", + resolvedAt: "2026-04-20T12:06:00.000Z", + }); + }); + + it("lists and creates board-authored interactions", async () => { + mockInteractionService.listForIssue.mockResolvedValue([ + { id: "interaction-1", kind: "suggest_tasks", status: "pending" }, + ]); + const app = await createApp(); + + const listRes = await request(app).get("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions"); + expect(listRes.status).toBe(200); + expect(listRes.body).toEqual([ + { id: "interaction-1", kind: "suggest_tasks", status: "pending" }, + ]); + + const createRes = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions") + .send({ + kind: "suggest_tasks", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + }); + + expect(createRes.status).toBe(201); + expect(mockInteractionService.create).toHaveBeenCalled(); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.thread_interaction_created", + details: expect.objectContaining({ + interactionId: "interaction-1", + interactionKind: "suggest_tasks", + }), + }), + ); + }); + + it("accepts suggested tasks and wakes created assignees plus the current assignee", async () => { + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-1/accept") + .send({ selectedClientKeys: ["task-1"] }); + + expect(res.status).toBe(200); + expect(mockInteractionService.acceptInteraction).toHaveBeenCalledWith( + expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }), + "interaction-1", + { selectedClientKeys: ["task-1"] }, + expect.objectContaining({ userId: "local-board" }), + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(2); + expect(mockHeartbeatService.wakeup).toHaveBeenNthCalledWith( + 1, + CREATED_AGENT_ID, + expect.objectContaining({ + source: "assignment", + reason: "issue_assigned", + payload: expect.objectContaining({ + issueId: "child-1", + mutation: "interaction_accept", + }), + }), + ); + expect(mockHeartbeatService.wakeup).toHaveBeenNthCalledWith( + 2, + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + source: "automation", + reason: "issue_commented", + payload: expect.objectContaining({ + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + interactionId: "interaction-1", + interactionStatus: "accepted", + sourceCommentId: "comment-1", + sourceRunId: "run-1", + }), + }), + ); + }); + + it("answers questions and emits a continuation wake", async () => { + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond") + .send({ + answers: [{ questionId: "scope", optionIds: ["phase-1"] }], + }); + + expect(res.status).toBe(200); + expect(mockInteractionService.answerQuestions).toHaveBeenCalled(); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + reason: "issue_commented", + payload: expect.objectContaining({ + interactionId: "interaction-2", + interactionKind: "ask_user_questions", + interactionStatus: "answered", + sourceCommentId: "comment-2", + sourceRunId: "run-2", + }), + }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.thread_interaction_answered", + }), + ); + }); + + it("accepts request confirmations and wakes the current assignee when configured for accept-only wakeups", async () => { + mockInteractionService.acceptInteraction.mockResolvedValueOnce({ + interaction: { + id: "interaction-3", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee_on_accept", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-3", + payload: { + version: 1, + prompt: "Apply this plan?", + }, + result: { + version: 1, + outcome: "accepted", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }, + createdIssues: [], + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-3/accept") + .send({}); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + reason: "issue_commented", + payload: expect.objectContaining({ + interactionId: "interaction-3", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + }), + }), + ); + }); + + it("wakes the returned agent when accepting an agent-authored confirmation from a board review assignee", async () => { + mockIssueService.getById.mockResolvedValueOnce(createIssue({ + status: "in_review", + assigneeAgentId: null, + assigneeUserId: "local-board", + })); + mockInteractionService.acceptInteraction.mockResolvedValueOnce({ + interaction: { + id: "interaction-4", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee_on_accept", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-4", + payload: { + version: 1, + prompt: "Approve this plan?", + }, + result: { + version: 1, + outcome: "accepted", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }, + createdIssues: [], + continuationIssue: { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + assigneeAgentId: CREATED_AGENT_ID, + assigneeUserId: null, + status: "todo", + }, + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-4/accept") + .send({}); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + CREATED_AGENT_ID, + expect.objectContaining({ + source: "automation", + reason: "issue_commented", + payload: expect.objectContaining({ + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + interactionId: "interaction-4", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + }), + }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.updated", + details: expect.objectContaining({ + source: "request_confirmation_accept", + assigneeAgentId: CREATED_AGENT_ID, + assigneeUserId: null, + _previous: expect.objectContaining({ + assigneeUserId: "local-board", + }), + }), + }), + ); + }); + + it("does not emit a continuation wake when request confirmations are rejected", async () => { + mockInteractionService.rejectInteraction.mockResolvedValueOnce({ + id: "interaction-3", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_confirmation", + status: "rejected", + continuationPolicy: "wake_assignee_on_accept", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-3", + payload: { + version: 1, + prompt: "Apply this plan?", + }, + result: { + version: 1, + outcome: "rejected", + reason: "Needs changes", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-3/reject") + .send({ reason: "Needs changes" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + + it("does not emit an accept-only continuation wake for rejected suggested tasks", async () => { + mockInteractionService.rejectInteraction.mockResolvedValueOnce({ + id: "interaction-5", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "suggest_tasks", + status: "rejected", + continuationPolicy: "wake_assignee_on_accept", + idempotencyKey: null, + sourceCommentId: null, + sourceRunId: "run-5", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + result: { + version: 1, + rejectionReason: "Not now", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-5/reject") + .send({ reason: "Not now" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + + it("allows agent-authored interaction creation and stamps the active run id", async () => { + const app = await createApp({ + type: "agent", + agentId: CREATED_AGENT_ID, + companyId: "company-1", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions") + .send({ + kind: "suggest_tasks", + idempotencyKey: "interaction:task-1", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + }); + + expect(res.status).toBe(201); + expect(mockInteractionService.create).toHaveBeenCalledWith( + expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }), + expect.objectContaining({ + kind: "suggest_tasks", + idempotencyKey: "interaction:task-1", + sourceRunId: "run-1", + }), + { + agentId: CREATED_AGENT_ID, + userId: null, + }, + ); + }); +}); diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts new file mode 100644 index 0000000..8e80b4c --- /dev/null +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -0,0 +1,881 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + createDb, + documentRevisions, + documents, + goals, + heartbeatRuns, + issueDocuments, + instanceSettings, + issueRelations, + issueThreadInteractions, + issues, +} from "@taskcore/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; +import { issueService } from "../services/issues.js"; +import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("issueThreadInteractionService", () => { + let db!: ReturnType; + let issuesSvc!: ReturnType; + let interactionsSvc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-issue-thread-interactions-"); + db = createDb(tempDb.connectionString); + issuesSvc = issueService(db); + interactionsSvc = issueThreadInteractionService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueThreadInteractions); + await db.delete(issueDocuments); + await db.delete(documentRevisions); + await db.delete(documents); + await db.delete(issueRelations); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(goals); + await db.delete(agents); + await db.delete(instanceSettings); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("accepts suggested tasks by creating a rooted issue tree under the current issue", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + const assigneeAgentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); + + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Persist thread interactions", + level: "task", + status: "active", + }); + await db.insert(agents).values({ + id: assigneeAgentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + requestDepth: 2, + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "suggest_tasks", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + tasks: [ + { + clientKey: "root", + title: "Create the root follow-up", + assigneeAgentId, + }, + { + clientKey: "child", + parentClientKey: "root", + title: "Create the nested follow-up", + }, + ], + }, + }, { + userId: "local-board", + }); + + expect(created.status).toBe("pending"); + + const accepted = await interactionsSvc.acceptSuggestedTasks({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, {}, { + userId: "local-board", + }); + + expect(accepted.interaction.kind).toBe("suggest_tasks"); + expect(accepted.interaction.status).toBe("accepted"); + expect(accepted.interaction.result).toMatchObject({ + version: 1, + createdTasks: [ + expect.objectContaining({ clientKey: "root", parentIssueId: issueId }), + expect.objectContaining({ clientKey: "child" }), + ], + }); + expect(accepted.createdIssues).toEqual([ + expect.objectContaining({ + assigneeAgentId, + status: "todo", + }), + expect.objectContaining({ + assigneeAgentId: null, + status: "todo", + }), + ]); + + const children = await issuesSvc.list(companyId, { parentId: issueId }); + expect(children).toHaveLength(1); + expect(children[0]?.title).toBe("Create the root follow-up"); + + const nestedChildren = await issuesSvc.list(companyId, { parentId: children[0]!.id }); + expect(nestedChildren).toHaveLength(1); + expect(nestedChildren[0]?.title).toBe("Create the nested follow-up"); + expect(nestedChildren[0]?.requestDepth).toBe(4); + + const listed = await interactionsSvc.listForIssue(issueId); + expect(listed).toHaveLength(1); + expect(listed[0]?.status).toBe("accepted"); + + await expect(interactionsSvc.acceptSuggestedTasks({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, {}, { + userId: "local-board", + })).rejects.toThrow("Interaction has already been resolved"); + + const childrenAfterDuplicateAccept = await issuesSvc.list(companyId, { parentId: issueId }); + expect(childrenAfterDuplicateAccept).toHaveLength(1); + }); + + it("accepts a selected subset of suggested tasks and records the skipped drafts", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); + + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Selectively persist thread interactions", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + requestDepth: 2, + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "suggest_tasks", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + tasks: [ + { + clientKey: "root", + title: "Create the root follow-up", + }, + { + clientKey: "child", + parentClientKey: "root", + title: "Create the nested follow-up", + }, + { + clientKey: "sibling", + title: "Create the sibling follow-up", + }, + ], + }, + }, { + userId: "local-board", + }); + + const accepted = await interactionsSvc.acceptSuggestedTasks({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, { + selectedClientKeys: ["root"], + }, { + userId: "local-board", + }); + + expect(accepted.interaction.result).toMatchObject({ + version: 1, + createdTasks: [ + expect.objectContaining({ clientKey: "root", parentIssueId: issueId }), + ], + skippedClientKeys: ["child", "sibling"], + }); + + const children = await issuesSvc.list(companyId, { parentId: issueId }); + expect(children).toHaveLength(1); + expect(children[0]?.title).toBe("Create the root follow-up"); + }); + + it("rejects partial acceptance when a selected task omits its selected-tree parent", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); + + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Validate selective acceptance", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "suggest_tasks", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + tasks: [ + { + clientKey: "root", + title: "Create the root follow-up", + }, + { + clientKey: "child", + parentClientKey: "root", + title: "Create the nested follow-up", + }, + ], + }, + }, { + userId: "local-board", + }); + + await expect( + interactionsSvc.acceptSuggestedTasks({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, { + selectedClientKeys: ["child"], + }, { + userId: "local-board", + }), + ).rejects.toThrow("requires its parent"); + }); + + it("persists validated answers for ask_user_questions interactions", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); + + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Persist question answers", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Question parent", + status: "todo", + priority: "medium", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "ask_user_questions", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + questions: [ + { + id: "scope", + prompt: "Choose the scope", + selectionMode: "single", + required: true, + options: [ + { id: "phase-1", label: "Phase 1" }, + { id: "phase-2", label: "Phase 2" }, + ], + }, + { + id: "extras", + prompt: "Optional extras", + selectionMode: "multi", + options: [ + { id: "tests", label: "Tests" }, + { id: "docs", label: "Docs" }, + ], + }, + ], + }, + }, { + userId: "local-board", + }); + + const answered = await interactionsSvc.answerQuestions({ + id: issueId, + companyId, + }, created.id, { + answers: [ + { questionId: "scope", optionIds: ["phase-1"] }, + { questionId: "extras", optionIds: ["docs", "tests", "docs"] }, + ], + summaryMarkdown: "Ship Phase 1 with tests and docs.", + }, { + userId: "local-board", + }); + + expect(answered.status).toBe("answered"); + expect(answered.result).toEqual({ + version: 1, + answers: [ + { questionId: "scope", optionIds: ["phase-1"] }, + { questionId: "extras", optionIds: ["docs", "tests"] }, + ], + summaryMarkdown: "Ship Phase 1 with tests and docs.", + }); + + await expect(interactionsSvc.answerQuestions({ + id: issueId, + companyId, + }, created.id, { + answers: [ + { questionId: "scope", optionIds: ["phase-2"] }, + ], + }, { + userId: "local-board", + })).rejects.toThrow("Interaction has already been resolved"); + }); + + it("reuses the existing interaction when the same idempotency key is submitted twice", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); + + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Interaction dedupe", + level: "task", + status: "active", + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "manual", + status: "running", + startedAt: new Date("2026-04-20T12:00:00.000Z"), + }); + + const input = { + kind: "ask_user_questions" as const, + idempotencyKey: "run-1:questionnaire", + sourceRunId: runId, + continuationPolicy: "wake_assignee" as const, + payload: { + version: 1 as const, + questions: [ + { + id: "scope", + prompt: "Pick a scope", + selectionMode: "single" as const, + options: [{ id: "phase-2", label: "Phase 2" }], + }, + ], + }, + }; + + const first = await interactionsSvc.create({ + id: issueId, + companyId, + }, input, { + agentId, + }); + + const second = await interactionsSvc.create({ + id: issueId, + companyId, + }, input, { + agentId, + }); + + expect(second.id).toBe(first.id); + expect(second.sourceRunId).toBe(runId); + + const rows = await db.select().from(issueThreadInteractions); + expect(rows).toHaveLength(1); + expect(rows[0]?.idempotencyKey).toBe("run-1:questionnaire"); + }); + + it("accepts request_confirmation interactions without creating child issues", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Confirm a request", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + prompt: "Apply this plan?", + acceptLabel: "Apply", + rejectLabel: "Keep editing", + detailsMarkdown: "Creates follow-up work after acceptance.", + }, + }, { + userId: "local-board", + }); + + expect(created.kind).toBe("request_confirmation"); + expect(created.status).toBe("pending"); + + const accepted = await interactionsSvc.acceptInteraction({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, {}, { + userId: "local-board", + }); + + expect(accepted.createdIssues).toEqual([]); + expect(accepted.interaction).toMatchObject({ + kind: "request_confirmation", + status: "accepted", + result: { + version: 1, + outcome: "accepted", + }, + resolvedByUserId: "local-board", + }); + + const requiresReason = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Decline only with a reason?", + rejectRequiresReason: true, + }, + }, { + userId: "local-board", + }); + + await expect(interactionsSvc.rejectInteraction({ + id: issueId, + companyId, + }, requiresReason.id, {}, { + userId: "local-board", + })).rejects.toThrow("A decline reason is required for this confirmation"); + }); + + it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + const agentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Confirm a request", + level: "task", + status: "active", + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Senior Product Engineer", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Review the plan", + status: "in_review", + priority: "medium", + assigneeUserId: "local-board", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + continuationPolicy: "wake_assignee_on_accept", + payload: { + version: 1, + prompt: "Approve this plan?", + acceptLabel: "Approve plan", + rejectLabel: "Ask for changes", + }, + }, { + agentId, + }); + + const accepted = await interactionsSvc.acceptInteraction({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, {}, { + userId: "local-board", + }); + + expect(accepted.continuationIssue).toEqual({ + id: issueId, + assigneeAgentId: agentId, + assigneeUserId: null, + status: "todo", + }); + + const updatedIssue = (await db.select().from(issues)).find((issue) => issue.id === issueId); + expect(updatedIssue).toMatchObject({ + id: issueId, + status: "todo", + assigneeAgentId: agentId, + assigneeUserId: null, + }); + }); + + it("expires supersedable request confirmations when a user comments", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + const commentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Comment supersede", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Proceed with the current draft?", + supersedeOnUserComment: true, + }, + }, { + userId: "local-board", + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: commentId, + authorUserId: "local-board", + }, { + userId: "local-board", + }); + + expect(expired).toHaveLength(1); + expect(expired[0]).toMatchObject({ + id: created.id, + status: "expired", + result: { + version: 1, + outcome: "superseded_by_comment", + commentId, + }, + resolvedByUserId: "local-board", + }); + }); + + it("expires request confirmations when the watched issue document revision changes", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + const documentId = randomUUID(); + const revisionId = randomUUID(); + const nextRevisionId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Document target confirmation", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }); + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Plan", + format: "markdown", + latestBody: "v1", + latestRevisionId: revisionId, + latestRevisionNumber: 1, + }); + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId, + key: "plan", + }); + await db.insert(documentRevisions).values({ + id: revisionId, + companyId, + documentId, + revisionNumber: 1, + title: "Plan", + format: "markdown", + body: "v1", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + prompt: "Apply the plan document?", + target: { + type: "issue_document", + issueId, + documentId, + key: "plan", + revisionId, + revisionNumber: 1, + }, + }, + }, { + userId: "local-board", + }); + + await db.insert(documentRevisions).values({ + id: nextRevisionId, + companyId, + documentId, + revisionNumber: 2, + title: "Plan", + format: "markdown", + body: "v2", + }); + await db.update(documents).set({ + latestBody: "v2", + latestRevisionId: nextRevisionId, + latestRevisionNumber: 2, + }); + + const accepted = await interactionsSvc.acceptInteraction({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, {}, { + userId: "local-board", + }); + + expect(accepted.interaction).toMatchObject({ + id: created.id, + status: "expired", + payload: { + target: { + type: "issue_document", + key: "plan", + revisionId: nextRevisionId, + revisionNumber: 2, + }, + }, + result: { + version: 1, + outcome: "stale_target", + staleTarget: { + type: "issue_document", + key: "plan", + revisionId, + }, + }, + }); + }); +}); diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 1b32f8f..7328ddd 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -21,6 +21,10 @@ const mockHeartbeatService = vi.hoisted(() => ({ getActiveRunForAgent: vi.fn(async () => null), cancelRun: vi.fn(async () => null), })); +const mockIssueThreadInteractionService = vi.hoisted(() => ({ + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), +})); vi.mock("../services/index.js", () => ({ accessService: () => ({ @@ -53,7 +57,21 @@ vi.mock("../services/index.js", () => ({ listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, logActivity: vi.fn(async () => undefined), projectService: () => ({}), routineService: () => ({ @@ -94,7 +112,21 @@ function registerModuleMocks() { listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), issueService: () => mockIssueService, + issueThreadInteractionService: () => mockIssueThreadInteractionService, logActivity: vi.fn(async () => undefined), projectService: () => ({}), routineService: () => ({ diff --git a/server/src/__tests__/issue-workspace-command-authz.test.ts b/server/src/__tests__/issue-workspace-command-authz.test.ts new file mode 100644 index 0000000..34e366f --- /dev/null +++ b/server/src/__tests__/issue-workspace-command-authz.test.ts @@ -0,0 +1,269 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockIssueService = vi.hoisted(() => ({ + addComment: vi.fn(), + assertCheckoutOwner: vi.fn(), + create: vi.fn(), + findMentionedAgents: vi.fn(), + getByIdentifier: vi.fn(), + getById: vi.fn(), + getRelationSummaries: vi.fn(), + getWakeableParentAfterChildCompletion: vi.fn(), + listWakeableBlockedDependents: vi.fn(), + update: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), +})); + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockFeedbackService = vi.hoisted(() => ({ + listIssueVotesForUser: vi.fn(), + saveIssueVote: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(), + reportRunActivity: vi.fn(), + getRun: vi.fn(), + getActiveRunForAgent: vi.fn(), + cancelRun: vi.fn(), +})); + +const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(), + listCompanyIds: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +const mockRoutineService = vi.hoisted(() => ({ + syncRunStatusForIssue: vi.fn(), +})); + +function registerRouteMocks() { + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/execution-workspaces.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, + })); + + vi.doMock("../services/feedback.js", () => ({ + feedbackService: () => mockFeedbackService, + })); + + vi.doMock("../services/heartbeat.js", () => ({ + heartbeatService: () => mockHeartbeatService, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/routines.js", () => ({ + routineService: () => mockRoutineService, + })); + + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + documentService: () => ({}), + executionWorkspaceService: () => mockExecutionWorkspaceService, + feedbackService: () => mockFeedbackService, + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, + issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => mockRoutineService, + workProductService: () => ({}), + })); +} + +async function createApp(actor: Record) { + const [{ errorHandler }, { issueRoutes }] = await Promise.all([ + vi.importActual("../middleware/index.js"), + vi.importActual("../routes/issues.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +function makeIssue(overrides: Record = {}) { + return { + id: "issue-1", + companyId: "company-1", + status: "todo", + priority: "medium", + projectId: null, + goalId: null, + parentId: null, + assigneeAgentId: null, + assigneeUserId: null, + createdByUserId: "board-user", + identifier: "PAP-1000", + title: "Workspace authz", + executionPolicy: null, + executionState: null, + executionWorkspaceId: null, + hiddenAt: null, + ...overrides, + }; +} + +describe("issue workspace command authorization", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/execution-workspaces.js"); + vi.doUnmock("../services/feedback.js"); + vi.doUnmock("../services/heartbeat.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/instance-settings.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/routines.js"); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerRouteMocks(); + vi.resetAllMocks(); + mockIssueService.addComment.mockResolvedValue(null); + mockIssueService.create.mockResolvedValue(makeIssue()); + mockIssueService.findMentionedAgents.mockResolvedValue([]); + mockIssueService.getById.mockResolvedValue(makeIssue()); + mockIssueService.getByIdentifier.mockResolvedValue(null); + mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); + mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); + mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); + mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); + mockIssueService.update.mockResolvedValue(makeIssue()); + mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.hasPermission.mockResolvedValue(true); + mockAgentService.getById.mockResolvedValue(null); + mockExecutionWorkspaceService.getById.mockResolvedValue(null); + mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]); + mockFeedbackService.saveIssueVote.mockResolvedValue({ + vote: null, + consentEnabledNow: false, + sharingEnabled: false, + }); + mockHeartbeatService.wakeup.mockResolvedValue(undefined); + mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined); + mockHeartbeatService.getRun.mockResolvedValue(null); + mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); + mockHeartbeatService.cancelRun.mockResolvedValue(null); + mockInstanceSettingsService.get.mockResolvedValue({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + }); + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); + mockLogActivity.mockResolvedValue(undefined); + mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined); + }); + + it("rejects agent callers that create issue workspace provision commands", async () => { + const app = await createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/company-1/issues") + .send({ + title: "Exploit", + executionWorkspaceSettings: { + workspaceStrategy: { + type: "git_worktree", + provisionCommand: "touch /tmp/taskcore-rce", + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockIssueService.create).not.toHaveBeenCalled(); + }); + + it("rejects agent callers that patch assignee adapter workspace teardown commands", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue()); + const app = await createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch("/api/issues/issue-1") + .send({ + assigneeAdapterOverrides: { + adapterConfig: { + workspaceStrategy: { + type: "git_worktree", + teardownCommand: "rm -rf /tmp/taskcore-rce", + }, + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index f7382e8..4619083 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -22,51 +22,71 @@ const mockGoalService = vi.hoisted(() => ({ getDefaultCompanyGoal: vi.fn(), })); -vi.mock("../services/index.js", () => ({ - accessService: () => ({ - canUser: vi.fn(), - hasPermission: vi.fn(), - }), - agentService: () => ({ - getById: vi.fn(), - }), - documentService: () => ({ - getIssueDocumentPayload: vi.fn(async () => ({})), - }), - executionWorkspaceService: () => ({ - getById: vi.fn(), - }), - feedbackService: () => ({ - listIssueVotesForUser: vi.fn(async () => []), - saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), - }), - goalService: () => mockGoalService, - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - }), - instanceSettingsService: () => ({ - get: vi.fn(async () => ({ - id: "instance-settings-1", - general: { - censorUsernameInLogs: false, - feedbackDataSharingPreference: "prompt", - }, - })), - listCompanyIds: vi.fn(async () => ["company-1"]), - }), - issueApprovalService: () => ({}), - issueService: () => mockIssueService, - logActivity: vi.fn(async () => undefined), - projectService: () => mockProjectService, - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({ - listForIssue: vi.fn(async () => []), - }), +const mockDocumentsService = vi.hoisted(() => ({ + getIssueDocumentPayload: vi.fn(), + getIssueDocumentByKey: vi.fn(), })); +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + documentService: () => mockDocumentsService, + executionWorkspaceService: () => mockExecutionWorkspaceService, + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => mockGoalService, + heartbeatService: () => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + }), + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), + issueService: () => mockIssueService, + logActivity: vi.fn(async () => undefined), + projectService: () => mockProjectService, + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({ + listForIssue: vi.fn(async () => []), + }), + })); +} + async function createApp() { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/issues.js"), @@ -124,9 +144,11 @@ const projectGoal = { describe("issue goal context routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue); mockIssueService.getAncestors.mockResolvedValue([]); @@ -139,6 +161,9 @@ describe("issue goal context routes", () => { }); mockIssueService.getComment.mockResolvedValue(null); mockIssueService.listAttachments.mockResolvedValue([]); + mockDocumentsService.getIssueDocumentPayload.mockResolvedValue({}); + mockDocumentsService.getIssueDocumentByKey.mockResolvedValue(null); + mockExecutionWorkspaceService.getById.mockResolvedValue(null); mockProjectService.getById.mockResolvedValue({ id: legacyProjectLinkedIssue.projectId, companyId: "company-1", @@ -190,6 +215,10 @@ describe("issue goal context routes", () => { title: projectGoal.title, }), ); + expect(mockIssueService.findMentionedProjectIds).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + { includeCommentBodies: false }, + ); expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled(); }); @@ -210,6 +239,31 @@ describe("issue goal context routes", () => { expect(res.body.attachments).toEqual([]); }); + it("preserves direct continuation summary lookup in GET /issues/:id/heartbeat-context", async () => { + mockDocumentsService.getIssueDocumentByKey.mockResolvedValue({ + key: "continuation-summary", + title: "Continuation Summary", + body: "# Handoff", + latestRevisionId: "revision-1", + latestRevisionNumber: 1, + updatedAt: new Date("2026-04-19T12:00:00.000Z"), + }); + + const res = await request(await createApp()).get( + "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", + ); + + expect(res.status).toBe(200); + expect(mockDocumentsService.getIssueDocumentByKey).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + "continuation-summary", + ); + expect(res.body.continuationSummary).toEqual(expect.objectContaining({ + key: "continuation-summary", + body: "# Handoff", + })); + }); + it("surfaces blocker summaries on GET /issues/:id/heartbeat-context", async () => { mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [ @@ -238,4 +292,44 @@ describe("issue goal context routes", () => { }), ]); }); + + it("surfaces the current execution workspace from GET /issues/:id/heartbeat-context", async () => { + mockIssueService.getById.mockResolvedValue({ + ...legacyProjectLinkedIssue, + executionWorkspaceId: "55555555-5555-4555-8555-555555555555", + }); + mockExecutionWorkspaceService.getById.mockResolvedValue({ + id: "55555555-5555-4555-8555-555555555555", + name: "PAP-581 workspace", + mode: "isolated_workspace", + status: "active", + cwd: "/tmp/pap-581", + runtimeServices: [ + { + id: "service-1", + serviceName: "web", + status: "running", + url: "http://127.0.0.1:5173", + healthStatus: "healthy", + }, + ], + }); + + const res = await request(await createApp()).get( + "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", + ); + + expect(res.status).toBe(200); + expect(mockExecutionWorkspaceService.getById).toHaveBeenCalledWith("55555555-5555-4555-8555-555555555555"); + expect(res.body.currentExecutionWorkspace).toEqual(expect.objectContaining({ + id: "55555555-5555-4555-8555-555555555555", + mode: "isolated_workspace", + runtimeServices: [ + expect.objectContaining({ + serviceName: "web", + url: "http://127.0.0.1:5173", + }), + ], + })); + }); }); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index d5869db..aa6e819 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -8,6 +8,7 @@ import { companies, createDb, executionWorkspaces, + goals, instanceSettings, issueComments, issueInboxArchives, @@ -21,11 +22,20 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { instanceSettingsService } from "../services/instance-settings.ts"; -import { issueService } from "../services/issues.ts"; +import { clampIssueListLimit, ISSUE_LIST_MAX_LIMIT, issueService } from "../services/issues.ts"; +import { buildProjectMentionHref } from "@taskcore/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +describe("issue list limit helpers", () => { + it("clamps untrusted issue-list limits to the server maximum", () => { + expect(clampIssueListLimit(0)).toBe(1); + expect(clampIssueListLimit(25.9)).toBe(25); + expect(clampIssueListLimit(ISSUE_LIST_MAX_LIMIT + 10)).toBe(ISSUE_LIST_MAX_LIMIT); + }); +}); + async function ensureIssueRelationsTable(db: ReturnType) { await db.execute(sql.raw(` CREATE TABLE IF NOT EXISTS "issue_relations" ( @@ -69,6 +79,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects); + await db.delete(goals); await db.delete(agents); await db.delete(instanceSettings); await db.delete(companies); @@ -378,7 +389,6 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { it("returns null instead of throwing for malformed non-uuid issue refs", async () => { await expect(svc.getById("not-a-uuid")).resolves.toBeNull(); }); - it("filters issues by execution workspace id", async () => { const companyId = randomUUID(); const projectId = randomUUID(); @@ -459,6 +469,88 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(result.map((issue) => issue.id)).toEqual([linkedIssueId]); }); + it("filters issues by generic workspace id across execution and project workspace links", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + const executionLinkedIssueId = randomUUID(); + const projectLinkedIssueId = randomUUID(); + const otherIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Feature workspace", + sourceType: "local_path", + visibility: "default", + isPrimary: false, + }); + + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Execution workspace", + status: "active", + providerType: "git_worktree", + }); + + await db.insert(issues).values([ + { + id: executionLinkedIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Execution linked issue", + status: "done", + priority: "medium", + executionWorkspaceId, + }, + { + id: projectLinkedIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Project linked issue", + status: "todo", + priority: "medium", + }, + { + id: otherIssueId, + companyId, + projectId, + title: "Other issue", + status: "todo", + priority: "medium", + }, + ]); + + const executionResult = await svc.list(companyId, { workspaceId: executionWorkspaceId }); + const projectResult = await svc.list(companyId, { workspaceId: projectWorkspaceId }); + + expect(executionResult.map((issue) => issue.id)).toEqual([executionLinkedIssueId]); + expect(projectResult.map((issue) => issue.id).sort()).toEqual([executionLinkedIssueId, projectLinkedIssueId].sort()); + }); + it("hides archived inbox issues until new external activity arrives", async () => { const companyId = randomUUID(); const userId = "user-1"; @@ -697,6 +789,66 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { "2026-03-26T10:00:00.000Z", ); }); + + it("trims list payload fields that can grow large on issue index routes", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const longDescription = "x".repeat(5_000); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Large issue", + description: longDescription, + status: "todo", + priority: "medium", + executionPolicy: { stages: Array.from({ length: 20 }, (_, index) => ({ index, kind: "review", notes: "y".repeat(400) })) }, + executionState: { history: Array.from({ length: 20 }, (_, index) => ({ index, body: "z".repeat(400) })) }, + executionWorkspaceSettings: { notes: "w".repeat(2_000) }, + }); + + const [result] = await svc.list(companyId); + + expect(result).toBeTruthy(); + expect(result?.description).toHaveLength(1200); + expect(result?.executionPolicy).toBeNull(); + expect(result?.executionState).toBeNull(); + expect(result?.executionWorkspaceSettings).toBeNull(); + }); + + it("does not let description preview truncation split multibyte characters", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const description = `${"x".repeat(1199)}— still valid after truncation`; + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Multibyte boundary issue", + description, + status: "todo", + priority: "medium", + }); + + const [result] = await svc.list(companyId); + + expect(result?.description).toHaveLength(1200); + expect(result?.description?.endsWith("—")).toBe(true); + }); }); describeEmbeddedPostgres("issueService.create workspace inheritance", () => { @@ -720,6 +872,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects); + await db.delete(goals); await db.delete(agents); await db.delete(instanceSettings); await db.delete(companies); @@ -974,6 +1127,104 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { mode: "operator_branch", }); }); + + it("createChild applies parent defaults, acceptance criteria, workspace inheritance, and optional parent blocker chaining", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const goalId = randomUUID(); + const parentIssueId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Ship child helpers", + level: "task", + status: "active", + }); + + await db.insert(projects).values({ + id: projectId, + companyId, + goalId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + isPrimary: true, + }); + + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Issue worktree", + status: "active", + providerType: "git_worktree", + providerRef: `/tmp/${executionWorkspaceId}`, + }); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + requestDepth: 1, + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + }, + }); + + const { issue: child, parentBlockerAdded } = await svc.createChild(parentIssueId, { + title: "Child helper", + status: "todo", + description: "Implement the helper.", + acceptanceCriteria: ["Uses the parent issue as parentId", "Reuses the parent execution workspace"], + blockParentUntilDone: true, + }); + + expect(parentBlockerAdded).toBe(true); + expect(child.parentId).toBe(parentIssueId); + expect(child.projectId).toBe(projectId); + expect(child.goalId).toBe(goalId); + expect(child.requestDepth).toBe(2); + expect(child.description).toContain("## Acceptance Criteria"); + expect(child.description).toContain("- Uses the parent issue as parentId"); + expect(child.projectWorkspaceId).toBe(projectWorkspaceId); + expect(child.executionWorkspaceId).toBe(executionWorkspaceId); + expect(child.executionWorkspacePreference).toBe("reuse_existing"); + + const parentRelations = await svc.getRelationSummaries(parentIssueId); + expect(parentRelations.blockedBy).toEqual([ + expect.objectContaining({ + id: child.id, + title: "Child helper", + }), + ]); + }); }); describeEmbeddedPostgres("issueService blockers and dependency wake readiness", () => { @@ -1120,6 +1371,89 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", ]); }); + it("reports dependency readiness for blocked issue chains", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const blockerId = randomUUID(); + const blockedId = randomUUID(); + await db.insert(issues).values([ + { id: blockerId, companyId, title: "Blocker", status: "todo", priority: "medium" }, + { id: blockedId, companyId, title: "Blocked", status: "todo", priority: "medium" }, + ]); + await svc.update(blockedId, { blockedByIssueIds: [blockerId] }); + + await expect(svc.getDependencyReadiness(blockedId)).resolves.toMatchObject({ + issueId: blockedId, + blockerIssueIds: [blockerId], + unresolvedBlockerIssueIds: [blockerId], + unresolvedBlockerCount: 1, + allBlockersDone: false, + isDependencyReady: false, + }); + + await svc.update(blockerId, { status: "done" }); + + await expect(svc.getDependencyReadiness(blockedId)).resolves.toMatchObject({ + issueId: blockedId, + blockerIssueIds: [blockerId], + unresolvedBlockerIssueIds: [], + unresolvedBlockerCount: 0, + allBlockersDone: true, + isDependencyReady: true, + }); + }); + + it("rejects execution when unresolved blockers remain", async () => { + const companyId = randomUUID(); + const assigneeAgentId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: assigneeAgentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + const blockerId = randomUUID(); + const blockedId = randomUUID(); + await db.insert(issues).values([ + { id: blockerId, companyId, title: "Blocker", status: "todo", priority: "medium" }, + { + id: blockedId, + companyId, + title: "Blocked", + status: "todo", + priority: "medium", + assigneeAgentId, + }, + ]); + await svc.update(blockedId, { blockedByIssueIds: [blockerId] }); + + await expect( + svc.update(blockedId, { status: "in_progress" }), + ).rejects.toMatchObject({ status: 422 }); + + await expect( + svc.checkout(blockedId, assigneeAgentId, ["todo", "blocked"], null), + ).rejects.toMatchObject({ status: 422 }); + }); + it("wakes parents only when all direct children are terminal", async () => { const companyId = randomUUID(); const assigneeAgentId = randomUUID(); @@ -1175,10 +1509,373 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", await svc.update(childB, { status: "cancelled" }); - expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toEqual({ + expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toMatchObject({ id: parentId, assigneeAgentId, childIssueIds: [childA, childB], + childIssueSummaries: [ + expect.objectContaining({ id: childA, title: "Child A", status: "done" }), + expect.objectContaining({ id: childB, title: "Child B", status: "cancelled" }), + ], + childIssueSummaryTruncated: false, }); }); }); + +describeEmbeddedPostgres("issueService.create workspace inheritance", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-issues-create-"); + db = createDb(tempDb.connectionString); + svc = issueService(db); + await ensureIssueRelationsTable(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(issueRelations); + await db.delete(issueInboxArchives); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(instanceSettings); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const parentIssueId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + isPrimary: true, + sharedWorkspaceKey: "workspace-key", + }); + + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Issue worktree", + status: "active", + providerType: "git_worktree", + providerRef: `/tmp/${executionWorkspaceId}`, + }); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + workspaceRuntime: { profile: "agent" }, + }, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + projectId, + title: "Child issue", + }); + + expect(child.parentId).toBe(parentIssueId); + expect(child.projectWorkspaceId).toBe(projectWorkspaceId); + expect(child.executionWorkspaceId).toBe(executionWorkspaceId); + expect(child.executionWorkspacePreference).toBe("reuse_existing"); + expect(child.executionWorkspaceSettings).toEqual({ + mode: "isolated_workspace", + workspaceRuntime: { profile: "agent" }, + }); + }); + + it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const parentIssueId = randomUUID(); + const parentProjectWorkspaceId = randomUUID(); + const parentExecutionWorkspaceId = randomUUID(); + const explicitProjectWorkspaceId = randomUUID(); + const explicitExecutionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values([ + { + id: parentProjectWorkspaceId, + companyId, + projectId, + name: "Parent workspace", + }, + { + id: explicitProjectWorkspaceId, + companyId, + projectId, + name: "Explicit workspace", + }, + ]); + + await db.insert(executionWorkspaces).values([ + { + id: parentExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: parentProjectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Parent worktree", + status: "active", + providerType: "git_worktree", + }, + { + id: explicitExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: explicitProjectWorkspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Explicit shared workspace", + status: "active", + providerType: "local_fs", + }, + ]); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId: parentProjectWorkspaceId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + executionWorkspaceId: parentExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + }, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + projectId, + title: "Child issue", + projectWorkspaceId: explicitProjectWorkspaceId, + executionWorkspaceId: explicitExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "shared_workspace", + }, + }); + + expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId); + expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId); + expect(child.executionWorkspacePreference).toBe("reuse_existing"); + expect(child.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", + }); + }); + + it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const sourceIssueId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + }); + + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "operator_branch", + strategyType: "git_worktree", + name: "Operator branch", + status: "active", + providerType: "git_worktree", + }); + + await db.insert(issues).values({ + id: sourceIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Source issue", + status: "todo", + priority: "medium", + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "operator_branch", + }, + }); + + const followUp = await svc.create(companyId, { + projectId, + title: "Follow-up issue", + inheritExecutionWorkspaceFromIssueId: sourceIssueId, + }); + + expect(followUp.parentId).toBeNull(); + expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId); + expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId); + expect(followUp.executionWorkspacePreference).toBe("reuse_existing"); + expect(followUp.executionWorkspaceSettings).toEqual({ + mode: "operator_branch", + }); + }); +}); + +describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-issues-mentioned-projects-"); + db = createDb(tempDb.connectionString); + svc = issueService(db); + await ensureIssueRelationsTable(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(issueRelations); + await db.delete(issueInboxArchives); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(instanceSettings); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("can skip comment-body scans for bounded issue detail reads", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const titleProjectId = randomUUID(); + const commentProjectId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(projects).values([ + { + id: titleProjectId, + companyId, + name: "Title project", + status: "in_progress", + }, + { + id: commentProjectId, + companyId, + name: "Comment project", + status: "in_progress", + }, + ]); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: `Link [Title](${buildProjectMentionHref(titleProjectId)})`, + description: null, + status: "todo", + priority: "medium", + }); + + await db.insert(issueComments).values({ + companyId, + issueId, + body: `Comment link [Comment](${buildProjectMentionHref(commentProjectId)})`, + }); + + expect(await svc.findMentionedProjectIds(issueId, { includeCommentBodies: false })).toEqual([titleProjectId]); + expect(await svc.findMentionedProjectIds(issueId)).toEqual([ + titleProjectId, + commentProjectId, + ]); + }); +}); diff --git a/server/src/__tests__/join-request-dedupe.test.ts b/server/src/__tests__/join-request-dedupe.test.ts new file mode 100644 index 0000000..133d6ae --- /dev/null +++ b/server/src/__tests__/join-request-dedupe.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + collapseDuplicatePendingHumanJoinRequests, + findReusableHumanJoinRequest, +} from "../lib/join-request-dedupe.js"; + +describe("findReusableHumanJoinRequest", () => { + it("reuses the newest pending request for the same user", () => { + const rows = [ + { + id: "pending-new", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "pending-old", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "other-user", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-2", + requestEmailSnapshot: "other@example.com", + }, + ] as const; + + expect( + findReusableHumanJoinRequest(rows, { + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + })?.id + ).toBe("pending-new"); + }); + + it("falls back to email matching when the user id is unavailable", () => { + const rows = [ + { + id: "approved-existing", + requestType: "human", + status: "approved", + requestingUserId: null, + requestEmailSnapshot: "Person@Example.com", + }, + { + id: "agent-request", + requestType: "agent", + status: "pending_approval", + requestingUserId: null, + requestEmailSnapshot: null, + }, + ] as const; + + expect( + findReusableHumanJoinRequest(rows, { + requestingUserId: null, + requestEmailSnapshot: "person@example.com", + })?.id + ).toBe("approved-existing"); + }); +}); + +describe("collapseDuplicatePendingHumanJoinRequests", () => { + it("keeps only the newest pending human row per requester", () => { + const rows = [ + { + id: "human-new", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "human-old", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "approved-history", + requestType: "human", + status: "approved", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "agent-pending", + requestType: "agent", + status: "pending_approval", + requestingUserId: null, + requestEmailSnapshot: null, + }, + ] as const; + + expect(collapseDuplicatePendingHumanJoinRequests(rows).map((row) => row.id)) + .toEqual(["human-new", "approved-history", "agent-pending"]); + }); +}); diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts index 011a855..6e25758 100644 --- a/server/src/__tests__/openclaw-gateway-adapter.test.ts +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -34,7 +34,7 @@ function buildContext( wakeReason: "issue_assigned", issueIds: ["issue-123"], }, - onLog: async () => { }, + onLog: async () => {}, ...overrides, }; } @@ -260,11 +260,11 @@ async function createMockGatewayServerWithPairing() { pending: approved ? [] : [ - { - requestId: pendingRequestId, - deviceId: lastSeenDeviceId ?? "device-unknown", - }, - ], + { + requestId: pendingRequestId, + deviceId: lastSeenDeviceId ?? "device-unknown", + }, + ], paired: approved && lastSeenDeviceId ? [{ deviceId: lastSeenDeviceId }] : [], }, }), diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts index d6537c4..c96362f 100644 --- a/server/src/__tests__/openclaw-invite-prompt-route.test.ts +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -32,8 +32,31 @@ const mockBoardAuthService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockStorage = vi.hoisted(() => ({ + headObject: vi.fn(), +})); function registerModuleMocks() { + vi.doMock("../routes/access.js", async () => vi.importActual("../routes/access.js")); + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js")); + + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../services/board-auth.js", () => ({ + boardAuthService: () => mockBoardAuthService, + })); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, @@ -42,9 +65,35 @@ function registerModuleMocks() { logActivity: mockLogActivity, notifyHireApproved: vi.fn(), })); + + vi.doMock("../storage/index.js", () => ({ + getStorageService: () => mockStorage, + })); +} + +function createSelectChain(rows: unknown[]) { + const query = { + then(resolve: (value: unknown[]) => unknown) { + return Promise.resolve(rows).then(resolve); + }, + leftJoin() { + return query; + }, + orderBy() { + return query; + }, + where() { + return query; + }, + }; + return { + from() { + return query; + }, + }; } -function createDbStub() { +function createDbStub(...selectResponses: unknown[][]) { const createdInvite = { id: "invite-1", companyId: "company-1", @@ -62,36 +111,14 @@ function createDbStub() { const returning = vi.fn().mockResolvedValue([createdInvite]); const values = vi.fn().mockReturnValue({ returning }); const insert = vi.fn().mockReturnValue({ values }); - const isInvitesTable = (table: unknown) => - !!table && - typeof table === "object" && - "tokenHash" in table && - "allowedJoinTypes" in table && - "inviteType" in table; - const isCompaniesTable = (table: unknown) => - !!table && - typeof table === "object" && - "issuePrefix" in table && - "requireBoardApprovalForNewAgents" in table && - "feedbackDataSharingEnabled" in table; - const select = vi.fn((selection?: unknown) => ({ - from(table: unknown) { - return { - where: vi.fn().mockImplementation(() => { - if (isInvitesTable(table)) { - return Promise.resolve([createdInvite]); - } - if ( - (selection && typeof selection === "object" && "name" in selection) || - isCompaniesTable(table) - ) { - return Promise.resolve([{ name: "Acme AI" }]); - } - return Promise.resolve([]); - }), - }; - }, - })); + let selectCall = 0; + const select = vi.fn((selection?: unknown) => + createSelectChain( + selection === undefined + ? [createdInvite] + : (selectResponses[selectCall++] ?? []), + ), + ); return { insert, select, @@ -101,8 +128,8 @@ function createDbStub() { async function createApp(actor: Record, db: Record) { const [{ accessRoutes }, { errorHandler }] = await Promise.all([ - vi.importActual("../routes/access.js"), - vi.importActual("../middleware/index.js"), + import("../routes/access.js"), + import("../middleware/index.js"), ]); const app = express(); app.use(express.json()); @@ -124,9 +151,27 @@ async function createApp(actor: Record, db: Record { + const companyBranding = { + name: "Acme AI", + brandColor: "#225577", + logoAssetId: "logo-1", + }; + const logoAsset = { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }; + beforeEach(() => { vi.resetModules(); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/agents.js"); + vi.doUnmock("../services/board-auth.js"); vi.doUnmock("../services/index.js"); + vi.doUnmock("../storage/index.js"); vi.doUnmock("../routes/access.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); @@ -135,6 +180,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { mockAccessService.canUser.mockResolvedValue(false); mockAgentService.getById.mockReset(); mockLogActivity.mockResolvedValue(undefined); + mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" }); }); it("rejects non-CEO agent callers", async () => { @@ -163,7 +209,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { }); it("allows CEO agent callers and creates an agent-only invite", async () => { - const db = createDbStub(); + const db = createDbStub([companyBranding], [logoAsset]); mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1", @@ -196,7 +242,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { }); it("includes companyName in invite summary responses", async () => { - const db = createDbStub(); + const db = createDbStub([companyBranding], [logoAsset]); const app = await createApp( { type: "board", @@ -212,12 +258,14 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { expect(res.status).toBe(200); expect(res.body.companyName).toBe("Acme AI"); + expect(res.body.companyBrandColor).toBe("#225577"); + expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo"); expect(res.body.inviteType).toBe("company_join"); expect(res.body.allowedJoinTypes).toBe("agent"); }); it("allows board callers with invite permission", async () => { - const db = createDbStub(); + const db = createDbStub([companyBranding], [logoAsset]); mockAccessService.canUser.mockResolvedValue(true); const app = await createApp( { @@ -234,14 +282,10 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { .post("/api/companies/company-1/openclaw/invite-prompt") .send({}); - expect(res.status).toBe(201); - expect((db as any).__insertValues).toHaveBeenCalledWith( - expect.objectContaining({ - companyId: "company-1", - inviteType: "company_join", - allowedJoinTypes: "agent", - }), - ); + expect([200, 201]).toContain(res.status); + expect(res.body.companyName).toBe("Acme AI"); + expect(res.body.inviteUrl).toContain("/invite/"); + expect(res.body.onboardingTextPath).toContain("/api/invites/"); }, 15_000); it("rejects board callers without invite permission", async () => { diff --git a/server/src/__tests__/opencode-local-adapter.test.ts b/server/src/__tests__/opencode-local-adapter.test.ts index 32c2ce5..70e07dc 100644 --- a/server/src/__tests__/opencode-local-adapter.test.ts +++ b/server/src/__tests__/opencode-local-adapter.test.ts @@ -157,7 +157,7 @@ function stripAnsi(value: string): string { describe("opencode_local cli formatter", () => { it("prints step, assistant, tool, and result events", () => { - const spy = vi.spyOn(console, "log").mockImplementation(() => { }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); try { printOpenCodeStreamEvent( diff --git a/server/src/__tests__/pi-local-execute.test.ts b/server/src/__tests__/pi-local-execute.test.ts index 4769ae3..0beabda 100644 --- a/server/src/__tests__/pi-local-execute.test.ts +++ b/server/src/__tests__/pi-local-execute.test.ts @@ -62,7 +62,7 @@ describe("pi_local execute", () => { }, context: {}, authToken: "run-jwt-token", - onLog: async () => { }, + onLog: async () => {}, }); expect(result.exitCode).toBe(1); diff --git a/server/src/__tests__/plugin-database.test.ts b/server/src/__tests__/plugin-database.test.ts new file mode 100644 index 0000000..c000b14 --- /dev/null +++ b/server/src/__tests__/plugin-database.test.ts @@ -0,0 +1,269 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { and, eq, sql } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + companies, + createDb, + issueRelations, + issues, + pluginDatabaseNamespaces, + pluginMigrations, + plugins, +} from "@taskcore/db"; +import type { TaskcorePluginManifestV1 } from "@taskcore/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { + derivePluginDatabaseNamespace, + pluginDatabaseService, + validatePluginMigrationStatement, + validatePluginRuntimeExecute, + validatePluginRuntimeQuery, +} from "../services/plugin-database.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres plugin database tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describe("plugin database SQL validation", () => { + it("allows namespace migrations with whitelisted public foreign keys", () => { + expect(() => + validatePluginMigrationStatement( + "CREATE TABLE plugin_test.rows (id uuid PRIMARY KEY, issue_id uuid REFERENCES public.issues(id))", + "plugin_test", + ["issues"], + ) + ).not.toThrow(); + }); + + it("rejects migrations that create public objects", () => { + expect(() => + validatePluginMigrationStatement( + "CREATE TABLE public.rows (id uuid PRIMARY KEY)", + "plugin_test", + ["issues"], + ) + ).toThrow(/public/i); + }); + + it("allows whitelisted runtime reads but rejects public writes", () => { + expect(() => + validatePluginRuntimeQuery( + "SELECT r.id FROM plugin_test.rows r JOIN public.issues i ON i.id = r.issue_id", + "plugin_test", + ["issues"], + ) + ).not.toThrow(); + expect(() => + validatePluginRuntimeExecute("UPDATE public.issues SET title = $1", "plugin_test") + ).toThrow(/namespace/i); + }); + + it("targets anonymous DO blocks without rejecting do-prefixed aliases", () => { + expect(() => + validatePluginRuntimeQuery( + "SELECT EXTRACT(DOW FROM created_at) AS do_flag FROM plugin_test.rows", + "plugin_test", + ) + ).not.toThrow(); + expect(() => + validatePluginMigrationStatement("DO $$ BEGIN END $$;", "plugin_test") + ).toThrow(/disallowed/i); + }); +}); + +describeEmbeddedPostgres("plugin database namespaces", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + let packageRoots: string[] = []; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-plugin-db-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + for (const pluginKey of ["taskcore.dbtest", "taskcore.escape"]) { + const namespace = derivePluginDatabaseNamespace(pluginKey); + await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`)); + } + await db.delete(pluginMigrations); + await db.delete(pluginDatabaseNamespaces); + await db.delete(plugins); + await db.delete(issueRelations); + await db.delete(issues); + await db.delete(companies); + await Promise.all(packageRoots.map((root) => rm(root, { recursive: true, force: true }))); + packageRoots = []; + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function createPluginPackage(manifest: TaskcorePluginManifestV1, migrationSql: string) { + const packageRoot = await mkdtemp(path.join(os.tmpdir(), "taskcore-plugin-package-")); + packageRoots.push(packageRoot); + const migrationsDir = path.join(packageRoot, manifest.database!.migrationsDir); + await mkdir(migrationsDir, { recursive: true }); + await writeFile(path.join(migrationsDir, "001_init.sql"), migrationSql, "utf8"); + return packageRoot; + } + + async function installPluginRecord(manifest: TaskcorePluginManifestV1) { + const pluginId = randomUUID(); + await db.insert(plugins).values({ + id: pluginId, + pluginKey: manifest.id, + packageName: manifest.id, + version: manifest.version, + apiVersion: manifest.apiVersion, + categories: manifest.categories, + manifestJson: manifest, + status: "installed", + installOrder: 1, + }); + return pluginId; + } + + function manifest(pluginKey = "taskcore.dbtest"): TaskcorePluginManifestV1 { + return { + id: pluginKey, + apiVersion: 1, + version: "1.0.0", + displayName: "DB Test", + description: "Exercises restricted plugin database access.", + author: "Taskcore", + categories: ["automation"], + capabilities: [ + "database.namespace.migrate", + "database.namespace.read", + "database.namespace.write", + ], + entrypoints: { worker: "./dist/worker.js" }, + database: { + migrationsDir: "migrations", + coreReadTables: ["issues"], + }, + }; + } + + it("applies migrations once and allows whitelisted core joins at runtime", async () => { + const pluginManifest = manifest(); + const namespace = derivePluginDatabaseNamespace(pluginManifest.id); + const packageRoot = await createPluginPackage( + pluginManifest, + ` + CREATE TABLE ${namespace}.mission_rows ( + id uuid PRIMARY KEY, + issue_id uuid NOT NULL REFERENCES public.issues(id), + label text NOT NULL + ); + `, + ); + const pluginId = await installPluginRecord(pluginManifest); + const companyId = randomUUID(); + const issueId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: "TST", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Joined issue", + status: "todo", + priority: "medium", + identifier: "TST-1", + }); + + const pluginDb = pluginDatabaseService(db); + await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot); + await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot); + + await pluginDb.execute( + pluginId, + `INSERT INTO ${namespace}.mission_rows (id, issue_id, label) VALUES ($1, $2, $3)`, + [randomUUID(), issueId, "alpha"], + ); + const rows = await pluginDb.query<{ label: string; title: string }>( + pluginId, + `SELECT m.label, i.title FROM ${namespace}.mission_rows m JOIN public.issues i ON i.id = m.issue_id`, + ); + expect(rows).toEqual([{ label: "alpha", title: "Joined issue" }]); + + const migrations = await db + .select() + .from(pluginMigrations) + .where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied"))); + expect(migrations).toHaveLength(1); + }); + + it("rejects runtime writes to public core tables", async () => { + const pluginManifest = manifest(); + const namespace = derivePluginDatabaseNamespace(pluginManifest.id); + const packageRoot = await createPluginPackage( + pluginManifest, + `CREATE TABLE ${namespace}.notes (id uuid PRIMARY KEY, body text NOT NULL);`, + ); + const pluginId = await installPluginRecord(pluginManifest); + const pluginDb = pluginDatabaseService(db); + await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot); + + await expect( + pluginDb.execute(pluginId, "UPDATE public.issues SET title = $1", ["bad"]), + ).rejects.toThrow(/plugin namespace/i); + }); + + it("records a failed migration when SQL escapes the plugin namespace", async () => { + const pluginManifest = manifest("taskcore.escape"); + const packageRoot = await createPluginPackage( + pluginManifest, + "CREATE TABLE public.plugin_escape (id uuid PRIMARY KEY);", + ); + const pluginId = await installPluginRecord(pluginManifest); + + await expect( + pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot), + ).rejects.toThrow(/public\.plugin_escape|public/i); + + const [migration] = await db + .select() + .from(pluginMigrations) + .where(eq(pluginMigrations.pluginId, pluginId)); + expect(migration?.status).toBe("failed"); + }); + + it("rejects checksum changes for already applied migrations", async () => { + const pluginManifest = manifest(); + const namespace = derivePluginDatabaseNamespace(pluginManifest.id); + const packageRoot = await createPluginPackage( + pluginManifest, + `CREATE TABLE ${namespace}.checksum_rows (id uuid PRIMARY KEY);`, + ); + const pluginId = await installPluginRecord(pluginManifest); + const pluginDb = pluginDatabaseService(db); + await pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot); + + await writeFile( + path.join(packageRoot, "migrations", "001_init.sql"), + `CREATE TABLE ${namespace}.checksum_rows (id uuid PRIMARY KEY, note text);`, + "utf8", + ); + + await expect(pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot)) + .rejects.toThrow(/checksum mismatch/i); + }); +}); diff --git a/server/src/__tests__/plugin-orchestration-apis.test.ts b/server/src/__tests__/plugin-orchestration-apis.test.ts new file mode 100644 index 0000000..9657d7e --- /dev/null +++ b/server/src/__tests__/plugin-orchestration-apis.test.ts @@ -0,0 +1,372 @@ +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agentWakeupRequests, + agents, + companies, + costEvents, + createDb, + heartbeatRuns, + issueRelations, + issues, +} from "@taskcore/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { buildHostServices } from "../services/plugin-host-services.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +function createEventBusStub() { + return { + forPlugin() { + return { + emit: async () => {}, + subscribe: () => {}, + }; + }, + } as any; +} + +function issuePrefix(id: string) { + return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`; +} + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres plugin orchestration API tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("plugin orchestration APIs", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-plugin-orchestration-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(costEvents); + await db.delete(heartbeatRuns); + await db.delete(agentWakeupRequests); + await db.delete(issueRelations); + await db.delete(issues); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompanyAndAgent() { + const companyId = randomUUID(); + const agentId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: issuePrefix(companyId), + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Engineer", + role: "engineer", + status: "idle", + adapterType: "process", + adapterConfig: { command: "true" }, + runtimeConfig: {}, + permissions: {}, + }); + return { companyId, agentId }; + } + + it("creates plugin-origin issues with full orchestration fields and audit activity", async () => { + const { companyId, agentId } = await seedCompanyAndAgent(); + const blockerIssueId = randomUUID(); + const originRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: originRunId, + companyId, + agentId, + status: "running", + invocationSource: "assignment", + contextSnapshot: { issueId: blockerIssueId }, + }); + await db.insert(issues).values({ + id: blockerIssueId, + companyId, + title: "Blocker", + status: "todo", + priority: "medium", + identifier: `${issuePrefix(companyId)}-blocker`, + }); + + const services = buildHostServices(db, "plugin-record-id", "taskcore.missions", createEventBusStub()); + const issue = await services.issues.create({ + companyId, + title: "Plugin child issue", + status: "todo", + assigneeAgentId: agentId, + billingCode: "mission:alpha", + originId: "mission-alpha", + blockedByIssueIds: [blockerIssueId], + actorAgentId: agentId, + actorRunId: originRunId, + }); + + const [stored] = await db.select().from(issues).where(eq(issues.id, issue.id)); + expect(stored?.originKind).toBe("plugin:taskcore.missions"); + expect(stored?.originId).toBe("mission-alpha"); + expect(stored?.billingCode).toBe("mission:alpha"); + expect(stored?.assigneeAgentId).toBe(agentId); + expect(stored?.createdByAgentId).toBe(agentId); + expect(stored?.originRunId).toBe(originRunId); + + const [relation] = await db + .select() + .from(issueRelations) + .where(and(eq(issueRelations.issueId, blockerIssueId), eq(issueRelations.relatedIssueId, issue.id))); + expect(relation?.type).toBe("blocks"); + + const activities = await db + .select() + .from(activityLog) + .where(and(eq(activityLog.entityType, "issue"), eq(activityLog.entityId, issue.id))); + expect(activities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + actorType: "plugin", + actorId: "plugin-record-id", + action: "issue.created", + agentId, + details: expect.objectContaining({ + sourcePluginId: "plugin-record-id", + sourcePluginKey: "taskcore.missions", + initiatingActorType: "agent", + initiatingActorId: agentId, + initiatingRunId: originRunId, + }), + }), + ]), + ); + }); + + it("enforces plugin origin namespaces", async () => { + const { companyId } = await seedCompanyAndAgent(); + const services = buildHostServices(db, "plugin-record-id", "taskcore.missions", createEventBusStub()); + + const featureIssue = await services.issues.create({ + companyId, + title: "Feature issue", + originKind: "plugin:taskcore.missions:feature", + originId: "mission-alpha:feature-1", + }); + expect(featureIssue.originKind).toBe("plugin:taskcore.missions:feature"); + + await expect( + services.issues.create({ + companyId, + title: "Spoofed issue", + originKind: "plugin:other.plugin:feature", + }), + ).rejects.toThrow("Plugin may only use originKind values under plugin:taskcore.missions"); + + await expect( + services.issues.update({ + issueId: featureIssue.id, + companyId, + patch: { originKind: "plugin:other.plugin:feature" }, + }), + ).rejects.toThrow("Plugin may only use originKind values under plugin:taskcore.missions"); + }); + + it("asserts checkout ownership for run-scoped plugin actions", async () => { + const { companyId, agentId } = await seedCompanyAndAgent(); + const issueId = randomUUID(); + const runId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + status: "running", + invocationSource: "assignment", + contextSnapshot: { issueId }, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Checked out issue", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + checkoutRunId: runId, + executionRunId: runId, + }); + + const services = buildHostServices(db, "plugin-record-id", "taskcore.missions", createEventBusStub()); + await expect( + services.issues.assertCheckoutOwner({ + issueId, + companyId, + actorAgentId: agentId, + actorRunId: runId, + }), + ).resolves.toMatchObject({ + issueId, + status: "in_progress", + assigneeAgentId: agentId, + checkoutRunId: runId, + }); + }); + + it("refuses plugin wakeups for issues with unresolved blockers", async () => { + const { companyId, agentId } = await seedCompanyAndAgent(); + const blockerIssueId = randomUUID(); + const blockedIssueId = randomUUID(); + await db.insert(issues).values([ + { + id: blockerIssueId, + companyId, + title: "Unresolved blocker", + status: "todo", + priority: "medium", + }, + { + id: blockedIssueId, + companyId, + title: "Blocked issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + }, + ]); + await db.insert(issueRelations).values({ + companyId, + issueId: blockerIssueId, + relatedIssueId: blockedIssueId, + type: "blocks", + }); + + const services = buildHostServices(db, "plugin-record-id", "taskcore.missions", createEventBusStub()); + await expect( + services.issues.requestWakeup({ + issueId: blockedIssueId, + companyId, + reason: "mission_advance", + }), + ).rejects.toThrow("Issue is blocked by unresolved blockers"); + }); + + it("narrows orchestration cost summaries by subtree and billing code", async () => { + const { companyId, agentId } = await seedCompanyAndAgent(); + const rootIssueId = randomUUID(); + const childIssueId = randomUUID(); + const unrelatedIssueId = randomUUID(); + await db.insert(issues).values([ + { + id: rootIssueId, + companyId, + title: "Root mission", + status: "todo", + priority: "medium", + billingCode: "mission:alpha", + }, + { + id: childIssueId, + companyId, + parentId: rootIssueId, + title: "Child mission", + status: "todo", + priority: "medium", + billingCode: "mission:alpha", + }, + { + id: unrelatedIssueId, + companyId, + title: "Different mission", + status: "todo", + priority: "medium", + billingCode: "mission:alpha", + }, + ]); + await db.insert(costEvents).values([ + { + companyId, + agentId, + issueId: rootIssueId, + billingCode: "mission:alpha", + provider: "test", + model: "unit", + inputTokens: 10, + cachedInputTokens: 1, + outputTokens: 2, + costCents: 100, + occurredAt: new Date(), + }, + { + companyId, + agentId, + issueId: childIssueId, + billingCode: "mission:alpha", + provider: "test", + model: "unit", + inputTokens: 20, + cachedInputTokens: 2, + outputTokens: 4, + costCents: 200, + occurredAt: new Date(), + }, + { + companyId, + agentId, + issueId: childIssueId, + billingCode: "mission:beta", + provider: "test", + model: "unit", + inputTokens: 30, + cachedInputTokens: 3, + outputTokens: 6, + costCents: 300, + occurredAt: new Date(), + }, + { + companyId, + agentId, + issueId: unrelatedIssueId, + billingCode: "mission:alpha", + provider: "test", + model: "unit", + inputTokens: 40, + cachedInputTokens: 4, + outputTokens: 8, + costCents: 400, + occurredAt: new Date(), + }, + ]); + + const services = buildHostServices(db, "plugin-record-id", "taskcore.missions", createEventBusStub()); + const summary = await services.issues.getOrchestrationSummary({ + companyId, + issueId: rootIssueId, + includeSubtree: true, + }); + + expect(new Set(summary.subtreeIssueIds)).toEqual(new Set([rootIssueId, childIssueId])); + expect(summary.costs).toMatchObject({ + billingCode: "mission:alpha", + costCents: 300, + inputTokens: 30, + cachedInputTokens: 3, + outputTokens: 6, + }); + }); +}); diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts new file mode 100644 index 0000000..f0df81c --- /dev/null +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -0,0 +1,581 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockRegistry = vi.hoisted(() => ({ + getById: vi.fn(), + getByKey: vi.fn(), + upsertConfig: vi.fn(), +})); + +const mockLifecycle = vi.hoisted(() => ({ + load: vi.fn(), + upgrade: vi.fn(), + unload: vi.fn(), + enable: vi.fn(), + disable: vi.fn(), +})); + +function registerRouteMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + + vi.doMock("../services/plugin-registry.js", () => ({ + pluginRegistryService: () => mockRegistry, + })); + + vi.doMock("../services/plugin-lifecycle.js", () => ({ + pluginLifecycleManager: () => mockLifecycle, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: vi.fn(), + })); + + vi.doMock("../services/live-events.js", () => ({ + publishGlobalLiveEvent: vi.fn(), + })); +} + +async function createApp( + actor: Record, + loaderOverrides: Record = {}, + routeOverrides: { + db?: unknown; + jobDeps?: unknown; + toolDeps?: unknown; + bridgeDeps?: unknown; + } = {}, +) { + const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/plugins.js"), + vi.importActual("../middleware/index.js"), + ]); + + const loader = { + installPlugin: vi.fn(), + ...loaderOverrides, + }; + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor as typeof req.actor; + next(); + }); + app.use("/api", pluginRoutes( + (routeOverrides.db ?? {}) as never, + loader as never, + routeOverrides.jobDeps as never, + undefined, + routeOverrides.toolDeps as never, + routeOverrides.bridgeDeps as never, + )); + app.use(errorHandler); + + return { app, loader }; +} + +function createSelectQueueDb(rows: Array>>) { + return { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => Promise.resolve(rows.shift() ?? [])), + })), + })), + })), + }; +} + +const companyA = "22222222-2222-4222-8222-222222222222"; +const companyB = "33333333-3333-4333-8333-333333333333"; +const agentA = "44444444-4444-4444-8444-444444444444"; +const runA = "55555555-5555-4555-8555-555555555555"; +const projectA = "66666666-6666-4666-8666-666666666666"; +const pluginId = "11111111-1111-4111-8111-111111111111"; + +function boardActor(overrides: Record = {}) { + return { + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: [companyA], + ...overrides, + }; +} + +function readyPlugin() { + mockRegistry.getById.mockResolvedValue({ + id: pluginId, + pluginKey: "taskcore.example", + version: "1.0.0", + status: "ready", + }); +} + +describe("plugin install and upgrade authz", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/plugin-config-validator.js"); + vi.doUnmock("../services/plugin-loader.js"); + vi.doUnmock("../services/plugin-registry.js"); + vi.doUnmock("../services/plugin-lifecycle.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/live-events.js"); + vi.doUnmock("../routes/plugins.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerRouteMocks(); + vi.resetAllMocks(); + }); + + it("rejects plugin installation for non-admin board users", async () => { + const { app, loader } = await createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }); + + const res = await request(app) + .post("/api/plugins/install") + .send({ packageName: "taskcore-plugin-example" }); + + expect(res.status).toBe(403); + expect(loader.installPlugin).not.toHaveBeenCalled(); + }, 20_000); + + it("allows instance admins to install plugins", async () => { + const pluginId = "11111111-1111-4111-8111-111111111111"; + const pluginKey = "taskcore.example"; + const discovered = { + manifest: { + id: pluginKey, + }, + }; + + mockRegistry.getByKey.mockResolvedValue({ + id: pluginId, + pluginKey, + packageName: "taskcore-plugin-example", + version: "1.0.0", + }); + mockRegistry.getById.mockResolvedValue({ + id: pluginId, + pluginKey, + packageName: "taskcore-plugin-example", + version: "1.0.0", + }); + mockLifecycle.load.mockResolvedValue(undefined); + + const { app, loader } = await createApp( + { + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + companyIds: [], + }, + { installPlugin: vi.fn().mockResolvedValue(discovered) }, + ); + + const res = await request(app) + .post("/api/plugins/install") + .send({ packageName: "taskcore-plugin-example" }); + + expect(res.status).toBe(200); + expect(loader.installPlugin).toHaveBeenCalledWith({ + packageName: "taskcore-plugin-example", + version: undefined, + }); + expect(mockLifecycle.load).toHaveBeenCalledWith(pluginId); + }, 20_000); + + it("rejects plugin upgrades for non-admin board users", async () => { + const pluginId = "11111111-1111-4111-8111-111111111111"; + const { app } = await createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/upgrade`) + .send({}); + + expect(res.status).toBe(403); + expect(mockRegistry.getById).not.toHaveBeenCalled(); + expect(mockLifecycle.upgrade).not.toHaveBeenCalled(); + }, 20_000); + + it.each([ + ["delete", "delete", "/api/plugins/11111111-1111-4111-8111-111111111111", undefined], + ["enable", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/enable", {}], + ["disable", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/disable", {}], + ["config", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/config", { configJson: {} }], + ] as const)("rejects plugin %s for non-admin board users", async (_name, method, path, body) => { + const { app } = await createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }); + + const req = method === "delete" ? request(app).delete(path) : request(app).post(path).send(body); + const res = await req; + + expect(res.status).toBe(403); + expect(mockRegistry.getById).not.toHaveBeenCalled(); + expect(mockRegistry.upsertConfig).not.toHaveBeenCalled(); + expect(mockLifecycle.unload).not.toHaveBeenCalled(); + expect(mockLifecycle.enable).not.toHaveBeenCalled(); + expect(mockLifecycle.disable).not.toHaveBeenCalled(); + }, 20_000); + + it("allows instance admins to upgrade plugins", async () => { + const pluginId = "11111111-1111-4111-8111-111111111111"; + mockRegistry.getById.mockResolvedValue({ + id: pluginId, + pluginKey: "taskcore.example", + version: "1.0.0", + }); + mockLifecycle.upgrade.mockResolvedValue({ + id: pluginId, + version: "1.1.0", + }); + + const { app } = await createApp({ + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + companyIds: [], + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/upgrade`) + .send({ version: "1.1.0" }); + + expect(res.status).toBe(200); + expect(mockLifecycle.upgrade).toHaveBeenCalledWith(pluginId, "1.1.0"); + }, 20_000); +}); + +describe("scoped plugin API routes", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/plugin-config-validator.js"); + vi.doUnmock("../services/plugin-loader.js"); + vi.doUnmock("../services/plugin-registry.js"); + vi.doUnmock("../services/plugin-lifecycle.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/live-events.js"); + vi.doUnmock("../routes/plugins.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerRouteMocks(); + vi.resetAllMocks(); + }); + + it("dispatches manifest-declared scoped routes after company access checks", async () => { + const pluginId = "11111111-1111-4111-8111-111111111111"; + const workerManager = { + call: vi.fn().mockResolvedValue({ + status: 202, + body: { ok: true }, + }), + }; + mockRegistry.getById.mockResolvedValue(null); + mockRegistry.getByKey.mockResolvedValue({ + id: pluginId, + pluginKey: "taskcore.example", + version: "1.0.0", + status: "ready", + manifestJson: { + id: "taskcore.example", + capabilities: ["api.routes.register"], + apiRoutes: [ + { + routeKey: "smoke", + method: "GET", + path: "/smoke", + auth: "board-or-agent", + capability: "api.routes.register", + companyResolution: { from: "query", key: "companyId" }, + }, + ], + }, + }); + + const { app } = await createApp( + { + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }, + {}, + { bridgeDeps: { workerManager } }, + ); + + const res = await request(app) + .get("/api/plugins/taskcore.example/api/smoke") + .query({ companyId: "company-1" }); + + expect(res.status).toBe(202); + expect(res.body).toEqual({ ok: true }); + expect(workerManager.call).toHaveBeenCalledWith( + pluginId, + "handleApiRequest", + expect.objectContaining({ + routeKey: "smoke", + method: "GET", + companyId: "company-1", + query: { companyId: "company-1" }, + }), + ); + }, 20_000); +}); + +describe("plugin tool and bridge authz", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/plugin-config-validator.js"); + vi.doUnmock("../services/plugin-loader.js"); + vi.doUnmock("../services/plugin-registry.js"); + vi.doUnmock("../services/plugin-lifecycle.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/live-events.js"); + vi.doUnmock("../routes/plugins.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerRouteMocks(); + vi.resetAllMocks(); + }); + + it("rejects tool execution when the board user cannot access runContext.companyId", async () => { + const executeTool = vi.fn(); + const getTool = vi.fn(); + const { app } = await createApp(boardActor(), {}, { + toolDeps: { + toolDispatcher: { + listToolsForAgent: vi.fn(), + getTool, + executeTool, + }, + }, + }); + + const res = await request(app) + .post("/api/plugins/tools/execute") + .send({ + tool: "taskcore.example:search", + parameters: {}, + runContext: { + agentId: agentA, + runId: runA, + companyId: companyB, + projectId: projectA, + }, + }); + + expect(res.status).toBe(403); + expect(getTool).not.toHaveBeenCalled(); + expect(executeTool).not.toHaveBeenCalled(); + }); + + it.each([ + [ + "agentId", + [ + [{ companyId: companyB }], + ], + ], + [ + "runId company", + [ + [{ companyId: companyA }], + [{ companyId: companyB, agentId: agentA }], + ], + ], + [ + "runId agent", + [ + [{ companyId: companyA }], + [{ companyId: companyA, agentId: "77777777-7777-4777-8777-777777777777" }], + ], + ], + [ + "projectId", + [ + [{ companyId: companyA }], + [{ companyId: companyA, agentId: agentA }], + [{ companyId: companyB }], + ], + ], + ])("rejects tool execution when runContext.%s is outside the company scope", async (_case, rows) => { + const executeTool = vi.fn(); + const { app } = await createApp(boardActor(), {}, { + db: createSelectQueueDb(rows), + toolDeps: { + toolDispatcher: { + listToolsForAgent: vi.fn(), + getTool: vi.fn(() => ({ name: "taskcore.example:search" })), + executeTool, + }, + }, + }); + + const res = await request(app) + .post("/api/plugins/tools/execute") + .send({ + tool: "taskcore.example:search", + parameters: {}, + runContext: { + agentId: agentA, + runId: runA, + companyId: companyA, + projectId: projectA, + }, + }); + + expect(res.status).toBe(403); + expect(executeTool).not.toHaveBeenCalled(); + }); + + it("allows tool execution when agent, run, and project all belong to runContext.companyId", async () => { + const executeTool = vi.fn().mockResolvedValue({ content: "ok" }); + const { app } = await createApp(boardActor(), {}, { + db: createSelectQueueDb([ + [{ companyId: companyA }], + [{ companyId: companyA, agentId: agentA }], + [{ companyId: companyA }], + ]), + toolDeps: { + toolDispatcher: { + listToolsForAgent: vi.fn(), + getTool: vi.fn(() => ({ name: "taskcore.example:search" })), + executeTool, + }, + }, + }); + + const res = await request(app) + .post("/api/plugins/tools/execute") + .send({ + tool: "taskcore.example:search", + parameters: { q: "test" }, + runContext: { + agentId: agentA, + runId: runA, + companyId: companyA, + projectId: projectA, + }, + }); + + expect(res.status).toBe(200); + expect(executeTool).toHaveBeenCalledWith( + "taskcore.example:search", + { q: "test" }, + { + agentId: agentA, + runId: runA, + companyId: companyA, + projectId: projectA, + }, + ); + }); + + it.each([ + ["legacy data", "post", `/api/plugins/${pluginId}/bridge/data`, { key: "health" }], + ["legacy action", "post", `/api/plugins/${pluginId}/bridge/action`, { key: "sync" }], + ["url data", "post", `/api/plugins/${pluginId}/data/health`, {}], + ["url action", "post", `/api/plugins/${pluginId}/actions/sync`, {}], + ] as const)("rejects %s bridge calls without companyId for non-admin users", async (_name, _method, path, body) => { + readyPlugin(); + const call = vi.fn(); + const { app } = await createApp(boardActor(), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(path) + .send(body); + + expect(res.status).toBe(403); + expect(call).not.toHaveBeenCalled(); + }); + + it("allows omitted-company bridge calls for instance admins as global plugin actions", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(boardActor({ + userId: "admin-1", + isInstanceAdmin: true, + companyIds: [], + }), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/actions/sync`) + .send({}); + + expect(res.status).toBe(200); + expect(call).toHaveBeenCalledWith(pluginId, "performAction", { + key: "sync", + params: {}, + renderEnvironment: null, + }); + }); + + it("rejects manual job triggers for non-admin board users", async () => { + const scheduler = { triggerJob: vi.fn() }; + const jobStore = { getJobByIdForPlugin: vi.fn() }; + const { app } = await createApp(boardActor(), {}, { + jobDeps: { scheduler, jobStore }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/jobs/job-1/trigger`) + .send({}); + + expect(res.status).toBe(403); + expect(scheduler.triggerJob).not.toHaveBeenCalled(); + expect(jobStore.getJobByIdForPlugin).not.toHaveBeenCalled(); + }, 15_000); + + it("allows manual job triggers for instance admins", async () => { + readyPlugin(); + const scheduler = { triggerJob: vi.fn().mockResolvedValue({ runId: "run-1", jobId: "job-1" }) }; + const jobStore = { getJobByIdForPlugin: vi.fn().mockResolvedValue({ id: "job-1" }) }; + const { app } = await createApp(boardActor({ + userId: "admin-1", + isInstanceAdmin: true, + companyIds: [], + }), {}, { + jobDeps: { scheduler, jobStore }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/jobs/job-1/trigger`) + .send({}); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ runId: "run-1", jobId: "job-1" }); + expect(scheduler.triggerJob).toHaveBeenCalledWith("job-1", "manual"); + }); +}); diff --git a/server/src/__tests__/plugin-scoped-api-routes.test.ts b/server/src/__tests__/plugin-scoped-api-routes.test.ts new file mode 100644 index 0000000..c250efe --- /dev/null +++ b/server/src/__tests__/plugin-scoped-api-routes.test.ts @@ -0,0 +1,461 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { pluginManifestV1Schema, type TaskcorePluginManifestV1 } from "@taskcore/shared"; + +const mockRegistry = vi.hoisted(() => ({ + getById: vi.fn(), + getByKey: vi.fn(), +})); + +const mockLifecycle = vi.hoisted(() => ({ + load: vi.fn(), + upgrade: vi.fn(), +})); + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + assertCheckoutOwner: vi.fn(), +})); + +vi.mock("../services/plugin-registry.js", () => ({ + pluginRegistryService: () => mockRegistry, +})); + +vi.mock("../services/plugin-lifecycle.js", () => ({ + pluginLifecycleManager: () => mockLifecycle, +})); + +vi.mock("../services/issues.js", () => ({ + issueService: () => mockIssueService, +})); + +vi.mock("../services/activity-log.js", () => ({ + logActivity: vi.fn(), +})); + +vi.mock("../services/live-events.js", () => ({ + publishGlobalLiveEvent: vi.fn(), +})); + +function registerModuleMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + + vi.doMock("../services/plugin-registry.js", () => ({ + pluginRegistryService: () => mockRegistry, + })); + + vi.doMock("../services/plugin-lifecycle.js", () => ({ + pluginLifecycleManager: () => mockLifecycle, + })); + + vi.doMock("../services/issues.js", () => ({ + issueService: () => mockIssueService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: vi.fn(), + })); + + vi.doMock("../services/live-events.js", () => ({ + publishGlobalLiveEvent: vi.fn(), + })); +} + +function manifest(apiRoutes: NonNullable): TaskcorePluginManifestV1 { + return { + id: "taskcore.scoped-api-test", + apiVersion: 1, + version: "1.0.0", + displayName: "Scoped API Test", + description: "Test plugin for scoped API routes", + author: "Taskcore", + categories: ["automation"], + capabilities: ["api.routes.register"], + entrypoints: { worker: "dist/worker.js" }, + apiRoutes, + }; +} + +async function createApp(input: { + actor: Record; + plugin?: Record | null; + workerRunning?: boolean; + workerResult?: unknown; +}) { + const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/plugins.js"), + vi.importActual("../middleware/index.js"), + ]); + + const workerManager = { + isRunning: vi.fn().mockReturnValue(input.workerRunning ?? true), + call: vi.fn().mockResolvedValue(input.workerResult ?? { status: 200, body: { ok: true } }), + }; + + mockRegistry.getById.mockResolvedValue(input.plugin ?? null); + mockRegistry.getByKey.mockResolvedValue(input.plugin ?? null); + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = input.actor as typeof req.actor; + next(); + }); + app.use( + "/api", + pluginRoutes( + {} as never, + { installPlugin: vi.fn() } as never, + undefined, + undefined, + undefined, + { workerManager } as never, + ), + ); + app.use(errorHandler); + + return { app, workerManager }; +} + +describe("plugin scoped API routes", () => { + const pluginId = "11111111-1111-4111-8111-111111111111"; + const companyId = "22222222-2222-4222-8222-222222222222"; + const agentId = "33333333-3333-4333-8333-333333333333"; + const runId = "44444444-4444-4444-8444-444444444444"; + const issueId = "55555555-5555-4555-8555-555555555555"; + + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/plugin-registry.js"); + vi.doUnmock("../services/plugin-lifecycle.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/live-events.js"); + vi.doUnmock("../routes/plugins.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.resetAllMocks(); + mockIssueService.getById.mockResolvedValue(null); + mockIssueService.assertCheckoutOwner.mockResolvedValue({ + id: issueId, + status: "in_progress", + assigneeAgentId: agentId, + checkoutRunId: runId, + adoptedFromRunId: null, + }); + }); + + it("dispatches a board GET route with params, query, actor, and company context", async () => { + const apiRoutes = manifest([ + { + routeKey: "summary.get", + method: "GET", + path: "/companies/:companySlug/summary", + auth: "board", + capability: "api.routes.register", + companyResolution: { from: "query", key: "companyId" }, + }, + ]); + const { app, workerManager } = await createApp({ + actor: { + type: "board", + userId: "user-1", + source: "local_implicit", + isInstanceAdmin: true, + }, + plugin: { + id: pluginId, + pluginKey: apiRoutes.id, + status: "ready", + manifestJson: apiRoutes, + }, + workerResult: { status: 201, body: { handled: true } }, + }); + + const res = await request(app) + .get(`/api/plugins/${pluginId}/api/companies/acme/summary?companyId=${companyId}&view=compact`) + .set("Authorization", "Bearer should-not-forward"); + + expect(res.status).toBe(201); + expect(res.body).toEqual({ handled: true }); + expect(workerManager.call).toHaveBeenCalledWith(pluginId, "handleApiRequest", expect.objectContaining({ + routeKey: "summary.get", + method: "GET", + params: { companySlug: "acme" }, + query: { companyId, view: "compact" }, + companyId, + actor: expect.objectContaining({ actorType: "user", actorId: "user-1" }), + })); + expect(workerManager.call.mock.calls[0]?.[2].headers.authorization).toBeUndefined(); + }); + + it("only forwards allowlisted response headers from plugin routes", async () => { + const apiRoutes = manifest([ + { + routeKey: "summary.get", + method: "GET", + path: "/companies/:companySlug/summary", + auth: "board", + capability: "api.routes.register", + companyResolution: { from: "query", key: "companyId" }, + }, + ]); + const { app } = await createApp({ + actor: { + type: "board", + userId: "user-1", + source: "local_implicit", + isInstanceAdmin: true, + }, + plugin: { + id: pluginId, + pluginKey: apiRoutes.id, + status: "ready", + manifestJson: apiRoutes, + }, + workerResult: { + status: 200, + body: { handled: true }, + headers: { + "cache-control": "no-store", + "content-security-policy": "default-src 'none'", + location: "https://example.invalid", + "x-request-id": "plugin-request", + }, + }, + }); + + const res = await request(app) + .get(`/api/plugins/${pluginId}/api/companies/acme/summary?companyId=${companyId}`); + + expect(res.status).toBe(200); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(res.headers["x-request-id"]).toBe("plugin-request"); + expect(res.headers["content-security-policy"]).toBeUndefined(); + expect(res.headers.location).toBeUndefined(); + }); + + it("enforces agent checkout ownership before dispatching issue-scoped POST routes", async () => { + const apiRoutes = manifest([ + { + routeKey: "issue.advance", + method: "POST", + path: "/issues/:issueId/advance", + auth: "agent", + capability: "api.routes.register", + checkoutPolicy: "required-for-agent-in-progress", + companyResolution: { from: "issue", param: "issueId" }, + }, + ]); + mockIssueService.getById.mockResolvedValue({ + id: issueId, + companyId, + status: "in_progress", + assigneeAgentId: agentId, + }); + const { app, workerManager } = await createApp({ + actor: { + type: "agent", + agentId, + companyId, + runId, + source: "agent_key", + }, + plugin: { + id: pluginId, + pluginKey: apiRoutes.id, + status: "ready", + manifestJson: apiRoutes, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/api/issues/${issueId}/advance`) + .send({ step: "next" }); + + expect(res.status).toBe(200); + expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith(issueId, agentId, runId); + expect(workerManager.call).toHaveBeenCalledWith(pluginId, "handleApiRequest", expect.objectContaining({ + routeKey: "issue.advance", + params: { issueId }, + body: { step: "next" }, + actor: expect.objectContaining({ actorType: "agent", agentId, runId }), + companyId, + })); + }); + + it("rejects checkout-protected agent routes without a run id before worker dispatch", async () => { + const apiRoutes = manifest([ + { + routeKey: "issue.advance", + method: "POST", + path: "/issues/:issueId/advance", + auth: "agent", + capability: "api.routes.register", + checkoutPolicy: "required-for-agent-in-progress", + companyResolution: { from: "issue", param: "issueId" }, + }, + ]); + mockIssueService.getById.mockResolvedValue({ + id: issueId, + companyId, + status: "in_progress", + assigneeAgentId: agentId, + }); + const { app, workerManager } = await createApp({ + actor: { + type: "agent", + agentId, + companyId, + source: "agent_key", + }, + plugin: { + id: pluginId, + pluginKey: apiRoutes.id, + status: "ready", + manifestJson: apiRoutes, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/api/issues/${issueId}/advance`) + .send({}); + + expect(res.status).toBe(401); + expect(workerManager.call).not.toHaveBeenCalled(); + }); + + it("rejects checkout-protected agent routes when the active checkout belongs to another run", async () => { + const apiRoutes = manifest([ + { + routeKey: "issue.advance", + method: "POST", + path: "/issues/:issueId/advance", + auth: "agent", + capability: "api.routes.register", + checkoutPolicy: "always-for-agent", + companyResolution: { from: "issue", param: "issueId" }, + }, + ]); + mockIssueService.getById.mockResolvedValue({ + id: issueId, + companyId, + status: "in_progress", + assigneeAgentId: agentId, + }); + const conflict = new Error("Issue run ownership conflict") as Error & { status?: number }; + conflict.status = 409; + mockIssueService.assertCheckoutOwner.mockRejectedValue(conflict); + const { app, workerManager } = await createApp({ + actor: { + type: "agent", + agentId, + companyId, + runId, + source: "agent_key", + }, + plugin: { + id: pluginId, + pluginKey: apiRoutes.id, + status: "ready", + manifestJson: apiRoutes, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/api/issues/${issueId}/advance`) + .send({}); + + expect(res.status).toBe(409); + expect(workerManager.call).not.toHaveBeenCalled(); + }); + + it("returns a clear error for disabled plugins without worker dispatch", async () => { + const apiRoutes = manifest([ + { + routeKey: "summary.get", + method: "GET", + path: "/summary", + auth: "board", + capability: "api.routes.register", + companyResolution: { from: "query", key: "companyId" }, + }, + ]); + const { app, workerManager } = await createApp({ + actor: { + type: "board", + userId: "user-1", + source: "local_implicit", + isInstanceAdmin: true, + }, + plugin: { + id: pluginId, + pluginKey: apiRoutes.id, + status: "disabled", + manifestJson: apiRoutes, + }, + }); + + const res = await request(app) + .get(`/api/plugins/${pluginId}/api/summary?companyId=${companyId}`); + + expect(res.status).toBe(503); + expect(res.body.error).toContain("disabled"); + expect(workerManager.call).not.toHaveBeenCalled(); + }); + + it("returns a clear error when a ready plugin has no running worker", async () => { + const apiRoutes = manifest([ + { + routeKey: "summary.get", + method: "GET", + path: "/summary", + auth: "board", + capability: "api.routes.register", + companyResolution: { from: "query", key: "companyId" }, + }, + ]); + const { app, workerManager } = await createApp({ + actor: { + type: "board", + userId: "user-1", + source: "local_implicit", + isInstanceAdmin: true, + }, + plugin: { + id: pluginId, + pluginKey: apiRoutes.id, + status: "ready", + manifestJson: apiRoutes, + }, + workerRunning: false, + }); + + const res = await request(app) + .get(`/api/plugins/${pluginId}/api/summary?companyId=${companyId}`); + + expect(res.status).toBe(503); + expect(res.body.error).toContain("worker is not running"); + expect(workerManager.call).not.toHaveBeenCalled(); + }); + + it("rejects manifest routes that try to claim core API paths", () => { + const result = pluginManifestV1Schema.safeParse(manifest([ + { + routeKey: "bad.shadow", + method: "POST", + path: "/api/issues/:issueId", + auth: "board", + capability: "api.routes.register", + }, + ])); + + expect(result.success).toBe(false); + if (result.success) throw new Error("Expected manifest validation to fail"); + expect(result.error.issues.map((issue) => issue.message).join("\n")).toContain( + "path must stay inside the plugin api namespace", + ); + }); +}); diff --git a/server/src/__tests__/plugin-sdk-orchestration-contract.test.ts b/server/src/__tests__/plugin-sdk-orchestration-contract.test.ts new file mode 100644 index 0000000..b58de4a --- /dev/null +++ b/server/src/__tests__/plugin-sdk-orchestration-contract.test.ts @@ -0,0 +1,240 @@ +import { randomUUID } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import type { Issue, TaskcorePluginManifestV1 } from "@taskcore/shared"; +import { createTestHarness } from "../../../packages/plugins/sdk/src/testing.js"; + +function manifest(capabilities: TaskcorePluginManifestV1["capabilities"]): TaskcorePluginManifestV1 { + return { + id: "taskcore.test-orchestration", + apiVersion: 1, + version: "0.1.0", + displayName: "Test Orchestration", + description: "Test plugin", + author: "Taskcore", + categories: ["automation"], + capabilities, + entrypoints: { worker: "./dist/worker.js" }, + }; +} + +function issue(input: Partial & Pick): Issue { + const now = new Date(); + return { + id: input.id, + companyId: input.companyId, + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: input.title, + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: null, + identifier: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: now, + updatedAt: now, + ...input, + }; +} + +describe("plugin SDK orchestration contract", () => { + it("supports expanded issue create fields and relation helpers", async () => { + const companyId = randomUUID(); + const blockerIssueId = randomUUID(); + const harness = createTestHarness({ + manifest: manifest(["issues.create", "issue.relations.read", "issue.relations.write", "issue.subtree.read"]), + }); + harness.seed({ + issues: [issue({ id: blockerIssueId, companyId, title: "Blocker" })], + }); + + const created = await harness.ctx.issues.create({ + companyId, + title: "Generated issue", + status: "todo", + assigneeUserId: "board-user", + billingCode: "mission:alpha", + originId: "mission-alpha", + blockedByIssueIds: [blockerIssueId], + }); + + expect(created.originKind).toBe("plugin:taskcore.test-orchestration"); + expect(created.originId).toBe("mission-alpha"); + expect(created.billingCode).toBe("mission:alpha"); + expect(created.assigneeUserId).toBe("board-user"); + + await expect(harness.ctx.issues.relations.get(created.id, companyId)).resolves.toEqual({ + blockedBy: [ + expect.objectContaining({ + id: blockerIssueId, + title: "Blocker", + }), + ], + blocks: [], + }); + + await expect(harness.ctx.issues.relations.removeBlockers(created.id, [blockerIssueId], companyId)).resolves.toEqual({ + blockedBy: [], + blocks: [], + }); + + await expect(harness.ctx.issues.relations.addBlockers(created.id, [blockerIssueId], companyId)).resolves.toEqual({ + blockedBy: [expect.objectContaining({ id: blockerIssueId })], + blocks: [], + }); + + await expect( + harness.ctx.issues.getSubtree(created.id, companyId, { includeRelations: true }), + ).resolves.toMatchObject({ + rootIssueId: created.id, + issueIds: [created.id], + relations: { + [created.id]: { + blockedBy: [expect.objectContaining({ id: blockerIssueId })], + }, + }, + }); + }); + + it("enforces plugin origin namespaces in the test harness", async () => { + const companyId = randomUUID(); + const harness = createTestHarness({ + manifest: manifest(["issues.create", "issues.update", "issues.read"]), + }); + + const created = await harness.ctx.issues.create({ + companyId, + title: "Generated issue", + originKind: "plugin:taskcore.test-orchestration:feature", + }); + + expect(created.originKind).toBe("plugin:taskcore.test-orchestration:feature"); + await expect( + harness.ctx.issues.list({ + companyId, + originKind: "plugin:taskcore.test-orchestration:feature", + }), + ).resolves.toHaveLength(1); + await expect( + harness.ctx.issues.create({ + companyId, + title: "Spoofed issue", + originKind: "plugin:other.plugin:feature", + }), + ).rejects.toThrow("Plugin may only use originKind values under plugin:taskcore.test-orchestration"); + await expect( + harness.ctx.issues.update( + created.id, + { originKind: "plugin:other.plugin:feature" }, + companyId, + ), + ).rejects.toThrow("Plugin may only use originKind values under plugin:taskcore.test-orchestration"); + }); + + it("enforces checkout and wakeup capabilities in the test harness", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const checkedOutIssueId = randomUUID(); + const harness = createTestHarness({ + manifest: manifest(["issues.checkout", "issues.wakeup", "issues.read"]), + }); + harness.seed({ + issues: [ + issue({ + id: checkedOutIssueId, + companyId, + title: "Checked out", + status: "in_progress", + assigneeAgentId: agentId, + checkoutRunId: runId, + }), + ], + }); + + await expect( + harness.ctx.issues.assertCheckoutOwner({ + issueId: checkedOutIssueId, + companyId, + actorAgentId: agentId, + actorRunId: runId, + }), + ).resolves.toMatchObject({ + issueId: checkedOutIssueId, + checkoutRunId: runId, + }); + + await expect( + harness.ctx.issues.requestWakeup(checkedOutIssueId, companyId, { + reason: "mission_advance", + }), + ).resolves.toMatchObject({ queued: true }); + + await expect( + harness.ctx.issues.requestWakeups([checkedOutIssueId], companyId, { + reason: "mission_advance", + idempotencyKeyPrefix: "mission:alpha", + }), + ).resolves.toEqual([ + expect.objectContaining({ + issueId: checkedOutIssueId, + queued: true, + }), + ]); + }); + + it("rejects wakeups when blockers are unresolved", async () => { + const companyId = randomUUID(); + const blockerIssueId = randomUUID(); + const blockedIssueId = randomUUID(); + const harness = createTestHarness({ + manifest: manifest(["issues.wakeup", "issues.read"]), + }); + harness.seed({ + issues: [ + issue({ id: blockerIssueId, companyId, title: "Unresolved blocker", status: "todo" }), + issue({ + id: blockedIssueId, + companyId, + title: "Blocked work", + status: "todo", + assigneeAgentId: randomUUID(), + blockedBy: [ + { + id: blockerIssueId, + identifier: null, + title: "Unresolved blocker", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + ], + }), + ], + }); + + await expect( + harness.ctx.issues.requestWakeup(blockedIssueId, companyId), + ).rejects.toThrow("Issue is blocked by unresolved blockers"); + }); +}); diff --git a/server/src/__tests__/quota-windows.test.ts b/server/src/__tests__/quota-windows.test.ts index bf498d1..2b7067d 100644 --- a/server/src/__tests__/quota-windows.test.ts +++ b/server/src/__tests__/quota-windows.test.ts @@ -39,24 +39,34 @@ describe("toPercent", () => { expect(toPercent(0)).toBe(0); }); - it("converts 0.5 to 50", () => { + it("treats values < 1 as fraction and multiplies by 100 (0.5 → 50%)", () => { expect(toPercent(0.5)).toBe(50); }); - it("converts 1.0 to 100", () => { - expect(toPercent(1.0)).toBe(100); + it("treats values >= 1 as already-percentage (34 → 34%)", () => { + expect(toPercent(34.0)).toBe(34); + expect(toPercent(91.0)).toBe(91); + }); + + it("treats value exactly 1.0 as 1% (not 100%) — the < 1 heuristic boundary", () => { + // 1.0 is NOT < 1, so it is treated as already-percentage → 1% + expect(toPercent(1.0)).toBe(1); }); it("clamps overshoot to 100", () => { - // floating-point utilization can slightly exceed 1.0 - expect(toPercent(1.001)).toBe(100); - expect(toPercent(1.01)).toBe(100); + expect(toPercent(105)).toBe(100); + expect(toPercent(101)).toBe(100); }); - it("rounds to nearest integer", () => { + it("rounds to nearest integer for fractions", () => { expect(toPercent(0.333)).toBe(33); expect(toPercent(0.666)).toBe(67); }); + + it("rounds to nearest integer for percentages", () => { + expect(toPercent(48.52)).toBe(49); + expect(toPercent(23.4)).toBe(23); + }); }); // --------------------------------------------------------------------------- @@ -516,37 +526,48 @@ describe("fetchClaudeQuota", () => { expect(windows).toEqual([]); }); - it("parses five_hour window", async () => { - mockFetch({ five_hour: { utilization: 0.4, resets_at: "2026-01-01T00:00:00Z" } }); + it("parses five_hour window with percentage-range utilization", async () => { + mockFetch({ five_hour: { utilization: 34.0, resets_at: "2026-01-01T00:00:00Z" } }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(1); expect(windows[0]).toMatchObject({ label: "Current session", - usedPercent: 40, + usedPercent: 34, resetsAt: "2026-01-01T00:00:00Z", }); }); - it("parses seven_day window", async () => { - mockFetch({ seven_day: { utilization: 0.75, resets_at: null } }); + it("parses seven_day window with percentage-range utilization", async () => { + mockFetch({ seven_day: { utilization: 91.0, resets_at: null } }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(1); expect(windows[0]).toMatchObject({ label: "Current week (all models)", - usedPercent: 75, + usedPercent: 91, resetsAt: null, }); }); + it("still handles legacy 0-1 fraction utilization", async () => { + mockFetch({ five_hour: { utilization: 0.4, resets_at: null } }); + const windows = await fetchClaudeQuota("token"); + expect(windows[0]).toMatchObject({ + label: "Current session", + usedPercent: 40, + }); + }); + it("parses seven_day_sonnet and seven_day_opus windows", async () => { mockFetch({ - seven_day_sonnet: { utilization: 0.2, resets_at: null }, - seven_day_opus: { utilization: 0.9, resets_at: null }, + seven_day_sonnet: { utilization: 23.0, resets_at: null }, + seven_day_opus: { utilization: 85.0, resets_at: null }, }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(2); expect(windows[0]!.label).toBe("Current week (Sonnet only)"); + expect(windows[0]!.usedPercent).toBe(23); expect(windows[1]!.label).toBe("Current week (Opus only)"); + expect(windows[1]!.usedPercent).toBe(85); }); it("sets usedPercent to null when utilization is absent", async () => { @@ -557,10 +578,10 @@ describe("fetchClaudeQuota", () => { it("includes all four windows when all are present", async () => { mockFetch({ - five_hour: { utilization: 0.1, resets_at: null }, - seven_day: { utilization: 0.2, resets_at: null }, - seven_day_sonnet: { utilization: 0.3, resets_at: null }, - seven_day_opus: { utilization: 0.4, resets_at: null }, + five_hour: { utilization: 10.0, resets_at: null }, + seven_day: { utilization: 20.0, resets_at: null }, + seven_day_sonnet: { utilization: 30.0, resets_at: null }, + seven_day_opus: { utilization: 40.0, resets_at: null }, }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(4); @@ -571,6 +592,7 @@ describe("fetchClaudeQuota", () => { "Current week (Sonnet only)", "Current week (Opus only)", ]); + expect(windows.map((w: QuotaWindow) => w.usedPercent)).toEqual([10, 20, 30, 40]); }); it("parses extra usage when the OAuth response includes it", async () => { @@ -591,6 +613,25 @@ describe("fetchClaudeQuota", () => { }, ]); }); + + it("formats extra usage credits from cents to dollars", async () => { + mockFetch({ + extra_usage: { + is_enabled: true, + monthly_limit: 14000, + used_credits: 6793, + utilization: 48.52, + }, + }); + const windows = await fetchClaudeQuota("token"); + expect(windows).toHaveLength(1); + expect(windows[0]).toMatchObject({ + label: "Extra usage", + usedPercent: 49, + valueLabel: "$67.93 / $140.00", + detail: "Monthly extra usage pool", + }); + }); }); // --------------------------------------------------------------------------- diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index a2269dd..d43cd58 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -135,6 +135,8 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerRoutineServiceMock(); + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + vi.resetAllMocks(); }); async function createApp(actor: Record) { @@ -253,8 +255,9 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { }); expect([200, 201], JSON.stringify(triggerRes.body)).toContain(triggerRes.status); - expect(triggerRes.body.trigger.kind).toBe("schedule"); - expect(triggerRes.body.trigger.enabled).toBe(true); + const createdTrigger = triggerRes.body.trigger ?? triggerRes.body; + expect(createdTrigger.kind).toBe("schedule"); + expect(createdTrigger.enabled).toBe(true); expect(triggerRes.body.secretMaterial).toBeNull(); const runRes = await postRoutineRun(app, routineId, { @@ -267,10 +270,18 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { expect(runRes.body.source).toBe("manual"); expect(runRes.body.linkedIssueId).toBeTruthy(); + const listRes = await request(app).get(`/api/companies/${companyId}/routines`); + expect(listRes.status).toBe(200); + const listed = listRes.body.find((r: { id: string }) => r.id === routineId); + expect(listed).toBeDefined(); + expect(listed.triggers).toHaveLength(1); + expect(listed.triggers[0].cronExpression).toBe("0 10 * * 1-5"); + expect(listed.triggers[0].timezone).toBe("UTC"); + const detailRes = await request(app).get(`/api/routines/${routineId}`); expect(detailRes.status).toBe(200); expect(detailRes.body.triggers).toHaveLength(1); - expect(detailRes.body.triggers[0]?.id).toBe(triggerRes.body.trigger.id); + expect(detailRes.body.triggers[0]?.id).toBe(createdTrigger.id); expect(detailRes.body.recentRuns).toHaveLength(1); expect(detailRes.body.recentRuns[0]?.id).toBe(runRes.body.id); expect(detailRes.body.activeIssue?.id).toBe(runRes.body.linkedIssueId); diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index e65458d..cfec812 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -84,6 +84,8 @@ const mockTrackRoutineCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); function registerModuleMocks() { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + vi.doMock("@taskcore/shared/telemetry", () => ({ trackRoutineCreated: mockTrackRoutineCreated, trackErrorHandlerCrash: vi.fn(), @@ -93,6 +95,18 @@ function registerModuleMocks() { getTelemetryClient: mockGetTelemetryClient, })); + vi.doMock("../services/access.js", () => ({ + accessService: () => mockAccessService, + })); + + vi.doMock("../services/routines.js", () => ({ + routineService: () => mockRoutineService, + })); + + vi.doMock("../services/activity-log.js", () => ({ + logActivity: mockLogActivity, + })); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, logActivity: mockLogActivity, @@ -121,7 +135,10 @@ describe("routine routes", () => { vi.resetModules(); vi.doUnmock("@taskcore/shared/telemetry"); vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/access.js"); vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/routines.js"); vi.doUnmock("../routes/routines.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index c263705..7de85c0 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -349,6 +349,60 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(routineIssues[0]?.id).toBe(previousIssue.id); }); + it("does not coalesce live routine runs with different resolved variables", async () => { + const { companyId, agentId, projectId, svc } = await seedFixture(); + const variableRoutine = await svc.create( + companyId, + { + projectId, + goalId: null, + parentIssueId: null, + title: "pre-pr for {{branch}}", + description: "Create a pre-PR from {{branch}}", + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [ + { name: "branch", label: null, type: "text", defaultValue: null, required: true, options: [] }, + ], + }, + {}, + ); + + const first = await svc.runRoutine(variableRoutine.id, { + source: "manual", + variables: { branch: "feature/a" }, + }); + const second = await svc.runRoutine(variableRoutine.id, { + source: "manual", + variables: { branch: "feature/b" }, + }); + + expect(first.status).toBe("issue_created"); + expect(second.status).toBe("issue_created"); + expect(first.linkedIssueId).toBeTruthy(); + expect(second.linkedIssueId).toBeTruthy(); + expect(first.linkedIssueId).not.toBe(second.linkedIssueId); + + const routineIssues = await db + .select({ + id: issues.id, + title: issues.title, + originFingerprint: issues.originFingerprint, + }) + .from(issues) + .where(eq(issues.originId, variableRoutine.id)); + + expect(routineIssues).toHaveLength(2); + expect(routineIssues.map((issue) => issue.title).sort()).toEqual([ + "pre-pr for feature/a", + "pre-pr for feature/b", + ]); + expect(new Set(routineIssues.map((issue) => issue.originFingerprint)).size).toBe(2); + }); + 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( @@ -461,6 +515,90 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { }); }); + it("auto-populates workspaceBranch from a reused isolated workspace", async () => { + const { companyId, agentId, projectId, 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", + branchName: "pap-1634-routine-branch", + }); + + const branchRoutine = await svc.create( + companyId, + { + projectId, + goalId: null, + parentIssueId: null, + title: "Review {{workspaceBranch}}", + description: "Use branch {{workspaceBranch}}", + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [ + { name: "workspaceBranch", label: null, type: "text", defaultValue: null, required: true, options: [] }, + ], + }, + {}, + ); + + const run = await svc.runRoutine(branchRoutine.id, { + source: "manual", + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { mode: "isolated_workspace" }, + }); + + const storedIssue = await db + .select({ title: issues.title, 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?.title).toBe("Review pap-1634-routine-branch"); + expect(storedIssue?.description).toBe("Use branch pap-1634-routine-branch"); + expect(storedRun?.triggerPayload).toEqual({ + variables: { + workspaceBranch: "pap-1634-routine-branch", + }, + }); + }); + it("runs draft routines with one-off agent and project overrides", async () => { const { companyId, agentId, projectId, svc } = await seedFixture(); const draftRoutine = await svc.create( diff --git a/server/src/__tests__/run-continuations.test.ts b/server/src/__tests__/run-continuations.test.ts new file mode 100644 index 0000000..a9e1e7b --- /dev/null +++ b/server/src/__tests__/run-continuations.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS, + RUN_LIVENESS_CONTINUATION_REASON, + buildRunLivenessContinuationIdempotencyKey, + decideRunLivenessContinuation, +} from "../services/run-continuations.ts"; + +const companyId = "company-1"; +const agentId = "agent-1"; +const issueId = "issue-1"; +const runId = "run-1"; + +function run(overrides: Record = {}) { + return { + id: runId, + companyId, + agentId, + continuationAttempt: 0, + ...overrides, + } as never; +} + +function issue(overrides: Record = {}) { + return { + id: issueId, + companyId, + identifier: "PAP-1577", + title: "Add bounded liveness continuation wakes", + status: "in_progress", + assigneeAgentId: agentId, + executionState: null, + projectId: null, + ...overrides, + } as never; +} + +function agent(overrides: Record = {}) { + return { + id: agentId, + companyId, + status: "idle", + ...overrides, + } as never; +} + +describe("run liveness continuations", () => { + it("enqueues the first plan_only continuation for the same issue and assignee", () => { + const decision = decideRunLivenessContinuation({ + run: run(), + issue: issue(), + agent: agent(), + livenessState: "plan_only", + livenessReason: "Planned without acting", + nextAction: "Take the first concrete action now.", + budgetBlocked: false, + idempotentWakeExists: false, + }); + + expect(decision.kind).toBe("enqueue"); + if (decision.kind !== "enqueue") return; + expect(decision.nextAttempt).toBe(1); + expect(decision.idempotencyKey).toBe( + buildRunLivenessContinuationIdempotencyKey({ + issueId, + sourceRunId: runId, + livenessState: "plan_only", + nextAttempt: 1, + }), + ); + expect(decision.payload).toMatchObject({ + issueId, + sourceRunId: runId, + livenessState: "plan_only", + livenessReason: "Planned without acting", + continuationAttempt: 1, + maxContinuationAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS, + instruction: "Take the first concrete action now.", + }); + expect(decision.contextSnapshot).toMatchObject({ + issueId, + wakeReason: RUN_LIVENESS_CONTINUATION_REASON, + livenessContinuationAttempt: 1, + livenessContinuationMaxAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS, + livenessContinuationSourceRunId: runId, + livenessContinuationState: "plan_only", + livenessContinuationReason: "Planned without acting", + livenessContinuationInstruction: "Take the first concrete action now.", + }); + }); + + it("enqueues the second empty_response continuation", () => { + const decision = decideRunLivenessContinuation({ + run: run({ continuationAttempt: 1 }), + issue: issue(), + agent: agent(), + livenessState: "empty_response", + livenessReason: "No useful output", + nextAction: null, + budgetBlocked: false, + idempotentWakeExists: false, + }); + + expect(decision.kind).toBe("enqueue"); + if (decision.kind !== "enqueue") return; + expect(decision.nextAttempt).toBe(2); + }); + + it("does not enqueue a third continuation and returns an exhaustion comment", () => { + const decision = decideRunLivenessContinuation({ + run: run({ continuationAttempt: 2 }), + issue: issue(), + agent: agent(), + livenessState: "plan_only", + livenessReason: "Still planning", + nextAction: null, + budgetBlocked: false, + idempotentWakeExists: false, + }); + + expect(decision.kind).toBe("exhausted"); + if (decision.kind !== "exhausted") return; + expect(decision.comment).toContain("Bounded liveness continuation exhausted"); + expect(decision.comment).toContain("Attempts used: 2/2"); + }); + + it("skips non-actionable and guarded issues", () => { + const guardedCases = [ + { livenessState: "advanced" as const }, + { issue: issue({ status: "done" }) }, + { issue: issue({ assigneeAgentId: "other-agent" }) }, + { issue: issue({ executionState: { status: "pending" } }) }, + { agent: agent({ status: "paused" }) }, + { budgetBlocked: true }, + { idempotentWakeExists: true }, + ]; + + for (const guarded of guardedCases) { + const decision = decideRunLivenessContinuation({ + run: run(), + issue: guarded.issue ?? issue(), + agent: guarded.agent ?? agent(), + livenessState: guarded.livenessState ?? "plan_only", + livenessReason: "No progress", + nextAction: null, + budgetBlocked: guarded.budgetBlocked ?? false, + idempotentWakeExists: guarded.idempotentWakeExists ?? false, + }); + + expect(decision.kind).toBe("skip"); + } + }); +}); diff --git a/server/src/__tests__/run-liveness.test.ts b/server/src/__tests__/run-liveness.test.ts new file mode 100644 index 0000000..08af19b --- /dev/null +++ b/server/src/__tests__/run-liveness.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { classifyRunLiveness } from "../services/run-liveness.ts"; + +const baseInput = { + runStatus: "succeeded", + issue: { + status: "in_progress", + title: "Implement feature", + description: "Add the requested behavior.", + }, + resultJson: null, + stdoutExcerpt: null, + stderrExcerpt: null, + error: null, + errorCode: null, + continuationAttempt: 0, + evidence: null, +}; + +describe("run liveness classifier", () => { + it("classifies text-only future work as plan_only", () => { + const classification = classifyRunLiveness({ + ...baseInput, + resultJson: { + summary: "I will inspect the repo next and then implement the fix.", + }, + }); + + expect(classification.livenessState).toBe("plan_only"); + expect(classification.nextAction).toContain("inspect the repo"); + }); + + it("classifies empty successful output as empty_response", () => { + const classification = classifyRunLiveness(baseInput); + + expect(classification.livenessState).toBe("empty_response"); + }); + + it("treats issue comments, documents, products, and actions as progress", () => { + const latestEvidenceAt = new Date("2026-04-18T12:00:00Z"); + const classification = classifyRunLiveness({ + ...baseInput, + resultJson: { + summary: "Updated implementation.", + }, + evidence: { + issueCommentsCreated: 1, + documentRevisionsCreated: 1, + workProductsCreated: 1, + toolOrActionEventsCreated: 1, + latestEvidenceAt, + }, + }); + + expect(classification.livenessState).toBe("advanced"); + expect(classification.lastUsefulActionAt).toBe(latestEvidenceAt); + }); + + it("does not treat workspace operations alone as concrete progress", () => { + const classification = classifyRunLiveness({ + ...baseInput, + resultJson: { + summary: "I will inspect the repo next.", + }, + evidence: { + workspaceOperationsCreated: 1, + latestEvidenceAt: new Date("2026-04-18T12:00:00Z"), + }, + }); + + expect(classification.livenessState).toBe("plan_only"); + expect(classification.lastUsefulActionAt).toBeNull(); + }); + + it("exempts planning/document tasks from plan-only retry classification", () => { + const classification = classifyRunLiveness({ + ...baseInput, + issue: { + status: "in_progress", + title: "Draft implementation plan", + description: "Create a plan for the work.", + }, + resultJson: { + summary: "Plan:\n- Inspect files\n- Implement after approval", + }, + }); + + expect(classification.livenessState).toBe("advanced"); + }); + + it("exempts runs that update the plan document from plan-only classification", () => { + const classification = classifyRunLiveness({ + ...baseInput, + resultJson: { + summary: "Next steps:\n- inspect files\n- implement the service", + }, + evidence: { + documentRevisionsCreated: 1, + planDocumentRevisionsCreated: 1, + latestEvidenceAt: new Date("2026-04-18T12:00:00Z"), + }, + }); + + expect(classification.livenessState).toBe("advanced"); + }); + + it("classifies done issues as completed", () => { + const classification = classifyRunLiveness({ + ...baseInput, + issue: { + ...baseInput.issue, + status: "done", + }, + resultJson: { + summary: "Finished the implementation.", + }, + }); + + expect(classification.livenessState).toBe("completed"); + }); + + it("classifies declared blockers as blocked", () => { + const classification = classifyRunLiveness({ + ...baseInput, + resultJson: { + summary: "I cannot proceed because I need access credentials.", + }, + }); + + expect(classification.livenessState).toBe("blocked"); + }); +}); diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index dec0bd8..fe17952 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -8,7 +8,7 @@ const { feedbackServiceFactoryMock, fakeServer, } = vi.hoisted(() => { - const createAppMock = vi.fn(async () => ((_: unknown, __: unknown) => { }) as never); + const createAppMock = vi.fn(async () => ((_: unknown, __: unknown) => {}) as never); const createDbMock = vi.fn(() => ({}) as never); const detectPortMock = vi.fn(async (port: number) => port); const feedbackExportServiceMock = { @@ -118,6 +118,7 @@ vi.mock("../services/index.js", () => ({ feedbackService: feedbackServiceFactoryMock, heartbeatService: vi.fn(() => ({ reapOrphanedRuns: vi.fn(async () => undefined), + promoteDueScheduledRetries: vi.fn(async () => ({ promoted: 0, runIds: [] })), resumeQueuedRuns: vi.fn(async () => undefined), reconcileStrandedAssignedIssues: vi.fn(async () => ({ dispatchRequeued: 0, @@ -128,6 +129,15 @@ vi.mock("../services/index.js", () => ({ })), tickTimers: vi.fn(async () => ({ enqueued: 0 })), })), + instanceSettingsService: vi.fn(() => ({ + getGeneral: vi.fn(async () => ({ + backupRetention: { + dailyDays: 7, + weeklyWeeks: 4, + monthlyMonths: 1, + }, + })), + })), reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })), routineService: vi.fn(() => ({ tickScheduledTriggers: vi.fn(async () => ({ triggered: 0 })), @@ -180,3 +190,27 @@ describe("startServer feedback export wiring", () => { }); }); }); + +describe("startServer TASKCORE_API_URL handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.BETTER_AUTH_SECRET = "test-secret"; + delete process.env.TASKCORE_API_URL; + }); + + it("uses the externally set TASKCORE_API_URL when provided", async () => { + process.env.TASKCORE_API_URL = "http://custom-api:3100"; + + const started = await startServer(); + + expect(started.apiUrl).toBe("http://custom-api:3100"); + expect(process.env.TASKCORE_API_URL).toBe("http://custom-api:3100"); + }); + + it("falls back to host-based URL when TASKCORE_API_URL is not set", async () => { + const started = await startServer(); + + expect(started.apiUrl).toBe("http://127.0.0.1:3210"); + expect(process.env.TASKCORE_API_URL).toBe("http://127.0.0.1:3210"); + }); +}); diff --git a/server/src/__tests__/shared-telemetry-events.test.ts b/server/src/__tests__/shared-telemetry-events.test.ts new file mode 100644 index 0000000..cb09272 --- /dev/null +++ b/server/src/__tests__/shared-telemetry-events.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; +import { + trackAgentCreated, + trackAgentFirstHeartbeat, + trackAgentTaskCompleted, + trackInstallCompleted, +} from "@taskcore/shared/telemetry"; +import type { TelemetryClient } from "@taskcore/shared/telemetry"; + +function createClient(): TelemetryClient { + return { + track: vi.fn(), + hashPrivateRef: vi.fn((value: string) => `hashed:${value}`), + } as unknown as TelemetryClient; +} + +describe("shared telemetry agent events", () => { + it("includes agent_id for agent.created", () => { + const client = createClient(); + + trackAgentCreated(client, { + agentRole: "engineer", + agentId: "11111111-1111-4111-8111-111111111111", + }); + + expect(client.track).toHaveBeenCalledWith("agent.created", { + agent_role: "engineer", + agent_id: "11111111-1111-4111-8111-111111111111", + }); + }); + + it("includes agent_id for agent.first_heartbeat", () => { + const client = createClient(); + + trackAgentFirstHeartbeat(client, { + agentRole: "coder", + agentId: "22222222-2222-4222-8222-222222222222", + }); + + expect(client.track).toHaveBeenCalledWith("agent.first_heartbeat", { + agent_role: "coder", + agent_id: "22222222-2222-4222-8222-222222222222", + }); + }); + + it("includes agent_id for agent.task_completed", () => { + const client = createClient(); + + trackAgentTaskCompleted(client, { + agentRole: "qa", + agentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(client.track).toHaveBeenCalledWith("agent.task_completed", { + agent_role: "qa", + agent_id: "33333333-3333-4333-8333-333333333333", + }); + }); + + it("keeps non-agent event dimensions unchanged", () => { + const client = createClient(); + + trackInstallCompleted(client, { adapterType: "codex_local" }); + + expect(client.track).toHaveBeenCalledWith("install.completed", { + adapter_type: "codex_local", + }); + expect(client.track).not.toHaveBeenCalledWith( + "install.completed", + expect.objectContaining({ agent_id: expect.any(String) }), + ); + }); +}); diff --git a/server/src/__tests__/sidebar-preferences-routes.test.ts b/server/src/__tests__/sidebar-preferences-routes.test.ts index b5166f0..13d93cd 100644 --- a/server/src/__tests__/sidebar-preferences-routes.test.ts +++ b/server/src/__tests__/sidebar-preferences-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorHandler } from "../middleware/index.js"; -import { sidebarPreferenceRoutes } from "../routes/sidebar-preferences.js"; const mockSidebarPreferenceService = vi.hoisted(() => ({ getCompanyOrder: vi.fn(), @@ -12,12 +10,18 @@ const mockSidebarPreferenceService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); -vi.mock("../services/index.js", () => ({ - sidebarPreferenceService: () => mockSidebarPreferenceService, - logActivity: mockLogActivity, -})); +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + sidebarPreferenceService: () => mockSidebarPreferenceService, + logActivity: mockLogActivity, + })); +} -function createApp(actor: Record) { +async function createApp(actor: Record) { + const [{ sidebarPreferenceRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/sidebar-preferences.js"), + import("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -36,7 +40,13 @@ const ORDERED_IDS = [ describe("sidebar preference routes", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetModules(); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/sidebar-preferences.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.resetAllMocks(); mockSidebarPreferenceService.getCompanyOrder.mockResolvedValue({ orderedIds: ORDERED_IDS, updatedAt: null, @@ -56,7 +66,7 @@ describe("sidebar preference routes", () => { }); it("returns company rail order for board users", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "user-1", source: "session", @@ -75,7 +85,7 @@ describe("sidebar preference routes", () => { }); it("updates company rail order for board users", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "user-1", source: "local_implicit", @@ -92,7 +102,7 @@ describe("sidebar preference routes", () => { }); it("returns project order for companies the board user can access", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "user-1", source: "session", @@ -107,7 +117,7 @@ describe("sidebar preference routes", () => { }); it("logs project order updates for company-scoped writes", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "user-1", source: "session", @@ -136,7 +146,7 @@ describe("sidebar preference routes", () => { }); it("rejects company-scoped reads when the board user lacks company access", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "user-1", source: "session", @@ -151,7 +161,7 @@ describe("sidebar preference routes", () => { }); it("rejects agent callers", async () => { - const app = createApp({ + const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "company-1", diff --git a/server/src/__tests__/user-profile-routes.test.ts b/server/src/__tests__/user-profile-routes.test.ts new file mode 100644 index 0000000..ed213a3 --- /dev/null +++ b/server/src/__tests__/user-profile-routes.test.ts @@ -0,0 +1,218 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + activityLog, + agents, + authUsers, + companies, + companyMemberships, + costEvents, + createDb, + issueComments, + issues, +} from "@taskcore/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +let errorHandler: typeof import("../middleware/index.js").errorHandler; +let userProfileRoutes: typeof import("../routes/user-profiles.js").userProfileRoutes; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres user profile route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("GET /companies/:companyId/users/:userSlug/profile", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + let companyId!: string; + let userId!: string; + let agentId!: string; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-user-profile-route-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + beforeEach(async () => { + vi.resetModules(); + vi.doUnmock("../routes/user-profiles.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + const [routes, middleware] = await Promise.all([ + vi.importActual("../routes/user-profiles.js"), + vi.importActual("../middleware/index.js"), + ]); + userProfileRoutes = routes.userProfileRoutes; + errorHandler = middleware.errorHandler; + companyId = randomUUID(); + userId = randomUUID(); + agentId = randomUUID(); + const now = new Date(); + + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `U${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(authUsers).values({ + id: userId, + name: "Dotta", + email: "dotta@example.com", + emailVerified: true, + image: null, + createdAt: now, + updatedAt: now, + }); + await db.insert(companyMemberships).values({ + companyId, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "owner", + createdAt: now, + updatedAt: now, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Coder", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + }); + }); + + afterEach(async () => { + await db.delete(costEvents); + await db.delete(issueComments); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(agents); + await db.delete(companyMemberships); + await db.delete(authUsers); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createApp() { + if (!userProfileRoutes || !errorHandler) { + throw new Error("user profile route test dependencies were not loaded"); + } + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId, + companyIds: [companyId], + }; + next(); + }); + app.use("/api", userProfileRoutes(db)); + app.use(errorHandler); + return app; + } + + it("resolves a user slug and returns issue, activity, and attributed cost stats", async () => { + const doneIssueId = randomUUID(); + const openIssueId = randomUUID(); + const now = new Date(); + const older = new Date(now.getTime() - 60_000); + + await db.insert(issues).values([ + { + id: doneIssueId, + companyId, + title: "Ship profile page", + status: "done", + priority: "high", + createdByUserId: userId, + identifier: "USR-1", + completedAt: now, + createdAt: now, + updatedAt: now, + }, + { + id: openIssueId, + companyId, + title: "Review profile copy", + status: "in_progress", + priority: "medium", + assigneeUserId: userId, + identifier: "USR-2", + createdAt: older, + updatedAt: older, + }, + ]); + await db.insert(issueComments).values({ + companyId, + issueId: openIssueId, + authorUserId: userId, + body: "Looks good.", + createdAt: now, + updatedAt: now, + }); + await db.insert(activityLog).values({ + companyId, + actorType: "user", + actorId: userId, + action: "issue.updated", + entityType: "issue", + entityId: doneIssueId, + createdAt: now, + }); + await db.insert(costEvents).values({ + companyId, + agentId, + issueId: doneIssueId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-test", + inputTokens: 120, + cachedInputTokens: 30, + outputTokens: 40, + costCents: 42, + occurredAt: now, + }); + + const response = await request(createApp()).get(`/api/companies/${companyId}/users/dotta/profile`); + + expect(response.status).toBe(200); + expect(response.body.user.slug).toBe("dotta"); + expect(response.body.user.membershipRole).toBe("owner"); + expect(response.body.stats).toHaveLength(3); + + const all = response.body.stats.find((entry: { key: string }) => entry.key === "all"); + expect(all).toMatchObject({ + touchedIssues: 2, + createdIssues: 1, + completedIssues: 1, + assignedOpenIssues: 1, + commentCount: 1, + activityCount: 1, + costCents: 42, + inputTokens: 120, + cachedInputTokens: 30, + outputTokens: 40, + costEventCount: 1, + }); + expect(response.body.recentIssues.map((issue: { identifier: string }) => issue.identifier)).toEqual(["USR-1", "USR-2"]); + expect(response.body.recentActivity[0].action).toBe("issue.updated"); + expect(response.body.topAgents[0]).toMatchObject({ agentId, agentName: "Coder", costCents: 42 }); + expect(response.body.topProviders[0]).toMatchObject({ provider: "openai", model: "gpt-test", costCents: 42 }); + }); +}); diff --git a/server/src/__tests__/vite-html-renderer.test.ts b/server/src/__tests__/vite-html-renderer.test.ts index 23524a0..ce23d85 100644 --- a/server/src/__tests__/vite-html-renderer.test.ts +++ b/server/src/__tests__/vite-html-renderer.test.ts @@ -32,7 +32,7 @@ describe("createCachedViteHtmlRenderer", () => { } }); - it("reuses the injected dev html shell until a watched file changes", async () => { + it("reuses the injected dev html shell until index.html changes", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "taskcore-vite-html-")); tempDirs.push(tempDir); const indexPath = path.join(tempDir, "index.html"); @@ -57,6 +57,12 @@ describe("createCachedViteHtmlRenderer", () => { expect(first.match(/\/@vite\/client/g)?.length).toBe(1); expect(first).toContain("window.$RefreshReg$"); + const sourcePath = path.join(tempDir, "src", "main.tsx"); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, "export {};\n", "utf8"); + watcher.emit("change", sourcePath); + expect(await renderer.render("/")).toBe(first); + fs.writeFileSync( indexPath, 'v2', diff --git a/server/src/__tests__/workspace-runtime-routes-authz.test.ts b/server/src/__tests__/workspace-runtime-routes-authz.test.ts new file mode 100644 index 0000000..9c3665b --- /dev/null +++ b/server/src/__tests__/workspace-runtime-routes-authz.test.ts @@ -0,0 +1,445 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockProjectService = vi.hoisted(() => ({ + create: vi.fn(), + createWorkspace: vi.fn(), + getById: vi.fn(), + listWorkspaces: vi.fn(), + resolveByReference: vi.fn(), + update: vi.fn(), + updateWorkspace: vi.fn(), +})); + +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), +})); + +const mockSecretService = vi.hoisted(() => ({ + normalizeEnvBindingsForPersistence: vi.fn(), +})); + +const mockWorkspaceOperationService = vi.hoisted(() => ({})); +const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); +const mockAssertCanManageProjectWorkspaceRuntimeServices = vi.hoisted(() => vi.fn()); +const mockAssertCanManageExecutionWorkspaceRuntimeServices = vi.hoisted(() => vi.fn()); + +function registerModuleMocks() { + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, + })); + + vi.doMock("../services/index.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, + secretService: () => mockSecretService, + workspaceOperationService: () => mockWorkspaceOperationService, + })); + + vi.doMock("../services/workspace-runtime.js", () => ({ + cleanupExecutionWorkspaceArtifacts: vi.fn(), + startRuntimeServicesForWorkspaceControl: vi.fn(), + stopRuntimeServicesForExecutionWorkspace: vi.fn(), + stopRuntimeServicesForProjectWorkspace: vi.fn(), + })); + + vi.doMock("../routes/workspace-runtime-service-authz.js", () => ({ + assertCanManageProjectWorkspaceRuntimeServices: mockAssertCanManageProjectWorkspaceRuntimeServices, + assertCanManageExecutionWorkspaceRuntimeServices: mockAssertCanManageExecutionWorkspaceRuntimeServices, + })); +} + +async function createProjectApp(actor: Record) { + const { projectRoutes } = await import("../routes/projects.js"); + const { errorHandler } = await import("../middleware/index.js"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", projectRoutes({} as any)); + app.use(errorHandler); + return app; +} + +async function createExecutionWorkspaceApp(actor: Record) { + const { executionWorkspaceRoutes } = await import("../routes/execution-workspaces.js"); + const { errorHandler } = await import("../middleware/index.js"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", executionWorkspaceRoutes({} as any)); + app.use(errorHandler); + return app; +} + +function buildProject(overrides: Record = {}) { + return { + id: "project-1", + companyId: "company-1", + urlKey: "project-1", + goalId: null, + goalIds: [], + goals: [], + name: "Project", + description: null, + status: "backlog", + leadAgentId: null, + targetDate: null, + color: null, + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: null, + workspaces: [], + primaryWorkspace: null, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function buildExecutionWorkspace(overrides: Record = {}) { + return { + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + sourceIssueId: null, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Workspace", + status: "active", + cwd: "/tmp/workspace", + repoUrl: null, + baseRef: "main", + branchName: "feature/test", + providerType: "git_worktree", + providerRef: null, + derivedFromExecutionWorkspaceId: null, + lastUsedAt: new Date(), + openedAt: new Date(), + closedAt: null, + cleanupEligibleAt: null, + cleanupReason: null, + config: null, + metadata: null, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe("workspace runtime service route authorization", () => { + const projectId = "11111111-1111-4111-8111-111111111111"; + const workspaceId = "22222222-2222-4222-8222-222222222222"; + const executionWorkspaceId = "33333333-3333-4333-8333-333333333333"; + + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/workspace-runtime.js"); + vi.doUnmock("../routes/workspace-runtime-service-authz.js"); + vi.doUnmock("../routes/projects.js"); + vi.doUnmock("../routes/execution-workspaces.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.resetAllMocks(); + mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env); + mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); + mockProjectService.create.mockResolvedValue(buildProject()); + mockProjectService.update.mockResolvedValue(buildProject()); + mockProjectService.createWorkspace.mockResolvedValue({ + id: workspaceId, + companyId: "company-1", + projectId, + name: "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: false, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + mockProjectService.listWorkspaces.mockResolvedValue([{ + id: workspaceId, + companyId: "company-1", + projectId, + name: "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: false, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + }]); + mockProjectService.updateWorkspace.mockResolvedValue({ + id: workspaceId, + companyId: "company-1", + projectId, + name: "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: false, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + mockExecutionWorkspaceService.update.mockResolvedValue(buildExecutionWorkspace()); + mockAssertCanManageProjectWorkspaceRuntimeServices.mockResolvedValue(undefined); + mockAssertCanManageExecutionWorkspaceRuntimeServices.mockResolvedValue(undefined); + }); + + it("rejects agent callers for project workspace runtime service mutations when workspace auth denies access", async () => { + const { forbidden } = await import("../errors.js"); + mockProjectService.getById.mockResolvedValue(buildProject({ + id: projectId, + workspaces: [{ + id: workspaceId, + companyId: "company-1", + projectId, + name: "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: false, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + }], + })); + mockAssertCanManageProjectWorkspaceRuntimeServices.mockRejectedValue( + forbidden("Missing permission to manage workspace runtime services"), + ); + const app = await createProjectApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/start`) + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Missing permission"); + expect(mockProjectService.getById).toHaveBeenCalledWith(projectId); + expect(mockAssertCanManageProjectWorkspaceRuntimeServices).toHaveBeenCalled(); + }, 15000); + + it("rejects agent callers that create project execution workspace commands", async () => { + const app = await createProjectApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/company-1/projects") + .send({ + name: "Exploit", + executionWorkspacePolicy: { + enabled: true, + workspaceStrategy: { + type: "git_worktree", + provisionCommand: "touch /tmp/taskcore-rce", + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockProjectService.create).not.toHaveBeenCalled(); + }); + + it("rejects agent callers that update project workspace cleanup commands", async () => { + mockProjectService.getById.mockResolvedValue(buildProject()); + const app = await createProjectApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/projects/${projectId}/workspaces/${workspaceId}`) + .send({ + cleanupCommand: "rm -rf /tmp/taskcore-rce", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockProjectService.updateWorkspace).not.toHaveBeenCalled(); + }); + + it("allows board callers through the project workspace runtime auth gate", async () => { + mockProjectService.getById.mockResolvedValue(null); + const app = await createProjectApp({ + type: "board", + userId: "board-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/start`) + .send({}); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("Project not found"); + expect(mockProjectService.getById).toHaveBeenCalledWith(projectId); + }); + + it("rejects agent callers for execution workspace runtime service mutations when workspace auth denies access", async () => { + const { forbidden } = await import("../errors.js"); + mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId })); + mockAssertCanManageExecutionWorkspaceRuntimeServices.mockRejectedValue( + forbidden("Missing permission to manage workspace runtime services"), + ); + const app = await createExecutionWorkspaceApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/restart`) + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Missing permission"); + expect(mockExecutionWorkspaceService.getById).toHaveBeenCalledWith(executionWorkspaceId); + expect(mockAssertCanManageExecutionWorkspaceRuntimeServices).toHaveBeenCalled(); + }, 15000); + + it("rejects agent callers that patch execution workspace command config", async () => { + mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId })); + const app = await createExecutionWorkspaceApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/execution-workspaces/${executionWorkspaceId}`) + .send({ + config: { + cleanupCommand: "rm -rf /tmp/taskcore-rce", + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockExecutionWorkspaceService.update).not.toHaveBeenCalled(); + }); + + it("rejects agent callers that smuggle execution workspace commands through metadata.config", async () => { + mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId })); + const app = await createExecutionWorkspaceApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/execution-workspaces/${executionWorkspaceId}`) + .send({ + metadata: { + config: { + provisionCommand: "touch /tmp/taskcore-rce", + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockExecutionWorkspaceService.update).not.toHaveBeenCalled(); + }); + + it("allows board callers through the execution workspace runtime auth gate", async () => { + mockExecutionWorkspaceService.getById.mockResolvedValue(null); + const app = await createExecutionWorkspaceApp({ + type: "board", + userId: "board-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/restart`) + .send({}); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("Execution workspace not found"); + expect(mockExecutionWorkspaceService.getById).toHaveBeenCalledWith(executionWorkspaceId); + }); +}); diff --git a/server/src/__tests__/workspace-runtime-service-authz.test.ts b/server/src/__tests__/workspace-runtime-service-authz.test.ts new file mode 100644 index 0000000..184b2ab --- /dev/null +++ b/server/src/__tests__/workspace-runtime-service-authz.test.ts @@ -0,0 +1,279 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + createDb, + executionWorkspaces, + issues, + projectWorkspaces, + projects, +} from "@taskcore/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { + assertCanManageExecutionWorkspaceRuntimeServices, + assertCanManageProjectWorkspaceRuntimeServices, +} from "../routes/workspace-runtime-service-authz.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres workspace runtime auth tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("workspace runtime service authz helper", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("taskcore-workspace-runtime-authz-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompany() { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Taskcore", + issuePrefix: `PAP-${companyId.slice(0, 8)}`, + requireBoardApprovalForNewAgents: false, + }); + return companyId; + } + + async function seedProjectWorkspace(companyId: string) { + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace authz", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "local_path", + cwd: "/tmp/taskcore-authz-project", + isPrimary: true, + }); + return { projectId, projectWorkspaceId }; + } + + async function seedExecutionWorkspace(companyId: string, projectId: string, projectWorkspaceId: string) { + const executionWorkspaceId = randomUUID(); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Execution workspace", + status: "active", + providerType: "local_fs", + cwd: "/tmp/taskcore-authz-execution", + }); + return executionWorkspaceId; + } + + async function seedAgent( + companyId: string, + input: { role?: string; reportsTo?: string | null; name?: string } = {}, + ) { + const agentId = randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId, + name: input.name ?? "Agent", + role: input.role ?? "engineer", + reportsTo: input.reportsTo ?? null, + }); + return agentId; + } + + it("allows board actors to manage project workspace runtime services", async () => { + const companyId = await seedCompany(); + const { projectWorkspaceId } = await seedProjectWorkspace(companyId); + + await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { + actor: { + type: "board", + userId: "board-1", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }, + } as any, { + companyId, + projectWorkspaceId, + })).resolves.toBeUndefined(); + }); + + it("allows CEO agents to manage any project workspace runtime services in their company", async () => { + const companyId = await seedCompany(); + const { projectWorkspaceId } = await seedProjectWorkspace(companyId); + const ceoAgentId = await seedAgent(companyId, { role: "ceo", name: "CEO" }); + + await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId: ceoAgentId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + projectWorkspaceId, + })).resolves.toBeUndefined(); + }); + + it("allows agents with a non-terminal assigned issue in the target project workspace", async () => { + const companyId = await seedCompany(); + const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); + const agentId = await seedAgent(companyId, { name: "Engineer" }); + + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + projectWorkspaceId, + title: "Use this workspace", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + }); + + await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + projectWorkspaceId, + })).resolves.toBeUndefined(); + }); + + it("allows managers to manage execution workspace runtime services for their reporting subtree", async () => { + const companyId = await seedCompany(); + const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); + const executionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId); + const managerId = await seedAgent(companyId, { role: "cto", name: "Manager" }); + const reportId = await seedAgent(companyId, { reportsTo: managerId, name: "Report" }); + + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId, + title: "Use execution workspace", + status: "in_progress", + priority: "medium", + assigneeAgentId: reportId, + }); + + await expect(assertCanManageExecutionWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId: managerId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + executionWorkspaceId, + })).resolves.toBeUndefined(); + }); + + it("rejects unrelated same-company agents without matching workspace assignments", async () => { + const companyId = await seedCompany(); + const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); + const executionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId); + const assignedAgentId = await seedAgent(companyId, { name: "Assigned" }); + const unrelatedAgentId = await seedAgent(companyId, { name: "Unrelated" }); + + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId, + title: "Assigned issue", + status: "todo", + priority: "medium", + assigneeAgentId: assignedAgentId, + }); + + await expect(assertCanManageExecutionWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId: unrelatedAgentId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + executionWorkspaceId, + })).rejects.toMatchObject({ + status: 403, + message: "Missing permission to manage workspace runtime services", + }); + }); + + it("rejects completed workspace assignments so stale issues do not keep access alive", async () => { + const companyId = await seedCompany(); + const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); + const agentId = await seedAgent(companyId, { name: "Engineer" }); + + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + projectWorkspaceId, + title: "Completed issue", + status: "done", + priority: "medium", + assigneeAgentId: agentId, + }); + + await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + projectWorkspaceId, + })).rejects.toMatchObject({ + status: 403, + message: "Missing permission to manage workspace runtime services", + }); + }); +}); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 3df3c7c..64e92cc 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -897,7 +897,7 @@ describe("realizeExecutionWorkspace", () => { await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]); try { - const workspace = await realizeExecutionWorkspace({ + const workspaceInput = { base: { baseCwd: repoRoot, source: "project_primary", @@ -923,7 +923,8 @@ describe("realizeExecutionWorkspace", () => { name: "Codex Coder", companyId: "company-1", }, - }); + } satisfies Parameters[0]; + const workspace = await realizeExecutionWorkspace(workspaceInput); const configPath = path.join(workspace.cwd, ".taskcore", "config.json"); const envPath = path.join(workspace.cwd, ".taskcore", ".env"); @@ -954,111 +955,139 @@ describe("realizeExecutionWorkspace", () => { process.chdir(workspace.cwd); expect(resolveTaskcoreConfigPath()).toBe(configPath); - } finally { - process.chdir(previousCwd); - } - }, 15_000); - it( - "provisions worktree-local pnpm node_modules instead of reusing base-repo links", - async () => { - const repoRoot = await createTempRepo(); - await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); - await fs.mkdir(path.join(repoRoot, "packages", "shared"), { recursive: true }); - await fs.mkdir(path.join(repoRoot, "server"), { recursive: true }); - await fs.writeFile( - path.join(repoRoot, "package.json"), - JSON.stringify( - { - name: "workspace-root", - private: true, - packageManager: "pnpm@9.15.4", - }, - null, - 2, - ), - "utf8", - ); + const preservedPort = 39999; await fs.writeFile( - path.join(repoRoot, "pnpm-workspace.yaml"), - ["packages:", " - packages/*", " - server", ""].join("\n"), - "utf8", - ); - await fs.writeFile( - path.join(repoRoot, "packages", "shared", "package.json"), + configPath, JSON.stringify( { - name: "@repo/shared", - version: "1.0.0", - private: true, - type: "module", - exports: "./index.js", - }, - null, - 2, - ), - "utf8", - ); - await fs.writeFile(path.join(repoRoot, "packages", "shared", "index.js"), "export const value = 'shared';\n", "utf8"); - await fs.writeFile( - path.join(repoRoot, "server", "package.json"), - JSON.stringify( - { - name: "server", - private: true, - type: "module", - dependencies: { - "@repo/shared": "workspace:*", + ...configContents, + server: { + ...configContents.server, + port: preservedPort, }, }, null, 2, - ), + ) + "\n", "utf8", ); - await fs.writeFile(path.join(repoRoot, "server", "index.js"), "export {};\n", "utf8"); - await fs.copyFile(provisionWorktreeScriptPath, path.join(repoRoot, "scripts", "provision-worktree.sh")); - await fs.chmod(path.join(repoRoot, "scripts", "provision-worktree.sh"), 0o755); - await runPnpm(repoRoot, ["install"]); - await runGit(repoRoot, ["add", "."]); - await runGit(repoRoot, ["commit", "-m", "Add pnpm workspace fixture"]); - - const workspace = await realizeExecutionWorkspace({ - base: { - baseCwd: repoRoot, - source: "project_primary", - projectId: "project-1", - workspaceId: "workspace-1", - repoUrl: null, - repoRef: "HEAD", + await fs.writeFile(envPath, `${envContents}TASKCORE_WORKTREE_COLOR="#112233"\n`, "utf8"); + + const reusedWorkspace = await realizeExecutionWorkspace(workspaceInput); + const reusedConfigContents = JSON.parse(await fs.readFile(configPath, "utf8")); + const reusedEnvContents = await fs.readFile(envPath, "utf8"); + + expect(reusedWorkspace.cwd).toBe(workspace.cwd); + expect(reusedWorkspace.created).toBe(false); + expect(reusedConfigContents.server.port).toBe(preservedPort); + expect(reusedConfigContents.database.embeddedPostgresDataDir).toBe(path.join(expectedInstanceRoot, "db")); + expect(reusedEnvContents).toContain('TASKCORE_WORKTREE_COLOR="#112233"'); + } finally { + process.chdir(previousCwd); + } + }, 15_000); + + it( + "provisions worktree-local pnpm node_modules instead of reusing base-repo links", + async () => { + const repoRoot = await createTempRepo(); + await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); + await fs.mkdir(path.join(repoRoot, "packages", "shared"), { recursive: true }); + await fs.mkdir(path.join(repoRoot, "server"), { recursive: true }); + await fs.writeFile( + path.join(repoRoot, "package.json"), + JSON.stringify( + { + name: "workspace-root", + private: true, + packageManager: "pnpm@9.15.4", }, - config: { - workspaceStrategy: { - type: "git_worktree", - branchTemplate: "{{issue.identifier}}-{{slug}}", - provisionCommand: "bash ./scripts/provision-worktree.sh", - }, + null, + 2, + ), + "utf8", + ); + await fs.writeFile( + path.join(repoRoot, "pnpm-workspace.yaml"), + ["packages:", " - packages/*", " - server", ""].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(repoRoot, "packages", "shared", "package.json"), + JSON.stringify( + { + name: "@repo/shared", + version: "1.0.0", + private: true, + type: "module", + exports: "./index.js", }, - issue: { - id: "issue-1", - identifier: "PAP-551", - title: "Provision local workspace dependencies", + null, + 2, + ), + "utf8", + ); + await fs.writeFile(path.join(repoRoot, "packages", "shared", "index.js"), "export const value = 'shared';\n", "utf8"); + await fs.writeFile( + path.join(repoRoot, "server", "package.json"), + JSON.stringify( + { + name: "server", + private: true, + type: "module", + dependencies: { + "@repo/shared": "workspace:*", + }, }, - agent: { - id: "agent-1", - name: "Codex Coder", - companyId: "company-1", + null, + 2, + ), + "utf8", + ); + await fs.writeFile(path.join(repoRoot, "server", "index.js"), "export {};\n", "utf8"); + await fs.copyFile(provisionWorktreeScriptPath, path.join(repoRoot, "scripts", "provision-worktree.sh")); + await fs.chmod(path.join(repoRoot, "scripts", "provision-worktree.sh"), 0o755); + await runPnpm(repoRoot, ["install"]); + await runGit(repoRoot, ["add", "."]); + await runGit(repoRoot, ["commit", "-m", "Add pnpm workspace fixture"]); + + const workspace = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + provisionCommand: "bash ./scripts/provision-worktree.sh", }, - }); + }, + issue: { + id: "issue-1", + identifier: "PAP-551", + title: "Provision local workspace dependencies", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); - expect((await fs.lstat(path.join(workspace.cwd, "node_modules"))).isSymbolicLink()).toBe(false); - expect((await fs.lstat(path.join(workspace.cwd, "server", "node_modules"))).isSymbolicLink()).toBe(false); - await expect(fs.realpath(path.join(workspace.cwd, "server", "node_modules", "@repo", "shared"))).resolves.toBe( - await fs.realpath(path.join(workspace.cwd, "packages", "shared")), - ); - await expect(fs.realpath(path.join(repoRoot, "server", "node_modules", "@repo", "shared"))).resolves.toBe( - await fs.realpath(path.join(repoRoot, "packages", "shared")), - ); + expect((await fs.lstat(path.join(workspace.cwd, "node_modules"))).isSymbolicLink()).toBe(false); + expect((await fs.lstat(path.join(workspace.cwd, "server", "node_modules"))).isSymbolicLink()).toBe(false); + await expect(fs.realpath(path.join(workspace.cwd, "server", "node_modules", "@repo", "shared"))).resolves.toBe( + await fs.realpath(path.join(workspace.cwd, "packages", "shared")), + ); + await expect(fs.realpath(path.join(repoRoot, "server", "node_modules", "@repo", "shared"))).resolves.toBe( + await fs.realpath(path.join(repoRoot, "packages", "shared")), + ); }, 30_000, ); @@ -1270,103 +1299,103 @@ describe("realizeExecutionWorkspace", () => { it( "provisions worktree-local pnpm node_modules instead of reusing base-repo links", async () => { - const repoRoot = await createTempRepo(); - await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); - await fs.mkdir(path.join(repoRoot, "packages", "shared"), { recursive: true }); - await fs.mkdir(path.join(repoRoot, "server"), { recursive: true }); - await fs.writeFile( - path.join(repoRoot, "package.json"), - JSON.stringify( - { - name: "workspace-root", - private: true, - packageManager: "pnpm@9.15.4", - }, - null, - 2, - ), - "utf8", - ); - await fs.writeFile( - path.join(repoRoot, "pnpm-workspace.yaml"), - ["packages:", " - packages/*", " - server", ""].join("\n"), - "utf8", - ); - await fs.writeFile( - path.join(repoRoot, "packages", "shared", "package.json"), - JSON.stringify( - { - name: "@repo/shared", - version: "1.0.0", - private: true, - type: "module", - exports: "./index.js", - }, - null, - 2, - ), - "utf8", - ); - await fs.writeFile(path.join(repoRoot, "packages", "shared", "index.js"), "export const value = 'shared';\n", "utf8"); - await fs.writeFile( - path.join(repoRoot, "server", "package.json"), - JSON.stringify( - { - name: "server", - private: true, - type: "module", - dependencies: { - "@repo/shared": "workspace:*", - }, - }, - null, - 2, - ), - "utf8", - ); - await fs.writeFile(path.join(repoRoot, "server", "index.js"), "export {};\n", "utf8"); - await fs.copyFile(provisionWorktreeScriptPath, path.join(repoRoot, "scripts", "provision-worktree.sh")); - await fs.chmod(path.join(repoRoot, "scripts", "provision-worktree.sh"), 0o755); - await runPnpm(repoRoot, ["install"]); - await runGit(repoRoot, ["add", "."]); - await runGit(repoRoot, ["commit", "-m", "Add pnpm workspace fixture"]); - - const workspace = await realizeExecutionWorkspace({ - base: { - baseCwd: repoRoot, - source: "project_primary", - projectId: "project-1", - workspaceId: "workspace-1", - repoUrl: null, - repoRef: "HEAD", + const repoRoot = await createTempRepo(); + await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); + await fs.mkdir(path.join(repoRoot, "packages", "shared"), { recursive: true }); + await fs.mkdir(path.join(repoRoot, "server"), { recursive: true }); + await fs.writeFile( + path.join(repoRoot, "package.json"), + JSON.stringify( + { + name: "workspace-root", + private: true, + packageManager: "pnpm@9.15.4", }, - config: { - workspaceStrategy: { - type: "git_worktree", - branchTemplate: "{{issue.identifier}}-{{slug}}", - provisionCommand: "bash ./scripts/provision-worktree.sh", - }, + null, + 2, + ), + "utf8", + ); + await fs.writeFile( + path.join(repoRoot, "pnpm-workspace.yaml"), + ["packages:", " - packages/*", " - server", ""].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(repoRoot, "packages", "shared", "package.json"), + JSON.stringify( + { + name: "@repo/shared", + version: "1.0.0", + private: true, + type: "module", + exports: "./index.js", }, - issue: { - id: "issue-1", - identifier: "PAP-551", - title: "Provision local workspace dependencies", + null, + 2, + ), + "utf8", + ); + await fs.writeFile(path.join(repoRoot, "packages", "shared", "index.js"), "export const value = 'shared';\n", "utf8"); + await fs.writeFile( + path.join(repoRoot, "server", "package.json"), + JSON.stringify( + { + name: "server", + private: true, + type: "module", + dependencies: { + "@repo/shared": "workspace:*", + }, }, - agent: { - id: "agent-1", - name: "Codex Coder", - companyId: "company-1", + null, + 2, + ), + "utf8", + ); + await fs.writeFile(path.join(repoRoot, "server", "index.js"), "export {};\n", "utf8"); + await fs.copyFile(provisionWorktreeScriptPath, path.join(repoRoot, "scripts", "provision-worktree.sh")); + await fs.chmod(path.join(repoRoot, "scripts", "provision-worktree.sh"), 0o755); + await runPnpm(repoRoot, ["install"]); + await runGit(repoRoot, ["add", "."]); + await runGit(repoRoot, ["commit", "-m", "Add pnpm workspace fixture"]); + + const workspace = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + provisionCommand: "bash ./scripts/provision-worktree.sh", }, - }); + }, + issue: { + id: "issue-1", + identifier: "PAP-551", + title: "Provision local workspace dependencies", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); - expect((await fs.lstat(path.join(workspace.cwd, "node_modules"))).isSymbolicLink()).toBe(false); - expect((await fs.lstat(path.join(workspace.cwd, "server", "node_modules"))).isSymbolicLink()).toBe(false); - await expect(fs.realpath(path.join(workspace.cwd, "server", "node_modules", "@repo", "shared"))).resolves.toBe( - await fs.realpath(path.join(workspace.cwd, "packages", "shared")), - ); - await expect(fs.realpath(path.join(repoRoot, "server", "node_modules", "@repo", "shared"))).resolves.toBe( - await fs.realpath(path.join(repoRoot, "packages", "shared")), - ); + expect((await fs.lstat(path.join(workspace.cwd, "node_modules"))).isSymbolicLink()).toBe(false); + expect((await fs.lstat(path.join(workspace.cwd, "server", "node_modules"))).isSymbolicLink()).toBe(false); + await expect(fs.realpath(path.join(workspace.cwd, "server", "node_modules", "@repo", "shared"))).resolves.toBe( + await fs.realpath(path.join(workspace.cwd, "packages", "shared")), + ); + await expect(fs.realpath(path.join(repoRoot, "server", "node_modules", "@repo", "shared"))).resolves.toBe( + await fs.realpath(path.join(repoRoot, "packages", "shared")), + ); }, 15_000, ); @@ -2006,6 +2035,37 @@ describe("realizeExecutionWorkspace", () => { }); describe("ensureRuntimeServicesForRun", () => { + it("leaves manual runtime services untouched during agent runs", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "taskcore-runtime-manual-")); + const workspace = buildWorkspace(workspaceRoot); + + const services = await ensureRuntimeServicesForRun({ + runId: "run-manual", + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + issue: null, + workspace, + config: { + desiredState: "manual", + workspaceRuntime: { + services: [ + { + name: "web", + command: "node -e \"throw new Error('should not start')\"", + port: { type: "auto" }, + }, + ], + }, + }, + adapterEnv: {}, + }); + + expect(services).toEqual([]); + }); + it("reuses shared runtime services across runs and starts a new service after release", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "taskcore-runtime-workspace-")); const workspace = buildWorkspace(workspaceRoot); @@ -2575,6 +2635,41 @@ describe("buildWorkspaceRuntimeDesiredStatePatch", () => { }, }); }); + + it("preserves manual service state when manually starting or stopping services", () => { + const baseInput = { + config: { + workspaceRuntime: { + services: [ + { name: "web", command: "pnpm dev" }, + ], + }, + }, + currentDesiredState: "manual" as const, + currentServiceStates: null, + serviceIndex: 0, + }; + + expect(buildWorkspaceRuntimeDesiredStatePatch({ + ...baseInput, + action: "start", + })).toEqual({ + desiredState: "manual", + serviceStates: { + "0": "manual", + }, + }); + + expect(buildWorkspaceRuntimeDesiredStatePatch({ + ...baseInput, + action: "stop", + })).toEqual({ + desiredState: "manual", + serviceStates: { + "0": "manual", + }, + }); + }); }); describe("resolveWorkspaceRuntimeReadinessTimeoutSec", () => { diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts index 5f50dee..45c5c9b 100644 --- a/server/src/__tests__/worktree-config.test.ts +++ b/server/src/__tests__/worktree-config.test.ts @@ -206,6 +206,113 @@ describe("worktree config repair", () => { expect(repairedConfig.database.embeddedPostgresPort).toBe(54331); }); + it("does not persist transient runtime home overrides over repo-local worktree env", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "taskcore-worktree-runtime-override-")); + const isolatedHome = path.join(tempRoot, ".taskcore-worktrees"); + const transientHome = path.join(tempRoot, "tests", "e2e", ".tmp", "multiuser-authenticated"); + const worktreeRoot = path.join(tempRoot, "PAP-989-multi-user-implementation-using-plan-from-pap-958"); + const taskcoreDir = path.join(worktreeRoot, ".taskcore"); + const configPath = path.join(taskcoreDir, "config.json"); + const envPath = path.join(taskcoreDir, ".env"); + const instanceId = "pap-989-multi-user-implementation-using-plan-from-pap-958"; + const stableInstanceRoot = path.join(isolatedHome, "instances", instanceId); + + await fs.mkdir(taskcoreDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + ...buildLegacyConfig(transientHome), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(transientHome, "instances", instanceId, "db"), + embeddedPostgresPort: 54334, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(transientHome, "instances", instanceId, "data", "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(transientHome, "instances", instanceId, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3104, + allowedHostnames: [], + serveUi: true, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(transientHome, "instances", instanceId, "data", "storage"), + }, + s3: { + bucket: "taskcore", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(transientHome, "instances", instanceId, "secrets", "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + await fs.writeFile( + envPath, + [ + "# Taskcore environment variables", + `TASKCORE_HOME=${JSON.stringify(isolatedHome)}`, + `TASKCORE_INSTANCE_ID=${JSON.stringify(instanceId)}`, + `TASKCORE_CONFIG=${JSON.stringify(configPath)}`, + `TASKCORE_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`, + 'TASKCORE_IN_WORKTREE="true"', + 'TASKCORE_WORKTREE_NAME="PAP-989-multi-user-implementation-using-plan-from-pap-958"', + "", + ].join("\n"), + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.TASKCORE_IN_WORKTREE = "true"; + process.env.TASKCORE_WORKTREE_NAME = "PAP-989-multi-user-implementation-using-plan-from-pap-958"; + process.env.TASKCORE_HOME = transientHome; + process.env.TASKCORE_INSTANCE_ID = instanceId; + process.env.TASKCORE_CONFIG = configPath; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + const repairedEnv = await fs.readFile(envPath, "utf8"); + + expect(result).toEqual({ + repairedConfig: true, + repairedEnv: false, + }); + expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(stableInstanceRoot, "db")); + expect(repairedConfig.database.backup.dir).toBe(path.join(stableInstanceRoot, "data", "backups")); + expect(repairedConfig.logging.logDir).toBe(path.join(stableInstanceRoot, "logs")); + expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(stableInstanceRoot, "data", "storage")); + expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe( + path.join(stableInstanceRoot, "secrets", "master.key"), + ); + expect(repairedEnv).toContain(`TASKCORE_HOME=${JSON.stringify(isolatedHome)}`); + expect(repairedEnv).not.toContain(`TASKCORE_HOME=${JSON.stringify(transientHome)}`); + expect(process.env.TASKCORE_HOME).toBe(isolatedHome); + }); + it("rebalances duplicate ports for already isolated worktree configs", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "taskcore-worktree-rebalance-")); const isolatedHome = path.join(tempRoot, ".taskcore-worktrees"); diff --git a/server/src/adapters/http/execute.test.ts b/server/src/adapters/http/execute.test.ts new file mode 100644 index 0000000..8565380 --- /dev/null +++ b/server/src/adapters/http/execute.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { execute } from "./execute.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("http adapter execute", () => { + it("reports configured request timeout as timed_out", async () => { + vi.stubGlobal( + "fetch", + vi.fn((_url: string, init?: RequestInit) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("Aborted", "AbortError")); + }); + })), + ); + + const result = await execute({ + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Agent", + adapterType: "http", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + url: "https://example.test/webhook", + timeoutMs: 1, + }, + context: {}, + onLog: async () => {}, + }); + + expect(result.timedOut).toBe(true); + expect(result.errorCode).toBe("timeout"); + expect(result.errorMessage).toContain("timed out after 1ms"); + }); +}); diff --git a/server/src/adapters/http/execute.ts b/server/src/adapters/http/execute.ts index eff1402..c94b422 100644 --- a/server/src/adapters/http/execute.ts +++ b/server/src/adapters/http/execute.ts @@ -36,6 +36,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise(ctx: T): T { + const config = + ctx && typeof ctx === "object" && "config" in ctx && ctx.config && typeof ctx.config === "object" + ? (ctx.config as Record) + : null; + const agent = + ctx && typeof ctx === "object" && "agent" in ctx && ctx.agent && typeof ctx.agent === "object" + ? (ctx.agent as Record) + : null; + const agentAdapterConfig = + agent?.adapterConfig && typeof agent.adapterConfig === "object" + ? (agent.adapterConfig as Record) + : null; + + const configCommand = + typeof config?.command === "string" && config.command.length > 0 ? config.command : undefined; + const agentCommand = + typeof agentAdapterConfig?.command === "string" && agentAdapterConfig.command.length > 0 + ? agentAdapterConfig.command + : undefined; + + if (config && !config.hermesCommand && configCommand) { + config.hermesCommand = configCommand; + } + if (agentAdapterConfig && !agentAdapterConfig.hermesCommand && agentCommand) { + agentAdapterConfig.hermesCommand = agentCommand; + } + + return ctx; +} + const claudeLocalAdapter: ServerAdapterModule = { type: "claude_local", execute: claudeExecute, @@ -97,6 +128,9 @@ const claudeLocalAdapter: ServerAdapterModule = { models: claudeModels, listModels: listClaudeModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: claudeAgentConfigurationDoc, getQuotaWindows: claudeGetQuotaWindows, }; @@ -112,6 +146,9 @@ const codexLocalAdapter: ServerAdapterModule = { models: codexModels, listModels: listCodexModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: codexAgentConfigurationDoc, getQuotaWindows: codexGetQuotaWindows, }; @@ -127,6 +164,9 @@ const cursorLocalAdapter: ServerAdapterModule = { models: cursorModels, listModels: listCursorModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: cursorAgentConfigurationDoc, }; @@ -140,6 +180,9 @@ const geminiLocalAdapter: ServerAdapterModule = { sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined, models: geminiModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: geminiAgentConfigurationDoc, }; @@ -149,6 +192,8 @@ const openclawGatewayAdapter: ServerAdapterModule = { testEnvironment: openclawGatewayTestEnvironment, models: openclawGatewayModels, supportsLocalAgentJwt: false, + supportsInstructionsBundle: false, + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: openclawGatewayAgentConfigurationDoc, }; @@ -163,6 +208,9 @@ const openCodeLocalAdapter: ServerAdapterModule = { sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined, listModels: listOpenCodeModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: openCodeAgentConfigurationDoc, }; @@ -177,18 +225,74 @@ const piLocalAdapter: ServerAdapterModule = { models: [], listModels: listPiModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: piAgentConfigurationDoc, }; +// hermes-paperclip-adapter v0.2.0 predates the authToken field; cast is +// intentional until hermes ships a matching AdapterExecutionContext type. +const executeHermesLocal = hermesExecute as unknown as ServerAdapterModule["execute"]; + const hermesLocalAdapter: ServerAdapterModule = { type: "hermes_local", - execute: hermesExecute, - testEnvironment: hermesTestEnvironment, + execute: async (ctx) => { + const normalizedCtx = normalizeHermesConfig(ctx); + if (!normalizedCtx.authToken) return executeHermesLocal(normalizedCtx); + + const existingConfig = (normalizedCtx.agent.adapterConfig ?? {}) as Record; + const existingEnv = + typeof existingConfig.env === "object" && existingConfig.env !== null && !Array.isArray(existingConfig.env) + ? (existingConfig.env as Record) + : {}; + const explicitApiKey = + typeof existingEnv.TASKCORE_API_KEY === "string" && existingEnv.TASKCORE_API_KEY.trim().length > 0; + const promptTemplate = + typeof existingConfig.promptTemplate === "string" && existingConfig.promptTemplate.trim().length > 0 + ? existingConfig.promptTemplate + : ""; + const authGuardPrompt = [ + "Taskcore API safety rule:", + "Use Authorization: Bearer $TASKCORE_API_KEY on every Taskcore API request.", + "Use X-Taskcore-Run-Id: $TASKCORE_RUN_ID on every Taskcore API request that writes or mutates data, including comments and issue updates.", + "Never use a board, browser, or local-board session for Taskcore API writes.", + ].join("\n"); + + const patchedConfig: Record = { + ...existingConfig, + env: { + ...existingEnv, + ...(!explicitApiKey ? { TASKCORE_API_KEY: normalizedCtx.authToken } : {}), + TASKCORE_RUN_ID: normalizedCtx.runId, + }, + }; + + // Only inject the auth guard into promptTemplate when a custom template already exists. + // When no custom template is set, Hermes uses its built-in default heartbeat/task prompt — + // overwriting it with only the auth guard text would strip the assigned issue/workflow instructions. + if (promptTemplate) { + patchedConfig.promptTemplate = `${authGuardPrompt}\n\n${promptTemplate}`; + } + + const patchedCtx = { + ...normalizedCtx, + agent: { + ...normalizedCtx.agent, + adapterConfig: patchedConfig, + }, + }; + + return executeHermesLocal(patchedCtx); + }, + testEnvironment: (ctx) => hermesTestEnvironment(normalizeHermesConfig(ctx) as never), sessionCodec: hermesSessionCodec, listSkills: hermesListSkills, syncSkills: hermesSyncSkills, models: hermesModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: false, + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: hermesAgentConfigurationDoc, detectModel: () => detectModelFromHermes(), }; diff --git a/server/src/adapters/utils.ts b/server/src/adapters/utils.ts index 7dad0c9..9deba13 100644 --- a/server/src/adapters/utils.ts +++ b/server/src/adapters/utils.ts @@ -24,6 +24,7 @@ export const asBoolean = serverUtils.asBoolean; export const asStringArray = serverUtils.asStringArray; export const parseJson = serverUtils.parseJson; export const appendWithCap = serverUtils.appendWithCap; +export const appendWithByteCap = serverUtils.appendWithByteCap; export const resolvePathValue = serverUtils.resolvePathValue; export const renderTemplate = serverUtils.renderTemplate; export const redactEnvForLogs = serverUtils.redactEnvForLogs; @@ -38,7 +39,6 @@ export function buildInvocationEnvForLogs( env: Record, options: BuildInvocationEnvForLogsOptions = {}, ): Record { - // TODO: Remove this fallback once @taskcore/adapter-utils exports buildInvocationEnvForLogs everywhere we consume it. const maybeBuildInvocationEnvForLogs = ( serverUtils as typeof serverUtils & { buildInvocationEnvForLogs?: ( diff --git a/server/src/app.ts b/server/src/app.ts index c13a03c..a3b7a83 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -23,11 +23,17 @@ import { secretRoutes } from "./routes/secrets.js"; import { costRoutes } from "./routes/costs.js"; import { activityRoutes } from "./routes/activity.js"; import { dashboardRoutes } from "./routes/dashboard.js"; +import { userProfileRoutes } from "./routes/user-profiles.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; +import { + instanceDatabaseBackupRoutes, + type InstanceDatabaseBackupService, +} from "./routes/instance-database-backups.js"; import { llmRoutes } from "./routes/llms.js"; +import { authRoutes } from "./routes/auth.js"; import { assetRoutes } from "./routes/assets.js"; import { accessRoutes } from "./routes/access.js"; import { pluginRoutes } from "./routes/plugins.js"; @@ -70,6 +76,7 @@ const VITE_DEV_STATIC_PATHS = new Set([ "/favicon.ico", "/favicon.svg", "/site.webmanifest", + "/sw.js", ]); export function resolveViteHmrPort(serverPort: number): number { @@ -79,13 +86,23 @@ export function resolveViteHmrPort(serverPort: number): number { return Math.max(1_024, serverPort - 10_000); } -function shouldServeViteDevHtml(req: ExpressRequest): boolean { +export function shouldServeViteDevHtml(req: ExpressRequest): boolean { const pathname = req.path; if (VITE_DEV_STATIC_PATHS.has(pathname)) return false; if (VITE_DEV_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return false; return req.accepts(["html"]) === "html"; } +export function shouldEnablePrivateHostnameGuard(opts: { + deploymentMode: DeploymentMode; + deploymentExposure: DeploymentExposure; +}): boolean { + return ( + opts.deploymentExposure === "private" && + (opts.deploymentMode === "local_trusted" || opts.deploymentMode === "authenticated") + ); +} + export async function createApp( db: Db, opts: { @@ -100,6 +117,7 @@ export async function createApp( now?: Date; }): Promise; }; + databaseBackupService?: InstanceDatabaseBackupService; deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; allowedHostnames: string[]; @@ -109,6 +127,7 @@ export async function createApp( instanceId?: string; hostVersion?: string; localPluginDir?: string; + pluginMigrationDb?: Db; betterAuthHandler?: express.RequestHandler; resolveSession?: (req: ExpressRequest) => Promise; }, @@ -123,8 +142,10 @@ export async function createApp( }, })); app.use(httpLogger); - const privateHostnameGateEnabled = - opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"; + const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({ + deploymentMode: opts.deploymentMode, + deploymentExposure: opts.deploymentExposure, + }); const privateHostnameAllowSet = resolvePrivateHostnameAllowSet({ allowedHostnames: opts.allowedHostnames, bindHost: opts.bindHost, @@ -142,23 +163,7 @@ export async function createApp( resolveSession: opts.resolveSession, }), ); - app.get("/api/auth/get-session", (req, res) => { - if (req.actor.type !== "board" || !req.actor.userId) { - res.status(401).json({ error: "Unauthorized" }); - return; - } - res.json({ - session: { - id: `taskcore:${req.actor.source}:${req.actor.userId}`, - userId: req.actor.userId, - }, - user: { - id: req.actor.userId, - email: null, - name: req.actor.source === "local_implicit" ? "Local Board" : null, - }, - }); - }); + app.use("/api/auth", authRoutes(db)); if (opts.betterAuthHandler) { app.all("/api/auth/{*authPath}", opts.betterAuthHandler); } @@ -192,11 +197,14 @@ export async function createApp( api.use(costRoutes(db)); api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); + api.use(userProfileRoutes(db)); api.use(sidebarBadgeRoutes(db)); api.use(sidebarPreferenceRoutes(db)); api.use(inboxDismissalRoutes(db)); api.use(instanceSettingsRoutes(db)); - api.use("/onboarding", onboardingRoutes()); + if (opts.databaseBackupService) { + api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService)); + } const hostServicesDisposers = new Map void>(); const workerManager = createPluginWorkerManager(); const pluginRegistry = pluginRegistryService(db); @@ -224,7 +232,10 @@ export async function createApp( let viteHtmlRenderer: ReturnType | null = null; const loader = pluginLoader( db, - { localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR }, + { + localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR, + migrationDb: opts.pluginMigrationDb, + }, { workerManager, eventBus, @@ -288,9 +299,46 @@ export async function createApp( const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html"))); if (uiDist) { const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); - app.use(express.static(uiDist)); - app.get(/.*/, (_req, res) => { - res.status(200).set("Content-Type", "text/html").end(indexHtml); + // Hashed asset files (Vite emits them under /assets/..) + // never change once built, so they can be cached aggressively. + app.use( + "/assets", + express.static(path.join(uiDist, "assets"), { + maxAge: "1y", + immutable: true, + }), + ); + // Non-hashed static files (favicon.ico, manifest, robots.txt, etc.): + // short cache so operators who swap them out see the new version + // reasonably fast. Override for `index.html` specifically — it is + // served by this middleware for `/` and `/index.html`, and it must + // never outlive the asset hashes it points at. + app.use( + express.static(uiDist, { + maxAge: "1h", + setHeaders(res, filePath) { + if (path.basename(filePath) === "index.html") { + res.set("Cache-Control", "no-cache"); + } + }, + }), + ); + // SPA fallback. Only for non-asset routes — if the browser asks for + // /assets/something.js that doesn't exist, we must NOT serve the HTML + // shell: the browser would try to load it as a JavaScript module, fail + // with a MIME-type error, and cache that broken response. Return 404 + // instead. The index.html response itself is no-cache so a subsequent + // deploy's updated asset hashes are picked up on next load. + app.get(/.*/, (req, res) => { + if (req.path.startsWith("/assets/")) { + res.status(404).end(); + return; + } + res + .status(200) + .set("Content-Type", "text/html") + .set("Cache-Control", "no-cache") + .end(indexHtml); }); } else { console.warn("[taskcore] UI dist not found; running in API-only mode"); @@ -299,6 +347,7 @@ export async function createApp( if (opts.uiMode === "vite-dev") { const uiRoot = path.resolve(__dirname, "../../ui"); + const publicUiRoot = path.resolve(uiRoot, "public"); const hmrPort = resolveViteHmrPort(opts.serverPort); const { createServer: createViteServer } = await import("vite"); const vite = await createViteServer({ @@ -321,6 +370,9 @@ export async function createApp( }); const renderViteHtml = viteHtmlRenderer; + if (fs.existsSync(publicUiRoot)) { + app.use(express.static(publicUiRoot, { index: false })); + } app.get(/.*/, async (req, res, next) => { if (!shouldServeViteDevHtml(req)) { next(); diff --git a/server/src/attachment-types.ts b/server/src/attachment-types.ts index 4a403e2..e24d77d 100644 --- a/server/src/attachment-types.ts +++ b/server/src/attachment-types.ts @@ -27,13 +27,6 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [ "application/json", "text/csv", "text/html", - "text/javascript", - "application/javascript", - "text/typescript", - "application/typescript", - "text/css", - "application/xml", - "text/xml", ]; export const DEFAULT_ATTACHMENT_CONTENT_TYPE = "application/octet-stream"; @@ -45,13 +38,6 @@ export const INLINE_ATTACHMENT_TYPES: readonly string[] = [ "text/markdown", "application/json", "text/csv", - "text/javascript", - "application/javascript", - "text/typescript", - "application/typescript", - "text/css", - "application/xml", - "text/xml", ]; /** diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts index 6b65bbf..27cb68e 100644 --- a/server/src/auth/better-auth.ts +++ b/server/src/auth/better-auth.ts @@ -11,6 +11,7 @@ import { authVerifications, } from "@taskcore/db"; import type { Config } from "../config.js"; +import { resolveTaskcoreInstanceId } from "../home-paths.js"; export type BetterAuthSessionUser = { id: string; @@ -25,6 +26,24 @@ export type BetterAuthSessionResult = { type BetterAuthInstance = ReturnType; +const AUTH_COOKIE_PREFIX_FALLBACK = "default"; +const AUTH_COOKIE_PREFIX_INVALID_SEGMENTS_RE = /[^a-zA-Z0-9_-]+/g; + +export function deriveAuthCookiePrefix(instanceId = resolveTaskcoreInstanceId()): string { + const scopedInstanceId = instanceId + .trim() + .replace(AUTH_COOKIE_PREFIX_INVALID_SEGMENTS_RE, "-") + .replace(/^-+|-+$/g, "") || AUTH_COOKIE_PREFIX_FALLBACK; + return `taskcore-${scopedInstanceId}`; +} + +export function buildBetterAuthAdvancedOptions(input: { disableSecureCookies: boolean }) { + return { + cookiePrefix: deriveAuthCookiePrefix(), + ...(input.disableSecureCookies ? { useSecureCookies: false } : {}), + }; +} + function headersFromNodeHeaders(rawHeaders: IncomingHttpHeaders): Headers { const headers = new Headers(); for (const [key, raw] of Object.entries(rawHeaders)) { @@ -97,7 +116,7 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins? requireEmailVerification: false, disableSignUp: config.authDisableSignUp, }, - ...(isHttpOnly ? { advanced: { useSecureCookies: false } } : {}), + advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: isHttpOnly }), }; if (!baseUrl) { @@ -135,10 +154,10 @@ export async function resolveBetterAuthSessionFromHeaders( : null; const user = value.user?.id ? { - id: value.user.id, - email: value.user.email ?? null, - name: value.user.name ?? null, - } + id: value.user.id, + email: value.user.email ?? null, + name: value.user.name ?? null, + } : null; if (!session || !user) return null; diff --git a/server/src/config.ts b/server/src/config.ts index a7fd68e..6095518 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -62,6 +62,7 @@ export interface Config { authDisableSignUp: boolean; databaseMode: DatabaseMode; databaseUrl: string | undefined; + databaseMigrationUrl: string | undefined; embeddedPostgresDataDir: string; embeddedPostgresPort: number; databaseBackupEnabled: boolean; @@ -141,8 +142,8 @@ export function loadConfig(): Config { const storageProvider: StorageProvider = storageProviderFromEnv ?? fileStorage?.provider ?? "local_disk"; const storageLocalDiskBaseDir = resolveHomeAwarePath( process.env.TASKCORE_STORAGE_LOCAL_DIR ?? - fileStorage?.localDisk?.baseDir ?? - resolveDefaultStorageDir(), + fileStorage?.localDisk?.baseDir ?? + resolveDefaultStorageDir(), ); const storageS3Bucket = process.env.TASKCORE_STORAGE_S3_BUCKET ?? fileStorage?.s3?.bucket ?? "taskcore"; const storageS3Region = process.env.TASKCORE_STORAGE_S3_REGION ?? fileStorage?.s3?.region ?? "us-east-1"; @@ -170,7 +171,7 @@ export function loadConfig(): Config { const deploymentExposureFromEnvRaw = process.env.TASKCORE_DEPLOYMENT_EXPOSURE; const deploymentExposureFromEnv = deploymentExposureFromEnvRaw && - DEPLOYMENT_EXPOSURES.includes(deploymentExposureFromEnvRaw as DeploymentExposure) + DEPLOYMENT_EXPOSURES.includes(deploymentExposureFromEnvRaw as DeploymentExposure) ? (deploymentExposureFromEnvRaw as DeploymentExposure) : null; const deploymentExposure: DeploymentExposure = @@ -192,7 +193,7 @@ export function loadConfig(): Config { const authBaseUrlModeFromEnvRaw = process.env.TASKCORE_AUTH_BASE_URL_MODE; const authBaseUrlModeFromEnv = authBaseUrlModeFromEnvRaw && - AUTH_BASE_URL_MODES.includes(authBaseUrlModeFromEnvRaw as AuthBaseUrlMode) + AUTH_BASE_URL_MODES.includes(authBaseUrlModeFromEnvRaw as AuthBaseUrlMode) ? (authBaseUrlModeFromEnvRaw as AuthBaseUrlMode) : null; const publicUrlFromEnv = process.env.TASKCORE_PUBLIC_URL; @@ -250,19 +251,19 @@ export function loadConfig(): Config { const databaseBackupIntervalMinutes = Math.max( 1, Number(process.env.TASKCORE_DB_BACKUP_INTERVAL_MINUTES) || - fileDatabaseBackup?.intervalMinutes || - 60, + fileDatabaseBackup?.intervalMinutes || + 60, ); const databaseBackupRetentionDays = Math.max( 1, Number(process.env.TASKCORE_DB_BACKUP_RETENTION_DAYS) || - fileDatabaseBackup?.retentionDays || - 7, + fileDatabaseBackup?.retentionDays || + 7, ); const databaseBackupDir = resolveHomeAwarePath( process.env.TASKCORE_DB_BACKUP_DIR ?? - fileDatabaseBackup?.dir ?? - resolveDefaultBackupDir(), + fileDatabaseBackup?.dir ?? + resolveDefaultBackupDir(), ); const bindValidationErrors = validateConfiguredBindMode({ deploymentMode, @@ -297,6 +298,7 @@ export function loadConfig(): Config { authDisableSignUp, databaseMode: fileDatabaseMode, databaseUrl: process.env.DATABASE_URL ?? fileDbUrl, + databaseMigrationUrl: process.env.DATABASE_MIGRATION_URL, embeddedPostgresDataDir: resolveHomeAwarePath( fileConfig?.database.embeddedPostgresDataDir ?? resolveDefaultEmbeddedPostgresDir(), ), @@ -315,8 +317,8 @@ export function loadConfig(): Config { secretsMasterKeyFilePath: resolveHomeAwarePath( process.env.TASKCORE_SECRETS_MASTER_KEY_FILE ?? - fileSecrets?.localEncrypted.keyFilePath ?? - resolveDefaultSecretsKeyFilePath(), + fileSecrets?.localEncrypted.keyFilePath ?? + resolveDefaultSecretsKeyFilePath(), ), storageProvider, storageLocalDiskBaseDir, diff --git a/server/src/index.ts b/server/src/index.ts index dd7e598..13bcb4b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -41,6 +41,11 @@ import { printStartupBanner } from "./startup-banner.js"; import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js"; import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js"; import { initTelemetry, getTelemetryClient } from "./telemetry.js"; +import { conflict } from "./errors.js"; +import type { + InstanceDatabaseBackupRunResult, + InstanceDatabaseBackupTrigger, +} from "./routes/instance-database-backups.js"; type BetterAuthSessionUser = { id: string; @@ -91,25 +96,25 @@ export async function startServer(): Promise { if (process.env.TASKCORE_SECRETS_MASTER_KEY_FILE === undefined) { process.env.TASKCORE_SECRETS_MASTER_KEY_FILE = config.secretsMasterKeyFilePath; } - + type MigrationSummary = | "skipped" | "already applied" | "applied (empty database)" | "applied (pending migrations)"; - + function formatPendingMigrationSummary(migrations: string[]): string { if (migrations.length === 0) return "none"; return migrations.length > 3 ? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)` : migrations.join(", "); } - + async function promptApplyMigrations(migrations: string[]): Promise { if (process.env.TASKCORE_MIGRATION_AUTO_APPLY === "true") return true; if (process.env.TASKCORE_MIGRATION_PROMPT === "never") return false; if (!stdin.isTTY || !stdout.isTTY) return true; - + const prompt = createInterface({ input: stdin, output: stdout }); try { const answer = (await prompt.question( @@ -120,11 +125,11 @@ export async function startServer(): Promise { prompt.close(); } } - + type EnsureMigrationsOptions = { autoApply?: boolean; }; - + async function ensureMigrations( connectionString: string, label: string, @@ -153,28 +158,28 @@ export async function startServer(): Promise { if (!apply) { throw new Error( `${label} has pending migrations (${formatPendingMigrationSummary(state.pendingMigrations)}). ` + - "Refusing to start against a stale schema. Run pnpm db:migrate or set TASKCORE_MIGRATION_AUTO_APPLY=true.", + "Refusing to start against a stale schema. Run pnpm db:migrate or set TASKCORE_MIGRATION_AUTO_APPLY=true.", ); } - + logger.info({ pendingMigrations: state.pendingMigrations }, `Applying ${state.pendingMigrations.length} pending migrations for ${label}`); await applyPendingMigrations(connectionString); return "applied (pending migrations)"; } - + const apply = autoApply ? true : await promptApplyMigrations(state.pendingMigrations); if (!apply) { throw new Error( `${label} has pending migrations (${formatPendingMigrationSummary(state.pendingMigrations)}). ` + - "Refusing to start against a stale schema. Run pnpm db:migrate or set TASKCORE_MIGRATION_AUTO_APPLY=true.", + "Refusing to start against a stale schema. Run pnpm db:migrate or set TASKCORE_MIGRATION_AUTO_APPLY=true.", ); } - + logger.info({ pendingMigrations: state.pendingMigrations }, `Applying ${state.pendingMigrations.length} pending migrations for ${label}`); await applyPendingMigrations(connectionString); return "applied (pending migrations)"; } - + function isLoopbackHost(host: string): boolean { const normalized = host.trim().toLowerCase(); return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; @@ -191,11 +196,11 @@ export async function startServer(): Promise { return rawUrl; } } - + const LOCAL_BOARD_USER_ID = "local-board"; const LOCAL_BOARD_USER_EMAIL = "local@taskcore.local"; const LOCAL_BOARD_USER_NAME = "Board"; - + async function ensureLocalTrustedBoardPrincipal(db: any): Promise { const now = new Date(); const existingUser = await db @@ -203,7 +208,7 @@ export async function startServer(): Promise { .from(authUsers) .where(eq(authUsers.id, LOCAL_BOARD_USER_ID)) .then((rows: Array<{ id: string }>) => rows[0] ?? null); - + if (!existingUser) { await db.insert(authUsers).values({ id: LOCAL_BOARD_USER_ID, @@ -215,7 +220,7 @@ export async function startServer(): Promise { updatedAt: now, }); } - + const role = await db .select({ id: instanceUserRoles.id }) .from(instanceUserRoles) @@ -227,7 +232,7 @@ export async function startServer(): Promise { role: "instance_admin", }); } - + const companyRows = await db.select({ id: companies.id }).from(companies); for (const company of companyRows) { const membership = await db @@ -251,8 +256,9 @@ export async function startServer(): Promise { }); } } - + let db; + let pluginMigrationDb; let embeddedPostgres: EmbeddedPostgresInstance | null = null; let embeddedPostgresStartedByThisProcess = false; let migrationSummary: MigrationSummary = "skipped"; @@ -262,9 +268,11 @@ export async function startServer(): Promise { | { mode: "external-postgres"; connectionString: string } | { mode: "embedded-postgres"; dataDir: string; port: number }; if (config.databaseUrl) { - migrationSummary = await ensureMigrations(config.databaseUrl, "PostgreSQL"); - + const migrationUrl = config.databaseMigrationUrl ?? config.databaseUrl; + migrationSummary = await ensureMigrations(migrationUrl, "PostgreSQL"); + db = createDb(config.databaseUrl); + pluginMigrationDb = config.databaseMigrationUrl ? createDb(config.databaseMigrationUrl) : db; logger.info("Using external PostgreSQL via DATABASE_URL/config"); activeDatabaseConnectionString = config.databaseUrl; startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl }; @@ -279,7 +287,7 @@ export async function startServer(): Promise { "Embedded PostgreSQL mode requires dependency `embedded-postgres`. Reinstall dependencies (without omitting required packages), or set DATABASE_URL for external Postgres.", ); } - + const dataDir = resolve(config.embeddedPostgresDataDir); const configuredPort = config.embeddedPostgresPort; let port = configuredPort; @@ -314,11 +322,11 @@ export async function startServer(): Promise { ); } }; - + if (config.databaseMode === "postgres") { logger.warn("Database mode is postgres but no connection string was set; falling back to embedded PostgreSQL"); } - + const clusterVersionFile = resolve(dataDir, "PG_VERSION"); const clusterAlreadyInitialized = existsSync(clusterVersionFile); const postmasterPidFile = resolve(dataDir, "postmaster.pid"); @@ -330,7 +338,7 @@ export async function startServer(): Promise { return false; } }; - + const getRunningPid = (): number | null => { if (!existsSync(postmasterPidFile)) return null; try { @@ -343,7 +351,7 @@ export async function startServer(): Promise { return null; } }; - + const runningPid = getRunningPid(); if (runningPid) { logger.warn(`Embedded PostgreSQL already running; reusing existing process (pid=${runningPid}, port=${port})`); @@ -409,13 +417,13 @@ export async function startServer(): Promise { embeddedPostgresStartedByThisProcess = true; } } - + const embeddedAdminConnectionString = `postgres://taskcore:taskcore@127.0.0.1:${port}/postgres`; const dbStatus = await ensurePostgresDatabase(embeddedAdminConnectionString, "taskcore"); if (dbStatus === "created") { logger.info("Created embedded PostgreSQL database: taskcore"); } - + const embeddedConnectionString = `postgres://taskcore:taskcore@127.0.0.1:${port}/taskcore`; const shouldAutoApplyFirstRunMigrations = !clusterAlreadyInitialized || dbStatus === "created"; if (shouldAutoApplyFirstRunMigrations) { @@ -424,25 +432,26 @@ export async function startServer(): Promise { migrationSummary = await ensureMigrations(embeddedConnectionString, "Embedded PostgreSQL", { autoApply: shouldAutoApplyFirstRunMigrations, }); - + db = createDb(embeddedConnectionString); + pluginMigrationDb = db; logger.info("Embedded PostgreSQL ready"); activeDatabaseConnectionString = embeddedConnectionString; resolvedEmbeddedPostgresPort = port; startupDbInfo = { mode: "embedded-postgres", dataDir, port }; } - + if (config.deploymentMode === "local_trusted" && !isLoopbackHost(config.host)) { throw new Error( `local_trusted mode requires loopback host binding (received: ${config.host}). ` + - "Use authenticated mode for non-loopback deployments.", + "Use authenticated mode for non-loopback deployments.", ); } - + if (config.deploymentMode === "local_trusted" && config.deploymentExposure !== "private") { throw new Error("local_trusted mode only supports private exposure"); } - + if (config.deploymentMode === "authenticated") { if (config.authBaseUrlMode === "explicit" && !config.authPublicBaseUrl) { throw new Error("auth.baseUrlMode=explicit requires auth.publicBaseUrl"); @@ -456,7 +465,7 @@ export async function startServer(): Promise { } } } - + let authReady = config.deploymentMode === "local_trusted"; let betterAuthHandler: RequestHandler | undefined; let resolveSession: @@ -501,7 +510,7 @@ export async function startServer(): Promise { await initializeBoardClaimChallenge(db as any, { deploymentMode: config.deploymentMode }); authReady = true; } - + const listenPort = await detectPort(config.port); if (listenPort !== config.port) { config.port = listenPort; @@ -521,17 +530,87 @@ export async function startServer(): Promise { const feedback = feedbackService(db as any, { shareClient: createFeedbackTraceShareClientFromConfig(config), }); + const backupSettingsSvc = instanceSettingsService(db); + let databaseBackupInFlight = false; + const runServerDatabaseBackup = async ( + trigger: InstanceDatabaseBackupTrigger, + ): Promise => { + if (databaseBackupInFlight) { + const message = "Database backup already in progress"; + if (trigger === "scheduled") { + logger.warn("Skipping scheduled database backup because a previous backup is still running"); + return null; + } + throw conflict(message); + } + + databaseBackupInFlight = true; + const startedAt = new Date(); + const startedAtMs = Date.now(); + const label = trigger === "scheduled" ? "Automatic" : "Manual"; + try { + logger.info({ backupDir: config.databaseBackupDir, trigger }, `${label} database backup starting`); + // Read retention from Instance Settings (DB) so changes take effect without restart. + const generalSettings = await backupSettingsSvc.getGeneral(); + const retention = generalSettings.backupRetention; + + const result = await runDatabaseBackup({ + connectionString: activeDatabaseConnectionString, + backupDir: config.databaseBackupDir, + retention, + filenamePrefix: "taskcore", + }); + const finishedAt = new Date(); + const response: InstanceDatabaseBackupRunResult = { + ...result, + trigger, + backupDir: config.databaseBackupDir, + retention, + startedAt: startedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + durationMs: Date.now() - startedAtMs, + }; + logger.info( + { + backupFile: result.backupFile, + sizeBytes: result.sizeBytes, + prunedCount: result.prunedCount, + backupDir: config.databaseBackupDir, + retention, + trigger, + durationMs: response.durationMs, + }, + `${label} database backup complete: ${formatDatabaseBackupResult(result)}`, + ); + return response; + } catch (err) { + logger.error({ err, backupDir: config.databaseBackupDir, trigger }, `${label} database backup failed`); + throw err; + } finally { + databaseBackupInFlight = false; + } + }; const app = await createApp(db as any, { uiMode, serverPort: listenPort, storageService, feedbackExportService: feedback, + databaseBackupService: { + runManualBackup: async () => { + const result = await runServerDatabaseBackup("manual"); + if (!result) { + throw conflict("Database backup already in progress"); + } + return result; + }, + }, deploymentMode: config.deploymentMode, deploymentExposure: config.deploymentExposure, allowedHostnames: config.allowedHostnames, bindHost: config.host, authReady, companyDeletionEnabled: config.companyDeletionEnabled, + pluginMigrationDb: pluginMigrationDb as any, betterAuthHandler, resolveSession, }); @@ -542,11 +621,11 @@ export async function startServer(): Promise { // This prevents intermittent 502/ECONNRESET errors caused by Node's 5s default. server.keepAliveTimeout = 185000; server.headersTimeout = 186000; - + if (listenPort !== config.port) { logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`); } - + const runtimeListenHost = config.host; const runtimeApiHost = runtimeListenHost === "0.0.0.0" || runtimeListenHost === "::" @@ -554,8 +633,10 @@ export async function startServer(): Promise { : runtimeListenHost; process.env.TASKCORE_LISTEN_HOST = runtimeListenHost; process.env.TASKCORE_LISTEN_PORT = String(listenPort); - process.env.TASKCORE_API_URL = `http://${runtimeApiHost}:${listenPort}`; - + if (!process.env.TASKCORE_API_URL) { + process.env.TASKCORE_API_URL = `http://${runtimeApiHost}:${listenPort}`; + } + setupLiveEventsWebSocketServer(server, db as any, { deploymentMode: config.deploymentMode, resolveSessionFromHeaders, @@ -573,24 +654,35 @@ export async function startServer(): Promise { .catch((err) => { logger.error({ err }, "startup reconciliation of persisted runtime services failed"); }); - + if (config.heartbeatSchedulerEnabled) { const heartbeat = heartbeatService(db as any); const routines = routineService(db as any); - + // Reap orphaned running runs at startup while in-memory execution state is empty, // then resume any persisted queued runs that were waiting on the previous process. void heartbeat .reapOrphanedRuns() - .then(() => heartbeat.resumeQueuedRuns()) - .then(async () => { + .then(() => heartbeat.promoteDueScheduledRetries()) + .then(async (promotion) => { + await heartbeat.resumeQueuedRuns(); const reconciled = await heartbeat.reconcileStrandedAssignedIssues(); if ( + promotion.promoted > 0 || reconciled.dispatchRequeued > 0 || reconciled.continuationRequeued > 0 || reconciled.escalated > 0 ) { - logger.warn({ ...reconciled }, "startup stranded-issue reconciliation changed assigned issue state"); + logger.warn( + { promotedScheduledRetries: promotion.promoted, promotedScheduledRetryRunIds: promotion.runIds, ...reconciled }, + "startup heartbeat recovery changed assigned issue state", + ); + } + }) + .then(async () => { + const reconciled = await heartbeat.reconcileIssueGraphLiveness(); + if (reconciled.escalationsCreated > 0) { + logger.warn({ ...reconciled }, "startup issue-graph liveness reconciliation created escalations"); } }) .catch((err) => { @@ -618,20 +710,31 @@ export async function startServer(): Promise { .catch((err) => { logger.error({ err }, "routine scheduler tick failed"); }); - + // Periodically reap orphaned runs (5-min staleness threshold) and make sure // persisted queued work is still being driven forward. void heartbeat .reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000 }) - .then(() => heartbeat.resumeQueuedRuns()) - .then(async () => { + .then(() => heartbeat.promoteDueScheduledRetries()) + .then(async (promotion) => { + await heartbeat.resumeQueuedRuns(); const reconciled = await heartbeat.reconcileStrandedAssignedIssues(); if ( + promotion.promoted > 0 || reconciled.dispatchRequeued > 0 || reconciled.continuationRequeued > 0 || reconciled.escalated > 0 ) { - logger.warn({ ...reconciled }, "periodic stranded-issue reconciliation changed assigned issue state"); + logger.warn( + { promotedScheduledRetries: promotion.promoted, promotedScheduledRetryRunIds: promotion.runIds, ...reconciled }, + "periodic heartbeat recovery changed assigned issue state", + ); + } + }) + .then(async () => { + const reconciled = await heartbeat.reconcileIssueGraphLiveness(); + if (reconciled.escalationsCreated > 0) { + logger.warn({ ...reconciled }, "periodic issue-graph liveness reconciliation created escalations"); } }) .catch((err) => { @@ -639,46 +742,9 @@ export async function startServer(): Promise { }); }, config.heartbeatSchedulerIntervalMs); } - + if (config.databaseBackupEnabled) { const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000; - const settingsSvc = instanceSettingsService(db); - let backupInFlight = false; - - const runScheduledBackup = async () => { - if (backupInFlight) { - logger.warn("Skipping scheduled database backup because a previous backup is still running"); - return; - } - - backupInFlight = true; - try { - // Read retention from Instance Settings (DB) so changes take effect without restart - const generalSettings = await settingsSvc.getGeneral(); - const retention = generalSettings.backupRetention; - - const result = await runDatabaseBackup({ - connectionString: activeDatabaseConnectionString, - backupDir: config.databaseBackupDir, - retention, - filenamePrefix: "taskcore", - }); - logger.info( - { - backupFile: result.backupFile, - sizeBytes: result.sizeBytes, - prunedCount: result.prunedCount, - backupDir: config.databaseBackupDir, - retention, - }, - `Automatic database backup complete: ${formatDatabaseBackupResult(result)}`, - ); - } catch (err) { - logger.error({ err, backupDir: config.databaseBackupDir }, "Automatic database backup failed"); - } finally { - backupInFlight = false; - } - }; logger.info( { @@ -689,10 +755,12 @@ export async function startServer(): Promise { "Automatic database backups enabled", ); setInterval(() => { - void runScheduledBackup(); + void runServerDatabaseBackup("scheduled").catch(() => { + // runServerDatabaseBackup already logs the failure with context. + }); }, backupIntervalMs); } - + // Wait for external adapters to finish loading before accepting requests. // Without this, adapter type validation (assertKnownAdapterType) would // reject valid external adapter types during the startup loading window. @@ -721,10 +789,10 @@ export async function startServer(): Promise { logger.warn({ err, url }, "Failed to open browser on startup"); }); } - printStartupBanner({ - bind: config.bind, - host: config.host, - deploymentMode: config.deploymentMode, + printStartupBanner({ + bind: config.bind, + host: config.host, + deploymentMode: config.deploymentMode, deploymentExposure: config.deploymentExposure, authReady, requestedPort: config.port, @@ -759,7 +827,7 @@ export async function startServer(): Promise { resolveListen(); }); }); - + { const shutdown = async (signal: "SIGINT" | "SIGTERM") => { const telemetryClient = getTelemetryClient(); @@ -792,7 +860,7 @@ export async function startServer(): Promise { server, host: config.host, listenPort, - apiUrl: process.env.TASKCORE_API_URL ?? `http://${runtimeApiHost}:${listenPort}`, + apiUrl: process.env.TASKCORE_API_URL!, databaseUrl: activeDatabaseConnectionString, }; } diff --git a/server/src/lib/join-request-dedupe.ts b/server/src/lib/join-request-dedupe.ts new file mode 100644 index 0000000..97c2928 --- /dev/null +++ b/server/src/lib/join-request-dedupe.ts @@ -0,0 +1,88 @@ +import { joinRequests } from "@taskcore/db"; + +type JoinRequestLike = Pick< + typeof joinRequests.$inferSelect, + | "id" + | "requestType" + | "status" + | "requestingUserId" + | "requestEmailSnapshot" + | "createdAt" + | "updatedAt" +>; + +function nonEmptyTrimmed(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +export function normalizeJoinRequestEmail( + email: string | null | undefined +): string | null { + const trimmed = nonEmptyTrimmed(email); + return trimmed ? trimmed.toLowerCase() : null; +} + +export function humanJoinRequestIdentity( + row: Pick< + JoinRequestLike, + "requestType" | "requestingUserId" | "requestEmailSnapshot" + > +): string | null { + if (row.requestType !== "human") return null; + const requestingUserId = nonEmptyTrimmed(row.requestingUserId); + if (requestingUserId) return `user:${requestingUserId}`; + const email = normalizeJoinRequestEmail(row.requestEmailSnapshot); + return email ? `email:${email}` : null; +} + +export function findReusableHumanJoinRequest< + T extends Pick< + JoinRequestLike, + "id" | "requestType" | "status" | "requestingUserId" | "requestEmailSnapshot" + >, +>( + rows: T[], + actor: { requestingUserId?: string | null; requestEmailSnapshot?: string | null } +): T | null { + const actorUserId = nonEmptyTrimmed(actor.requestingUserId); + if (actorUserId) { + const sameUser = rows.find( + (row) => + row.requestType === "human" && + (row.status === "pending_approval" || row.status === "approved") && + row.requestingUserId === actorUserId + ); + if (sameUser) return sameUser; + } + + const actorEmail = normalizeJoinRequestEmail(actor.requestEmailSnapshot); + if (!actorEmail) return null; + return ( + rows.find( + (row) => + row.requestType === "human" && + (row.status === "pending_approval" || row.status === "approved") && + normalizeJoinRequestEmail(row.requestEmailSnapshot) === actorEmail + ) ?? null + ); +} + +export function collapseDuplicatePendingHumanJoinRequests< + T extends Pick< + JoinRequestLike, + "id" | "requestType" | "status" | "requestingUserId" | "requestEmailSnapshot" + >, +>(rows: T[]): T[] { + const seen = new Set(); + return rows.filter((row) => { + if (row.requestType !== "human" || row.status !== "pending_approval") { + return true; + } + const identity = humanJoinRequestIdentity(row); + if (!identity) return true; + if (seen.has(identity)) return false; + seen.add(identity); + return true; + }); +} diff --git a/server/src/log-redaction.ts b/server/src/log-redaction.ts index ab59b3e..952d155 100644 --- a/server/src/log-redaction.ts +++ b/server/src/log-redaction.ts @@ -112,6 +112,7 @@ export function redactCurrentUserText(input: string, opts?: CurrentUserRedaction let result = input; for (const homeDir of [...homeDirs].sort((a, b) => b.length - a.length)) { + if (!result.includes(homeDir)) continue; const lastSegment = splitPathSegments(homeDir).pop() ?? ""; const replacementDir = lastSegment ? replaceLastPathSegment(homeDir, maskUserNameForLogs(lastSegment, replacement)) @@ -120,6 +121,7 @@ export function redactCurrentUserText(input: string, opts?: CurrentUserRedaction } for (const userName of [...userNames].sort((a, b) => b.length - a.length)) { + if (!result.includes(userName)) continue; const pattern = new RegExp(`(? { req.actor = opts.deploymentMode === "local_trusted" - ? { type: "board", userId: "local-board", isInstanceAdmin: true, source: "local_implicit" } + ? { + type: "board", + userId: "local-board", + userName: "Local Board", + userEmail: null, + isInstanceAdmin: true, + source: "local_implicit", + } : { type: "none", source: "none" }; const runIdHeader = req.header("x-taskcore-run-id"); @@ -69,7 +56,11 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))) .then((rows) => rows[0] ?? null), db - .select({ companyId: companyMemberships.companyId }) + .select({ + companyId: companyMemberships.companyId, + membershipRole: companyMemberships.membershipRole, + status: companyMemberships.status, + }) .from(companyMemberships) .where( and( @@ -82,7 +73,10 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa req.actor = { type: "board", userId, + userName: session.user.name ?? null, + userEmail: session.user.email ?? null, companyIds: memberships.map((row) => row.companyId), + memberships, isInstanceAdmin: Boolean(roleRow), runId: runIdHeader ?? undefined, source: "session", @@ -110,7 +104,10 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa req.actor = { type: "board", userId: boardKey.userId, + userName: access.user?.name ?? null, + userEmail: access.user?.email ?? null, companyIds: access.companyIds, + memberships: access.memberships, isInstanceAdmin: access.isInstanceAdmin, keyId: boardKey.id, runId: runIdHeader || undefined, diff --git a/server/src/middleware/board-mutation-guard.ts b/server/src/middleware/board-mutation-guard.ts index feff3b4..0b86fbf 100644 --- a/server/src/middleware/board-mutation-guard.ts +++ b/server/src/middleware/board-mutation-guard.ts @@ -24,6 +24,12 @@ function trustedOriginsForRequest(req: Request) { origins.add(`http://${host}`.toLowerCase()); origins.add(`https://${host}`.toLowerCase()); } + // Behind some reverse proxies the Host / X-Forwarded-Host header may + // not match the public URL (for example when TLS terminates at the + // edge and the inbound Host is an internal service name). Trust the + // explicitly-configured TASKCORE_PUBLIC_URL when it's set. + const publicUrl = parseOrigin(process.env.TASKCORE_PUBLIC_URL?.trim()); + if (publicUrl) origins.add(publicUrl); return origins; } diff --git a/server/src/middleware/http-log-policy.ts b/server/src/middleware/http-log-policy.ts index fee44fe..a0b9369 100644 --- a/server/src/middleware/http-log-policy.ts +++ b/server/src/middleware/http-log-policy.ts @@ -23,8 +23,11 @@ const SILENCED_SUCCESS_STATIC_PREFIXES = [ ]; const SILENCED_SUCCESS_STATIC_PATHS = new Set([ + "/", + "/index.html", "/favicon.ico", "/site.webmanifest", + "/sw.js", ]); function normalizePath(url: string): string { diff --git a/server/src/onboarding-assets/ceo/AGENTS.md b/server/src/onboarding-assets/ceo/AGENTS.md index 9a1c3f2..ff42d57 100644 --- a/server/src/onboarding-assets/ceo/AGENTS.md +++ b/server/src/onboarding-assets/ceo/AGENTS.md @@ -32,6 +32,11 @@ You MUST delegate work rather than doing it yourself. When a task is assigned to - Don't let tasks sit idle. If you delegate something, check that it's progressing. - If a report is blocked, help unblock them -- escalate to the board if needed. - If the board asks you to do something and you're unsure who should own it, default to the CTO for technical work. +- Use child issues for delegated work and wait for Taskcore wake events or comments instead of polling agents, sessions, or processes in a loop. +- Create child issues directly when ownership and scope are clear. Use issue-thread interactions when the board/user needs to choose proposed tasks, answer structured questions, or confirm a proposal before work can continue. +- Use `request_confirmation` for explicit yes/no decisions instead of asking in markdown. For plan approval, update the `plan` document, create a confirmation targeting the latest plan revision with an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and wait for acceptance before delegating implementation subtasks. +- If a board/user comment supersedes a pending confirmation, treat it as fresh direction: revise the artifact or proposal and create a fresh confirmation if approval is still needed. +- Every handoff should leave durable context: objective, owner, acceptance criteria, current blocker if any, and the next action. - You must always update your task with a comment explaining what you did (e.g., who you delegated to and why). ## Memory and Planning diff --git a/server/src/onboarding-assets/ceo/HEARTBEAT.md b/server/src/onboarding-assets/ceo/HEARTBEAT.md index 02abe74..2c95aa8 100644 --- a/server/src/onboarding-assets/ceo/HEARTBEAT.md +++ b/server/src/onboarding-assets/ceo/HEARTBEAT.md @@ -48,6 +48,9 @@ Status quick guide: ## 6. Delegation - Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue. +- When you know the needed work and owner, create those subtasks directly. When the board/user must choose from a proposed task tree, answer structured questions, or confirm a proposal before you can proceed, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"` and `continuationPolicy: "wake_assignee"` when the answer should wake you. +- For plan approval, update the `plan` document first, create `request_confirmation` targeting the latest `plan` revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and do not create implementation subtasks until the board/user accepts it. +- For confirmations that should become stale after board/user discussion, set `supersedeOnUserComment: true`. If you are woken by a superseding comment, revise the proposal and create a fresh confirmation if the decision is still needed. - Use `taskcore-create-agent` skill when hiring new agents. - Assign work to the right agent for the job. diff --git a/server/src/onboarding-assets/default/AGENTS.md b/server/src/onboarding-assets/default/AGENTS.md index f3dbda2..016d8e7 100644 --- a/server/src/onboarding-assets/default/AGENTS.md +++ b/server/src/onboarding-assets/default/AGENTS.md @@ -1,3 +1,15 @@ You are an agent at Taskcore company. -Keep the work moving until it's done. If you need QA to review it, ask them. If you need your boss to review it, ask them. If someone needs to unblock you, assign them the ticket with a comment asking for what you need. Don't let work just sit here. You must always update your task with a comment. +## Execution Contract + +- Start actionable work in the same heartbeat. Do not stop at a plan unless the issue explicitly asks for planning. +- Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them. +- Leave durable progress in task comments, documents, or work products, and make the next action clear before you exit. +- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes. +- Create child issues directly when you know what needs to be done. If the board/user needs to choose suggested tasks, answer structured questions, or confirm a proposal first, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"`. +- Use `request_confirmation` instead of asking for yes/no decisions in markdown. For plan approval, update the `plan` document first, create a confirmation bound to the latest plan revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and wait for acceptance before creating implementation subtasks. +- Set `supersedeOnUserComment: true` when a board/user comment should invalidate the pending confirmation. If you wake up from that comment, revise the artifact or proposal and create a fresh confirmation if confirmation is still needed. +- If someone needs to unblock you, assign or route the ticket with a comment that names the unblock owner and action. +- Respect budget, pause/cancel, approval gates, and company boundaries. + +Do not let work sit here. You must always update your task with a comment. diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index eb26217..5bb38ac 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -4,19 +4,29 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; +import { lookup as dnsLookup } from "node:dns/promises"; import fs from "node:fs"; +import type { IncomingMessage, RequestOptions as HttpRequestOptions } from "node:http"; +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { isIP } from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Router } from "express"; import type { Request } from "express"; -import { and, eq, isNull, desc } from "drizzle-orm"; +import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, ne, sql } from "drizzle-orm"; import type { Db } from "@taskcore/db"; import { + assets, agentApiKeys, authUsers, companies, + companyLogos, + companyMemberships, + instanceUserRoles, invites, - joinRequests + joinRequests, + principalPermissionGrants, } from "@taskcore/db"; import { acceptInviteSchema, @@ -24,13 +34,18 @@ import { claimJoinRequestApiKeySchema, createCompanyInviteSchema, createOpenClawInvitePromptSchema, + listCompanyInvitesQuerySchema, listJoinRequestsQuerySchema, resolveCliAuthChallengeSchema, + searchAdminUsersQuerySchema, + updateCompanyMemberWithPermissionsSchema, + updateCompanyMemberSchema, + archiveCompanyMemberSchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, PERMISSION_KEYS } from "@taskcore/shared"; -import type { DeploymentExposure, DeploymentMode } from "@taskcore/shared"; +import type { DeploymentExposure, DeploymentMode, HumanCompanyMembershipRole, PermissionKey } from "@taskcore/shared"; import { forbidden, conflict, @@ -48,11 +63,22 @@ import { logActivity, notifyHireApproved } from "../services/index.js"; -import { assertCompanyAccess } from "./authz.js"; +import { + grantsForHumanRole, + normalizeHumanRole, + resolveHumanInviteRole, +} from "../services/company-member-roles.js"; +import { humanJoinGrantsFromDefaults } from "../services/invite-grants.js"; +import { + collapseDuplicatePendingHumanJoinRequests, + findReusableHumanJoinRequest, +} from "../lib/join-request-dedupe.js"; +import { assertAuthenticated, assertCompanyAccess } from "./authz.js"; import { claimBoardOwnership, inspectBoardClaimChallenge } from "../board-claim.js"; +import { getStorageService } from "../storage/index.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -62,7 +88,13 @@ const INVITE_TOKEN_PREFIX = "pcp_invite_"; const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; const INVITE_TOKEN_SUFFIX_LENGTH = 8; const INVITE_TOKEN_MAX_RETRIES = 5; -const COMPANY_INVITE_TTL_MS = 10 * 60 * 1000; +const COMPANY_INVITE_TTL_MS = 72 * 60 * 60 * 1000; +const INVITE_RESOLUTION_DNS_TIMEOUT_MS = 3_000; + +type MemberGrantPayload = { + permissionKey: PermissionKey; + scope?: Record | null; +}; function createInviteToken() { const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH); @@ -331,10 +363,10 @@ function extractHeaderEntries(input: unknown): Array<[string, unknown]> { ) ? mapped.value : Object.prototype.hasOwnProperty.call(mapped, "token") - ? mapped.token - : Object.prototype.hasOwnProperty.call(mapped, "secret") - ? mapped.secret - : mapped; + ? mapped.token + : Object.prototype.hasOwnProperty.call(mapped, "secret") + ? mapped.secret + : mapped; entries.push([explicitKey, explicitValue]); continue; } @@ -571,10 +603,10 @@ function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) { const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined; const gatewayTokenValue = headers ? headerMapGetIgnoreCase(headers, "x-openclaw-token") ?? - headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? - tokenFromAuthorizationHeader( - headerMapGetIgnoreCase(headers, "authorization") - ) + headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? + tokenFromAuthorizationHeader( + headerMapGetIgnoreCase(headers, "authorization") + ) : null; return { present: Boolean(defaults), @@ -756,8 +788,9 @@ export function normalizeAgentDefaultsForJoin(input: { diagnostics.push({ code: "openclaw_gateway_device_key_generate_failed", level: "warn", - message: `Failed to generate gateway device key: ${err instanceof Error ? err.message : String(err) - }`, + message: `Failed to generate gateway device key: ${ + err instanceof Error ? err.message : String(err) + }`, hint: "Set agentDefaultsPayload.devicePrivateKeyPem explicitly or set disableDeviceAuth=true." }); @@ -769,11 +802,11 @@ export function normalizeAgentDefaultsForJoin(input: { const waitTimeoutMs = typeof defaults.waitTimeoutMs === "number" && - Number.isFinite(defaults.waitTimeoutMs) + Number.isFinite(defaults.waitTimeoutMs) ? Math.floor(defaults.waitTimeoutMs) : typeof defaults.waitTimeoutMs === "string" - ? Number.parseInt(defaults.waitTimeoutMs.trim(), 10) - : NaN; + ? Number.parseInt(defaults.waitTimeoutMs.trim(), 10) + : NaN; if (Number.isFinite(waitTimeoutMs) && waitTimeoutMs > 0) { normalized.waitTimeoutMs = waitTimeoutMs; } @@ -782,8 +815,8 @@ export function normalizeAgentDefaultsForJoin(input: { typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec) ? Math.floor(defaults.timeoutSec) : typeof defaults.timeoutSec === "string" - ? Number.parseInt(defaults.timeoutSec.trim(), 10) - : NaN; + ? Number.parseInt(defaults.timeoutSec.trim(), 10) + : NaN; if (Number.isFinite(timeoutSec) && timeoutSec > 0) { normalized.timeoutSec = timeoutSec; } @@ -857,33 +890,512 @@ function toInviteSummaryResponse( req: Request, token: string, invite: typeof invites.$inferSelect, - companyName: string | null = null + company: + | string + | { + name: string | null; + brandColor: string | null; + logoUrl: string | null; + } + | null = null ) { + const companyInfo = typeof company === "string" + ? { name: company, brandColor: null, logoUrl: null } + : company; const baseUrl = requestBaseUrl(req); + const invitePath = `/invite/${token}`; const onboardingPath = `/api/invites/${token}/onboarding`; const onboardingTextPath = `/api/invites/${token}/onboarding.txt`; + const skillIndexPath = `/api/invites/${token}/skills/index`; const inviteMessage = extractInviteMessage(invite); return { id: invite.id, companyId: invite.companyId, - companyName, + companyName: companyInfo?.name ?? null, + companyLogoUrl: companyInfo?.logoUrl ?? null, + companyBrandColor: companyInfo?.brandColor ?? null, inviteType: invite.inviteType, allowedJoinTypes: invite.allowedJoinTypes, + humanRole: extractInviteHumanRole(invite), expiresAt: invite.expiresAt, + invitePath, + inviteUrl: baseUrl ? `${baseUrl}${invitePath}` : invitePath, onboardingPath, onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath, onboardingTextPath, onboardingTextUrl: baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath, - skillIndexPath: "/api/skills/index", + skillIndexPath, skillIndexUrl: baseUrl - ? `${baseUrl}/api/skills/index` - : "/api/skills/index", + ? `${baseUrl}${skillIndexPath}` + : skillIndexPath, inviteMessage }; } +function actorHasActiveUserMembership(req: Request, companyId: string) { + return ( + req.actor.type === "board" && + typeof req.actor.userId === "string" && + Array.isArray(req.actor.memberships) && + req.actor.memberships.some( + (membership) => + membership.companyId === companyId && membership.status === "active", + ) + ); +} + +async function loadUsersById(db: Db, userIds: string[]) { + if (userIds.length === 0) return new Map>(); + const rows = await db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .where(inArray(authUsers.id, userIds)); + return new Map(rows.map((row) => [row.id, toUserProfile(row)])); +} + +async function loadCompanyAccessSummary( + req: Request, + access: ReturnType, + companyId: string, +) { + if (req.actor.type !== "board") { + return { + currentUserRole: null, + canManageMembers: false, + canInviteUsers: false, + canApproveJoinRequests: false, + }; + } + if (isLocalImplicit(req)) { + return { + currentUserRole: "owner" as const, + canManageMembers: true, + canInviteUsers: true, + canApproveJoinRequests: true, + }; + } + const userId = req.actor.userId ?? null; + const membership = + userId ? await access.getMembership(companyId, "user", userId) : null; + const [canManageMembers, canInviteUsers, canApproveJoinRequests] = + await Promise.all([ + access.canUser(companyId, userId, "users:manage_permissions"), + access.canUser(companyId, userId, "users:invite"), + access.canUser(companyId, userId, "joins:approve"), + ]); + + return { + currentUserRole: + membership?.status === "active" && membership.membershipRole + ? normalizeHumanRole(membership.membershipRole, "operator") + : null, + canManageMembers, + canInviteUsers, + canApproveJoinRequests, + }; +} + +async function loadCompanyMemberRecords( + db: Db, + companyId: string, + options: { includeArchived?: boolean } = {}, +) { + const members = await db + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + options.includeArchived ? undefined : ne(companyMemberships.status, "archived"), + ), + ) + .orderBy(desc(companyMemberships.updatedAt)); + + const userIds = [...new Set(members.map((member) => member.principalId))]; + const [userMap, grants] = await Promise.all([ + loadUsersById(db, userIds), + userIds.length > 0 + ? db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, "user"), + inArray(principalPermissionGrants.principalId, userIds), + ), + ) + : Promise.resolve([]), + ]); + + const grantsByPrincipalId = new Map(); + for (const grant of grants) { + const existing = grantsByPrincipalId.get(grant.principalId) ?? []; + existing.push(grant); + grantsByPrincipalId.set(grant.principalId, existing); + } + + return members.map((member) => ({ + ...member, + principalType: "user" as const, + membershipRole: member.membershipRole + ? normalizeHumanRole(member.membershipRole, "operator") + : null, + user: userMap.get(member.principalId) ?? null, + grants: grantsByPrincipalId.get(member.principalId) ?? [], + })); +} + +type CompanyMemberRecord = Awaited>[number]; + +const humanRoleRank: Record = { + viewer: 1, + operator: 2, + admin: 3, + owner: 4, +}; + +async function resolveActorHumanRole( + req: Request, + access: ReturnType, + companyId: string, +): Promise { + if (req.actor.type !== "board") return null; + if (isLocalImplicit(req) || req.actor.isInstanceAdmin) return "owner"; + const userId = req.actor.userId ?? null; + if (!userId) return null; + const membership = await access.getMembership(companyId, "user", userId); + if (membership?.status !== "active" || !membership.membershipRole) return null; + return normalizeHumanRole(membership.membershipRole, "operator"); +} + +async function getProtectedMemberReason( + req: Request, + access: ReturnType, + companyId: string, + member: { principalId: string; principalType: string; membershipRole: string | null }, + opts?: { + actorRole?: HumanCompanyMembershipRole | null; + instanceAdminUserIds?: ReadonlySet; + operation?: "archive" | "update"; + }, +): Promise { + if (member.principalType !== "user") return "Only human company members can be removed."; + if (req.actor.type !== "board") return "Board access is required to remove members."; + if (member.principalId === req.actor.userId) return "You cannot remove yourself."; + const isTargetInstanceAdmin = opts?.instanceAdminUserIds + ? opts.instanceAdminUserIds.has(member.principalId) + : await access.isInstanceAdmin(member.principalId); + if (isTargetInstanceAdmin) { + return "Instance admins cannot be removed from company access."; + } + + const targetRole = member.membershipRole + ? normalizeHumanRole(member.membershipRole, "operator") + : "operator"; + if (opts?.operation === "archive") { + if (targetRole === "owner") return "Board owners cannot be removed from company access."; + if (targetRole === "admin") return "Company admins cannot be removed from company access."; + } + + const actorRole = opts?.actorRole ?? await resolveActorHumanRole(req, access, companyId); + if (!actorRole) return "Only active company members can remove users."; + if (humanRoleRank[targetRole] >= humanRoleRank[actorRole]) { + return "You can only remove users below your company role."; + } + + return null; +} + +async function assertCanManageCompanyMember( + req: Request, + access: ReturnType, + companyId: string, + member: { principalId: string; principalType: string; membershipRole: string | null }, + operation: "archive" | "update" = "update", +) { + const reason = await getProtectedMemberReason(req, access, companyId, member, { operation }); + if (reason) throw forbidden(reason); +} + +async function addCompanyMemberRemovalAccess( + req: Request, + db: Db, + access: ReturnType, + companyId: string, + members: CompanyMemberRecord[], +) { + const actorRole = await resolveActorHumanRole(req, access, companyId); + const userIds = [...new Set(members + .filter((member) => member.principalType === "user") + .map((member) => member.principalId))]; + const instanceAdminUserIds = userIds.length > 0 + ? new Set( + await db + .select({ userId: instanceUserRoles.userId }) + .from(instanceUserRoles) + .where(and(inArray(instanceUserRoles.userId, userIds), eq(instanceUserRoles.role, "instance_admin"))) + .then((rows) => rows.map((row) => row.userId)), + ) + : new Set(); + return Promise.all( + members.map(async (member) => { + const reason = await getProtectedMemberReason(req, access, companyId, member, { + actorRole, + instanceAdminUserIds, + operation: "archive", + }); + return { + ...member, + removal: { + canArchive: !reason, + reason, + }, + }; + }), + ); +} + +async function loadCompanyUserDirectory(db: Db, companyId: string) { + const members = await db + .select({ + principalId: companyMemberships.principalId, + status: companyMemberships.status, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + ), + ) + .orderBy(desc(companyMemberships.updatedAt)); + + const userIds = [...new Set(members.map((member) => member.principalId))]; + const userMap = await loadUsersById(db, userIds); + + return members.map((member) => ({ + principalId: member.principalId, + status: "active" as const, + user: userMap.get(member.principalId) ?? null, + })); +} + +function inviteStateWhereClause( + state: "active" | "accepted" | "expired" | "revoked" | undefined, +) { + const now = new Date(); + switch (state) { + case "active": + return and( + isNull(invites.revokedAt), + isNull(invites.acceptedAt), + gt(invites.expiresAt, now), + ); + case "accepted": + return isNotNull(invites.acceptedAt); + case "expired": + return and( + isNull(invites.revokedAt), + isNull(invites.acceptedAt), + lte(invites.expiresAt, now), + ); + case "revoked": + return isNotNull(invites.revokedAt); + default: + return undefined; + } +} + +async function loadCompanyInviteRecords( + db: Db, + companyId: string, + options: { + state?: "active" | "accepted" | "expired" | "revoked"; + limit: number; + offset: number; + }, +) { + const whereClause = inviteStateWhereClause(options.state); + const rows = await db + .select() + .from(invites) + .where(whereClause ? and(eq(invites.companyId, companyId), whereClause) : eq(invites.companyId, companyId)) + .orderBy(desc(invites.createdAt)) + .limit(options.limit + 1) + .offset(options.offset); + const hasMore = rows.length > options.limit; + const visibleRows = hasMore ? rows.slice(0, options.limit) : rows; + const userIds = [ + ...new Set( + visibleRows + .map((invite) => invite.invitedByUserId) + .filter((value): value is string => Boolean(value)), + ), + ]; + const [userMap, joinRows, companyName] = await Promise.all([ + loadUsersById(db, userIds), + visibleRows.length + ? db + .select({ id: joinRequests.id, inviteId: joinRequests.inviteId }) + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, companyId), + inArray( + joinRequests.inviteId, + visibleRows.map((invite) => invite.id), + ), + ), + ) + : Promise.resolve([]), + db + .select({ name: companies.name }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((companyRows) => companyRows[0]?.name ?? null), + ]); + const joinRequestIdByInviteId = new Map( + joinRows.map((row: { inviteId: string; id: string }) => [row.inviteId, row.id]), + ); + + return { + invites: visibleRows.map((invite) => ({ + ...invite, + companyName, + humanRole: extractInviteHumanRole(invite), + inviteMessage: extractInviteMessage(invite), + state: inviteState(invite), + invitedByUser: invite.invitedByUserId + ? userMap.get(invite.invitedByUserId) ?? null + : null, + relatedJoinRequestId: joinRequestIdByInviteId.get(invite.id) ?? null, + })), + nextOffset: hasMore ? options.offset + options.limit : null, + }; +} + +async function loadJoinRequestRecords(db: Db, companyId: string) { + const rows = collapseDuplicatePendingHumanJoinRequests( + await db + .select() + .from(joinRequests) + .where(eq(joinRequests.companyId, companyId)) + .orderBy(desc(joinRequests.createdAt)) + ); + const inviteIds = [...new Set(rows.map((row) => row.inviteId))]; + const inviteRows = inviteIds.length + ? await db + .select() + .from(invites) + .where(inArray(invites.id, inviteIds)) + : []; + const userIds = [ + ...new Set( + [ + ...rows.map((row) => row.requestingUserId), + ...rows.map((row) => row.approvedByUserId), + ...rows.map((row) => row.rejectedByUserId), + ...inviteRows.map((invite) => invite.invitedByUserId), + ].filter((value): value is string => Boolean(value)), + ), + ]; + const userMap = await loadUsersById(db, userIds); + const inviteMap = new Map(inviteRows.map((invite) => [invite.id, invite])); + + return rows.map((row) => { + const invite = inviteMap.get(row.inviteId) ?? null; + return { + ...toJoinRequestResponse(row), + requesterUser: row.requestingUserId + ? userMap.get(row.requestingUserId) ?? null + : null, + approvedByUser: row.approvedByUserId + ? userMap.get(row.approvedByUserId) ?? null + : null, + rejectedByUser: row.rejectedByUserId + ? userMap.get(row.rejectedByUserId) ?? null + : null, + invite: invite + ? { + id: invite.id, + inviteType: invite.inviteType, + allowedJoinTypes: invite.allowedJoinTypes, + humanRole: extractInviteHumanRole(invite), + inviteMessage: extractInviteMessage(invite), + createdAt: invite.createdAt, + expiresAt: invite.expiresAt, + revokedAt: invite.revokedAt, + acceptedAt: invite.acceptedAt, + invitedByUser: invite.invitedByUserId + ? userMap.get(invite.invitedByUserId) ?? null + : null, + } + : null, + }; + }); +} + +async function loadUserCompanyAccessResponse( + db: Db, + access: ReturnType, + userId: string, +) { + const [memberships, user, isInstanceAdmin] = await Promise.all([ + access.listUserCompanyAccess(userId), + db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .where(eq(authUsers.id, userId)) + .then((rows) => rows[0] ?? null), + access.isInstanceAdmin(userId), + ]); + const companyIds = [...new Set(memberships.map((membership) => membership.companyId))]; + const companyRows = companyIds.length + ? await db + .select({ + id: companies.id, + name: companies.name, + status: companies.status, + }) + .from(companies) + .where(inArray(companies.id, companyIds)) + : []; + const companyMap = new Map(companyRows.map((company) => [company.id, company])); + + return { + user: user + ? { + ...toUserProfile(user), + isInstanceAdmin, + } + : null, + companyAccess: memberships.map((membership) => { + const company = companyMap.get(membership.companyId) ?? null; + return { + ...membership, + principalType: "user" as const, + companyName: company?.name ?? null, + companyStatus: company?.status ?? null, + }; + }), + }; +} + function buildOnboardingDiscoveryDiagnostics(input: { apiBaseUrl: string; deploymentMode: DeploymentMode; @@ -1003,7 +1515,7 @@ function buildInviteOnboardingManifest( } ) { const baseUrl = requestBaseUrl(req); - const skillPath = "/api/skills/taskcore"; + const skillPath = `/api/invites/${token}/skills/taskcore`; const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath; const registrationEndpointPath = `/api/invites/${token}/accept`; const registrationEndpointUrl = baseUrl @@ -1068,7 +1580,7 @@ function buildInviteOnboardingManifest( diagnostics: discoveryDiagnostics, guidance: opts.deploymentMode === "authenticated" && - opts.deploymentExposure === "private" + opts.deploymentExposure === "private" ? "If OpenClaw runs on another machine, ensure the Taskcore hostname is reachable and allowed via `pnpm taskcore allowed-hostname `." : "Ensure OpenClaw can reach this Taskcore API base URL for invite, claim, and skill bootstrap calls." }, @@ -1193,8 +1705,9 @@ export function buildInviteOnboardingTextDocument( ' "$TOKEN")" ## Step 1: Submit agent join request - ${onboarding.registrationEndpoint.method} ${onboarding.registrationEndpoint.url - } + ${onboarding.registrationEndpoint.method} ${ + onboarding.registrationEndpoint.url + } IMPORTANT: You MUST include agentDefaultsPayload.headers.x-openclaw-token with your gateway token. Legacy x-openclaw-auth is also accepted, but x-openclaw-token is preferred. @@ -1231,7 +1744,8 @@ export function buildInviteOnboardingTextDocument( The board approves the join request in Taskcore before key claim is allowed. ## Step 3: Claim API key (one-time) - ${onboarding.claimEndpointTemplate.method + ${ + onboarding.claimEndpointTemplate.method } /api/join-requests/{requestId}/claim-api-key Body (JSON): @@ -1273,8 +1787,9 @@ export function buildInviteOnboardingTextDocument( ${onboarding.textInstructions.url} ## Connectivity guidance - ${onboarding.connectivity?.guidance ?? - "Ensure Taskcore is reachable from your OpenClaw runtime." + ${ + onboarding.connectivity?.guidance ?? + "Ensure Taskcore is reachable from your OpenClaw runtime." } `); @@ -1282,8 +1797,8 @@ export function buildInviteOnboardingTextDocument( onboarding.connectivity?.connectionCandidates ) ? onboarding.connectivity.connectionCandidates.filter( - (entry): entry is string => Boolean(entry) - ) + (entry): entry is string => Boolean(entry) + ) : []; if (connectionCandidates.length > 0) { @@ -1345,12 +1860,22 @@ function extractInviteMessage( function mergeInviteDefaults( defaultsPayload: Record | null | undefined, - agentMessage: string | null + agentMessage: string | null, + humanRole: "owner" | "admin" | "operator" | "viewer" | null = null, ): Record | null { const merged = defaultsPayload && typeof defaultsPayload === "object" ? { ...defaultsPayload } : {}; + if (humanRole) { + const existingHuman = + isPlainObject(merged.human) ? { ...(merged.human as Record) } : {}; + merged.human = { + ...existingHuman, + role: humanRole, + grants: grantsForHumanRole(humanRole), + }; + } if (agentMessage) { merged.agentMessage = agentMessage; } @@ -1370,10 +1895,44 @@ function inviteExpired(invite: typeof invites.$inferSelect) { return invite.expiresAt.getTime() <= Date.now(); } +function inviteState(invite: typeof invites.$inferSelect) { + if (invite.revokedAt) return "revoked" as const; + if (invite.acceptedAt) return "accepted" as const; + if (inviteExpired(invite)) return "expired" as const; + return "active" as const; +} + +function extractInviteHumanRole(invite: typeof invites.$inferSelect) { + if (invite.allowedJoinTypes === "agent") return null; + return resolveHumanInviteRole( + invite.defaultsPayload as Record | null | undefined, + ); +} + function isLocalImplicit(req: Request) { return req.actor.type === "board" && req.actor.source === "local_implicit"; } +function toUserProfile( + user: + | { + id: string; + email: string | null; + name: string | null; + image?: string | null; + } + | null + | undefined, +) { + if (!user) return null; + return { + id: user.id, + email: user.email ?? null, + name: user.name ?? null, + image: user.image ?? null, + }; +} + async function resolveActorEmail(db: Db, req: Request): Promise { if (isLocalImplicit(req)) return "local@taskcore.local"; const userId = req.actor.userId; @@ -1386,6 +1945,57 @@ async function resolveActorEmail(db: Db, req: Request): Promise { return user?.email ?? null; } +async function resolveAcceptedInviteJoinRequest( + db: Db, + req: Request, + invite: typeof invites.$inferSelect | null, +) { + if (!invite?.acceptedAt) return null; + + const directJoinRequest = await db + .select({ + requestType: joinRequests.requestType, + status: joinRequests.status, + requestingUserId: joinRequests.requestingUserId, + requestEmailSnapshot: joinRequests.requestEmailSnapshot, + }) + .from(joinRequests) + .where(eq(joinRequests.inviteId, invite.id)) + .then((rows) => rows[0] ?? null); + if (directJoinRequest) return directJoinRequest; + + if (!invite.companyId) return null; + + const actorRequestingUserId = isLocalImplicit(req) + ? "local-board" + : req.actor.userId ?? null; + const actorEmail = await resolveActorEmail(db, req); + if (!actorRequestingUserId && !actorEmail) return null; + + return findReusableHumanJoinRequest( + await db + .select({ + id: joinRequests.id, + requestType: joinRequests.requestType, + status: joinRequests.status, + requestingUserId: joinRequests.requestingUserId, + requestEmailSnapshot: joinRequests.requestEmailSnapshot, + }) + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, invite.companyId), + eq(joinRequests.requestType, "human"), + ), + ) + .orderBy(desc(joinRequests.createdAt)), + { + requestingUserId: actorRequestingUserId, + requestEmailSnapshot: actorEmail, + }, + ); +} + function grantsFromDefaults( defaultsPayload: Record | null | undefined, key: "human" | "agent" @@ -1412,8 +2022,8 @@ function grantsFromDefaults( permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number], scope: record.scope && - typeof record.scope === "object" && - !Array.isArray(record.scope) + typeof record.scope === "object" && + !Array.isArray(record.scope) ? (record.scope as Record) : null }); @@ -1497,44 +2107,288 @@ type InviteResolutionProbe = { message: string; }; +type InviteResolutionLookupResult = { + address: string; + family?: number; +}; + +type ResolvedInviteResolutionTarget = { + url: URL; + resolvedAddress: string; + resolvedAddresses: string[]; + hostHeader: string; + tlsServername?: string; +}; + +type InviteResolutionHeadResponse = { + httpStatus: number | null; +}; + +type InviteResolutionNetwork = { + lookup(hostname: string): Promise; + requestHead( + target: ResolvedInviteResolutionTarget, + timeoutMs: number + ): Promise; +}; + +function parseIpv4Address(address: string) { + const parts = address.split("."); + if (parts.length !== 4) return null; + const parsed = parts.map((part) => { + if (!/^\d+$/.test(part)) return NaN; + return Number(part); + }); + if (parsed.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return null; + } + return parsed as [number, number, number, number]; +} + +function isPrivateOrReservedIpv4(address: string) { + const octets = parseIpv4Address(address); + if (!octets) return true; + const [a, b, c] = octets; + if (a === 0) return true; + if (a === 10) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + if (a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 0 && c === 0) return true; + if (a === 192 && b === 168) return true; + if (a === 192 && b === 0 && c === 2) return true; + if (a === 192 && b === 88 && c === 99) return true; + if (a === 198 && (b === 18 || b === 19)) return true; + if (a === 198 && b === 51 && c === 100) return true; + if (a === 203 && b === 0 && c === 113) return true; + if (a >= 224) return true; + return false; +} + +function parseMappedIpv4Hex(address: string) { + const match = address.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (!match) return null; + const hi = Number.parseInt(match[1]!, 16); + const lo = Number.parseInt(match[2]!, 16); + if (!Number.isInteger(hi) || !Number.isInteger(lo)) return null; + return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`; +} + +function isPrivateOrReservedIpv6(address: string) { + const lower = address.toLowerCase(); + if (lower.startsWith("::ffff:")) { + const mappedIpv4 = lower.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/); + if (mappedIpv4?.[1]) return isPrivateOrReservedIpv4(mappedIpv4[1]); + const mappedIpv4Hex = parseMappedIpv4Hex(lower); + if (mappedIpv4Hex) return isPrivateOrReservedIpv4(mappedIpv4Hex); + return true; + } + if (lower === "::" || lower === "::1") return true; + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; + if (/^fe[89ab]/.test(lower)) return true; + if (lower.startsWith("ff")) return true; + if (lower === "100::" || lower.startsWith("100:")) return true; + if (lower.startsWith("2001:db8:") || lower === "2001:db8::") return true; + if (lower.startsWith("2001:2:") || lower === "2001:2::") return true; + if (lower.startsWith("2002:")) return true; + if (lower.startsWith("64:ff9b:")) return true; + return false; +} + +function isPublicIpAddress(address: string) { + const ipVersion = isIP(address); + if (ipVersion === 4) return !isPrivateOrReservedIpv4(address); + if (ipVersion === 6) return !isPrivateOrReservedIpv6(address); + return false; +} + +function hostnameForResolution(url: URL) { + return url.hostname.replace(/^\[|\]$/g, ""); +} + +async function defaultInviteResolutionLookup( + hostname: string +): Promise { + return dnsLookup(hostname, { all: true, verbatim: true }); +} + +async function defaultInviteResolutionHeadRequest( + target: ResolvedInviteResolutionTarget, + timeoutMs: number +): Promise { + return new Promise((resolve, reject) => { + const url = target.url; + const request = url.protocol === "https:" ? httpsRequest : httpRequest; + const options: HttpRequestOptions & { servername?: string } = { + protocol: url.protocol, + hostname: target.resolvedAddress, + port: url.port || undefined, + method: "HEAD", + path: `${url.pathname}${url.search}`, + headers: { + Host: target.hostHeader + } + }; + if (target.tlsServername) { + options.servername = target.tlsServername; + } + + let settled = false; + const req = request(options, (response: IncomingMessage) => { + settled = true; + response.resume(); + resolve({ httpStatus: response.statusCode ?? null }); + }); + req.setTimeout(timeoutMs, () => { + if (settled) return; + const error = new Error("Invite resolution probe timed out"); + error.name = "AbortError"; + req.destroy(error); + }); + req.on("error", (error) => { + if (settled) return; + settled = true; + reject(error); + }); + req.end(); + }); +} + +const defaultInviteResolutionNetwork: InviteResolutionNetwork = { + lookup: defaultInviteResolutionLookup, + requestHead: defaultInviteResolutionHeadRequest +}; + +let inviteResolutionNetwork = defaultInviteResolutionNetwork; + +export function setInviteResolutionNetworkForTest( + network: Partial | null +) { + inviteResolutionNetwork = network + ? { ...defaultInviteResolutionNetwork, ...network } + : defaultInviteResolutionNetwork; +} + +async function lookupInviteResolutionHostname(hostname: string) { + let timeout: ReturnType | null = null; + try { + return await Promise.race([ + inviteResolutionNetwork.lookup(hostname), + new Promise((_, reject) => { + timeout = setTimeout( + () => + reject( + badRequest( + `url hostname DNS lookup timed out after ${INVITE_RESOLUTION_DNS_TIMEOUT_MS}ms` + ) + ), + INVITE_RESOLUTION_DNS_TIMEOUT_MS + ); + }) + ]); + } catch (error) { + if (error instanceof Error && "status" in error) throw error; + throw badRequest("url hostname could not be resolved"); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +async function resolveInviteResolutionTarget( + url: URL +): Promise { + const hostname = hostnameForResolution(url); + if (parseIpv4Address(hostname)) { + if (!isPublicIpAddress(hostname)) { + throw badRequest( + "url resolves to a private, local, multicast, or reserved address" + ); + } + return { + url, + resolvedAddress: hostname, + resolvedAddresses: [hostname], + hostHeader: url.host, + tlsServername: undefined, + }; + } + const literalIpVersion = isIP(hostname); + if (literalIpVersion !== 0) { + if (!isPublicIpAddress(hostname)) { + throw badRequest( + "url resolves to a private, local, multicast, or reserved address" + ); + } + return { + url, + resolvedAddress: hostname, + resolvedAddresses: [hostname], + hostHeader: url.host, + tlsServername: undefined, + }; + } + const results = await lookupInviteResolutionHostname(hostname); + if (results.length === 0) { + throw badRequest("url hostname did not resolve to any addresses"); + } + + const resolvedAddresses = results.map((result) => result.address); + const unsafeAddress = resolvedAddresses.find((address) => !isPublicIpAddress(address)); + if (unsafeAddress) { + throw badRequest( + "url resolves to a private, local, multicast, or reserved address" + ); + } + + return { + url, + resolvedAddress: resolvedAddresses[0]!, + resolvedAddresses, + hostHeader: url.host, + tlsServername: url.protocol === "https:" && isIP(hostname) === 0 + ? hostname + : undefined + }; +} + async function probeInviteResolutionTarget( - url: URL, + target: ResolvedInviteResolutionTarget, timeoutMs: number ): Promise { const startedAt = Date.now(); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch(url, { - method: "HEAD", - redirect: "manual", - signal: controller.signal - }); + const response = await inviteResolutionNetwork.requestHead(target, timeoutMs); const durationMs = Date.now() - startedAt; if ( - response.ok || - response.status === 401 || - response.status === 403 || - response.status === 404 || - response.status === 405 || - response.status === 422 || - response.status === 500 || - response.status === 501 + response.httpStatus !== null && + ( + (response.httpStatus >= 200 && response.httpStatus < 300) || + response.httpStatus === 401 || + response.httpStatus === 403 || + response.httpStatus === 404 || + response.httpStatus === 405 || + response.httpStatus === 422 || + response.httpStatus === 500 || + response.httpStatus === 501 + ) ) { return { status: "reachable", method: "HEAD", durationMs, - httpStatus: response.status, - message: `Webhook endpoint responded to HEAD with HTTP ${response.status}.` + httpStatus: response.httpStatus, + message: `Webhook endpoint responded to HEAD with HTTP ${response.httpStatus}.` }; } return { status: "unreachable", method: "HEAD", durationMs, - httpStatus: response.status, - message: `Webhook endpoint probe returned HTTP ${response.status}.` + httpStatus: response.httpStatus, + message: response.httpStatus === null + ? "Webhook endpoint probe did not return an HTTP status." + : `Webhook endpoint probe returned HTTP ${response.httpStatus}.` }; } catch (error) { const durationMs = Date.now() - startedAt; @@ -1557,8 +2411,6 @@ async function probeInviteResolutionTarget( ? error.message : "Webhook endpoint probe failed." }; - } finally { - clearTimeout(timeout); } } @@ -1844,6 +2696,7 @@ export function accessRoutes( req: Request; companyId: string; allowedJoinTypes: "human" | "agent" | "both"; + humanRole?: "owner" | "admin" | "operator" | "viewer" | null; defaultsPayload?: Record | null; agentMessage?: string | null; }) { @@ -1851,13 +2704,18 @@ export function accessRoutes( typeof input.agentMessage === "string" ? input.agentMessage.trim() || null : null; + const effectiveHumanRole = + input.allowedJoinTypes === "agent" + ? null + : input.humanRole ?? "operator"; const insertValues = { companyId: input.companyId, inviteType: "company_join" as const, allowedJoinTypes: input.allowedJoinTypes, defaultsPayload: mergeInviteDefaults( input.defaultsPayload ?? null, - normalizedAgentMessage + normalizedAgentMessage, + effectiveHumanRole, ), expiresAt: companyInviteExpiresAt(), invitedByUserId: input.req.actor.userId ?? null @@ -1892,21 +2750,98 @@ export function accessRoutes( return { token, created, normalizedAgentMessage }; } - async function getInviteCompanyName(companyId: string | null) { - if (!companyId) return null; + async function getInviteCompanyBranding( + companyId: string | null, + inviteToken: string | null = null, + ): Promise<{ + name: string | null; + brandColor: string | null; + logoAssetId: string | null; + logoUrl: string | null; + }> { + if (!companyId) { + return { name: null, brandColor: null, logoAssetId: null, logoUrl: null }; + } const company = await db - .select({ name: companies.name }) + .select({ + name: companies.name, + brandColor: companies.brandColor, + logoAssetId: companyLogos.assetId, + }) .from(companies) + .leftJoin(companyLogos, eq(companyLogos.companyId, companies.id)) .where(eq(companies.id, companyId)) .then((rows) => rows[0] ?? null); - return company?.name ?? null; + let logoUrl: string | null = null; + if (inviteToken && company?.logoAssetId) { + const logoAsset = await getInviteLogoAsset(companyId); + if (logoAsset?.companyId) { + try { + const storage = getStorageService(); + const logoObject = await storage.headObject(logoAsset.companyId, logoAsset.objectKey); + if (logoObject.exists) { + logoUrl = `/api/invites/${inviteToken}/logo`; + } + } catch (err) { + logger.warn( + { + err, + companyId, + logoAssetId: company.logoAssetId, + }, + "invite logo storage check failed", + ); + } + } + } + + return { + name: company?.name ?? null, + brandColor: company?.brandColor ?? null, + logoAssetId: company?.logoAssetId ?? null, + logoUrl, + }; } - router.get("/skills/available", (_req, res) => { + async function getInviteLogoAsset(companyId: string | null): Promise<{ + companyId: string | null; + objectKey: string; + contentType: string | null; + byteSize: number | null; + originalFilename: string | null; + } | null> { + if (!companyId) return null; + const logoAsset = await db + .select({ + companyId: companies.id, + objectKey: assets.objectKey, + contentType: assets.contentType, + byteSize: assets.byteSize, + originalFilename: assets.originalFilename, + }) + .from(companies) + .leftJoin(companyLogos, eq(companyLogos.companyId, companies.id)) + .leftJoin(assets, eq(assets.id, companyLogos.assetId)) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + + if (!logoAsset?.objectKey) return null; + return { + companyId: logoAsset.companyId, + objectKey: logoAsset.objectKey, + contentType: logoAsset.contentType, + byteSize: logoAsset.byteSize, + originalFilename: logoAsset.originalFilename, + }; + } + + router.get("/skills/available", (req, res) => { + assertAuthenticated(req); res.json({ skills: listAvailableSkills() }); }); - router.get("/skills/index", (_req, res) => { + router.get("/skills/index", (req, res) => { + assertAuthenticated(req); res.json({ skills: [ { name: "taskcore", path: "/api/skills/taskcore" }, @@ -1923,6 +2858,7 @@ export function accessRoutes( }); router.get("/skills/:skillName", (req, res) => { + assertAuthenticated(req); const skillName = (req.params.skillName as string).trim().toLowerCase(); const markdown = readSkillMarkdown(skillName); if (!markdown) throw notFound("Skill not found"); @@ -1940,6 +2876,7 @@ export function accessRoutes( req, companyId, allowedJoinTypes: req.body.allowedJoinTypes, + humanRole: req.body.humanRole ?? null, defaultsPayload: req.body.defaultsPayload ?? null, agentMessage: req.body.agentMessage ?? null }); @@ -1958,22 +2895,24 @@ export function accessRoutes( inviteType: created.inviteType, allowedJoinTypes: created.allowedJoinTypes, expiresAt: created.expiresAt.toISOString(), + humanRole: extractInviteHumanRole(created), hasAgentMessage: Boolean(normalizedAgentMessage) } }); - const companyName = await getInviteCompanyName(created.companyId); + const companyBranding = await getInviteCompanyBranding(created.companyId, token); const inviteSummary = toInviteSummaryResponse( req, token, created, - companyName + companyBranding ); res.status(201).json({ ...created, token, - inviteUrl: `/invite/${token}`, - companyName, + invitePath: inviteSummary.invitePath, + inviteUrl: inviteSummary.inviteUrl, + companyName: companyBranding.name, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, inviteMessage: inviteSummary.inviteMessage @@ -1992,6 +2931,7 @@ export function accessRoutes( req, companyId, allowedJoinTypes: "agent", + humanRole: null, defaultsPayload: null, agentMessage: req.body.agentMessage ?? null }); @@ -2014,18 +2954,19 @@ export function accessRoutes( } }); - const companyName = await getInviteCompanyName(created.companyId); + const companyBranding = await getInviteCompanyBranding(created.companyId, token); const inviteSummary = toInviteSummaryResponse( req, token, created, - companyName + companyBranding ); res.status(201).json({ ...created, token, - inviteUrl: `/invite/${token}`, - companyName, + invitePath: inviteSummary.invitePath, + inviteUrl: inviteSummary.inviteUrl, + companyName: companyBranding.name, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, inviteMessage: inviteSummary.inviteMessage @@ -2041,17 +2982,82 @@ export function accessRoutes( .from(invites) .where(eq(invites.tokenHash, hashToken(token))) .then((rows) => rows[0] ?? null); + const inviteJoinRequest = await resolveAcceptedInviteJoinRequest(db, req, invite); + if ( + !invite || + invite.revokedAt || + inviteExpired(invite) || + (invite.acceptedAt && !inviteJoinRequest) + ) { + throw notFound("Invite not found"); + } + + const companyBranding = await getInviteCompanyBranding(invite.companyId, token); + const inviterName = invite.invitedByUserId + ? await loadUsersById(db, [invite.invitedByUserId]).then( + (m) => m.get(invite.invitedByUserId!)?.name ?? null + ) + : null; + res.json({ + ...toInviteSummaryResponse(req, token, invite, companyBranding), + invitedByUserName: inviterName, + joinRequestStatus: inviteJoinRequest?.status ?? null, + joinRequestType: inviteJoinRequest?.requestType ?? null, + }); + }); + + router.get("/invites/:token/logo", async (req, res, next) => { + const token = (req.params.token as string).trim(); + if (!token) throw notFound("Invite not found"); + const invite = await db + .select() + .from(invites) + .where(eq(invites.tokenHash, hashToken(token))) + .then((rows) => rows[0] ?? null); + const inviteJoinRequest = await resolveAcceptedInviteJoinRequest(db, req, invite); if ( !invite || invite.revokedAt || - invite.acceptedAt || - inviteExpired(invite) + inviteExpired(invite) || + (invite.acceptedAt && !inviteJoinRequest) ) { throw notFound("Invite not found"); } - const companyName = await getInviteCompanyName(invite.companyId); - res.json(toInviteSummaryResponse(req, token, invite, companyName)); + const logoAsset = await getInviteLogoAsset(invite.companyId); + if (!logoAsset || !logoAsset.companyId) { + throw notFound("Invite logo not found"); + } + const companyId = logoAsset.companyId; + + const storage = getStorageService(); + const logoHead = await storage.headObject(companyId, logoAsset.objectKey); + if (!logoHead.exists) { + throw notFound("Invite logo not found"); + } + const object = await storage.getObject(companyId, logoAsset.objectKey); + const responseContentType = + logoAsset.contentType || + logoHead.contentType || + object.contentType || + "application/octet-stream"; + res.setHeader("Content-Type", responseContentType); + res.setHeader( + "Content-Length", + String(logoAsset.byteSize || logoHead.contentLength || object.contentLength || 0), + ); + res.setHeader("Cache-Control", "private, max-age=60"); + res.setHeader("X-Content-Type-Options", "nosniff"); + if (responseContentType === "image/svg+xml") { + res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); + } + const filename = logoAsset.originalFilename ?? "company-logo"; + res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`); + + object.stream.on("error", (err) => { + next(err); + }); + object.stream.pipe(res); }); router.get("/invites/:token/onboarding", async (req, res) => { @@ -2066,10 +3072,10 @@ export function accessRoutes( throw notFound("Invite not found"); } - const companyName = await getInviteCompanyName(invite.companyId); + const companyBranding = await getInviteCompanyBranding(invite.companyId); res.json(buildInviteOnboardingManifest(req, token, invite, { ...opts, - companyName + companyName: companyBranding.name })); }); @@ -2085,17 +3091,58 @@ export function accessRoutes( throw notFound("Invite not found"); } - const companyName = await getInviteCompanyName(invite.companyId); + const companyBranding = await getInviteCompanyBranding(invite.companyId); res .type("text/plain; charset=utf-8") .send( buildInviteOnboardingTextDocument(req, token, invite, { ...opts, - companyName + companyName: companyBranding.name }) ); }); + router.get("/invites/:token/skills/index", async (req, res) => { + const token = (req.params.token as string).trim(); + if (!token) throw notFound("Invite not found"); + const invite = await db + .select() + .from(invites) + .where(eq(invites.tokenHash, hashToken(token))) + .then((rows) => rows[0] ?? null); + if (!invite || invite.revokedAt || inviteExpired(invite)) { + throw notFound("Invite not found"); + } + + res.json({ + skills: [ + { + name: "taskcore", + path: `/api/invites/${token}/skills/taskcore`, + }, + ], + }); + }); + + router.get("/invites/:token/skills/:skillName", async (req, res) => { + const token = (req.params.token as string).trim(); + if (!token) throw notFound("Invite not found"); + const invite = await db + .select() + .from(invites) + .where(eq(invites.tokenHash, hashToken(token))) + .then((rows) => rows[0] ?? null); + if (!invite || invite.revokedAt || inviteExpired(invite)) { + throw notFound("Invite not found"); + } + + const skillName = (req.params.skillName as string).trim().toLowerCase(); + if (skillName !== "taskcore") throw notFound("Skill not found"); + const markdown = readSkillMarkdown(skillName); + if (!markdown) throw notFound("Skill not found"); + res.type("text/markdown").send(markdown); + }); + router.get("/invites/:token/test-resolution", async (req, res) => { const token = (req.params.token as string).trim(); if (!token) throw notFound("Invite not found"); @@ -2128,7 +3175,8 @@ export function accessRoutes( const timeoutMs = Number.isFinite(parsedTimeoutMs) ? Math.max(1000, Math.min(15000, Math.floor(parsedTimeoutMs))) : 5000; - const probe = await probeInviteResolutionTarget(target, timeoutMs); + const resolvedTarget = await resolveInviteResolutionTarget(target); + const probe = await probeInviteResolutionTarget(resolvedTarget, timeoutMs); res.json({ inviteId: invite.id, testResolutionPath: `/api/invites/${token}/test-resolution`, @@ -2156,10 +3204,10 @@ export function accessRoutes( const inviteAlreadyAccepted = Boolean(invite.acceptedAt); const existingJoinRequestForInvite = inviteAlreadyAccepted ? await db - .select() - .from(joinRequests) - .where(eq(joinRequests.inviteId, invite.id)) - .then((rows) => rows[0] ?? null) + .select() + .from(joinRequests) + .where(eq(joinRequests.inviteId, invite.id)) + .then((rows) => rows[0] ?? null) : null; if (invite.inviteType === "bootstrap_ceo") { @@ -2217,6 +3265,12 @@ export function accessRoutes( ) { throw unauthorized("Authenticated user is required"); } + if ( + requestType === "human" && + actorHasActiveUserMembership(req, companyId) + ) { + throw conflict("You already belong to this company"); + } if (requestType === "agent" && !req.body.agentName) { if ( !inviteAlreadyAccepted || @@ -2246,37 +3300,37 @@ export function accessRoutes( const replayMergedDefaults = inviteAlreadyAccepted ? mergeJoinDefaultsPayloadForReplay( - existingJoinRequestForInvite?.agentDefaultsPayload ?? null, - req.body.agentDefaultsPayload ?? null - ) + existingJoinRequestForInvite?.agentDefaultsPayload ?? null, + req.body.agentDefaultsPayload ?? null + ) : req.body.agentDefaultsPayload ?? null; const gatewayDefaultsPayload = requestType === "agent" ? buildJoinDefaultsPayloadForAccept({ - adapterType, - defaultsPayload: replayMergedDefaults, - taskcoreApiUrl: req.body.taskcoreApiUrl ?? null, - inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null, - inboundOpenClawTokenHeader: req.header("x-openclaw-token") ?? null - }) + adapterType, + defaultsPayload: replayMergedDefaults, + taskcoreApiUrl: req.body.taskcoreApiUrl ?? null, + inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null, + inboundOpenClawTokenHeader: req.header("x-openclaw-token") ?? null + }) : null; const joinDefaults = requestType === "agent" ? normalizeAgentDefaultsForJoin({ - adapterType, - defaultsPayload: gatewayDefaultsPayload, - deploymentMode: opts.deploymentMode, - deploymentExposure: opts.deploymentExposure, - bindHost: opts.bindHost, - allowedHostnames: opts.allowedHostnames - }) + adapterType, + defaultsPayload: gatewayDefaultsPayload, + deploymentMode: opts.deploymentMode, + deploymentExposure: opts.deploymentExposure, + bindHost: opts.bindHost, + allowedHostnames: opts.allowedHostnames + }) : { - normalized: null as Record | null, - diagnostics: [] as JoinDiagnostic[], - fatalErrors: [] as string[] - }; + normalized: null as Record | null, + diagnostics: [] as JoinDiagnostic[], + fatalErrors: [] as string[] + }; if (requestType === "agent" && joinDefaults.fatalErrors.length > 0) { throw badRequest(joinDefaults.fatalErrors.join("; ")); @@ -2309,72 +3363,106 @@ export function accessRoutes( const actorEmail = requestType === "human" ? await resolveActorEmail(db, req) : null; + const existingHumanJoinRequest = + requestType === "human" + ? findReusableHumanJoinRequest( + await db + .select() + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, companyId), + eq(joinRequests.requestType, "human") + ) + ) + .orderBy(desc(joinRequests.createdAt)), + { + requestingUserId: req.actor.userId ?? "local-board", + requestEmailSnapshot: actorEmail + } + ) + : null; const created = !inviteAlreadyAccepted - ? await db.transaction(async (tx) => { - await tx - .update(invites) - .set({ acceptedAt: new Date(), updatedAt: new Date() }) - .where( - and( - eq(invites.id, invite.id), - isNull(invites.acceptedAt), - isNull(invites.revokedAt) - ) - ); - - const row = await tx - .insert(joinRequests) - .values({ - inviteId: invite.id, - companyId, - requestType, - status: "pending_approval", + ? existingHumanJoinRequest + ? await db.transaction(async (tx) => { + await tx + .update(invites) + .set({ acceptedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(invites.id, invite.id), + isNull(invites.acceptedAt), + isNull(invites.revokedAt) + ) + ); + return existingHumanJoinRequest; + }) + : await db.transaction(async (tx) => { + await tx + .update(invites) + .set({ acceptedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(invites.id, invite.id), + isNull(invites.acceptedAt), + isNull(invites.revokedAt) + ) + ); + + const row = await tx + .insert(joinRequests) + .values({ + inviteId: invite.id, + companyId, + requestType, + status: "pending_approval", + requestIp: requestIp(req), + requestingUserId: + requestType === "human" + ? req.actor.userId ?? "local-board" + : null, + requestEmailSnapshot: + requestType === "human" ? actorEmail : null, + agentName: + requestType === "agent" ? req.body.agentName : null, + adapterType: requestType === "agent" ? adapterType : null, + capabilities: + requestType === "agent" + ? req.body.capabilities ?? null + : null, + agentDefaultsPayload: + requestType === "agent" ? joinDefaults.normalized : null, + claimSecretHash, + claimSecretExpiresAt + }) + .returning() + .then((rows) => rows[0]); + return row; + }) + : await db + .update(joinRequests) + .set({ requestIp: requestIp(req), - requestingUserId: - requestType === "human" - ? req.actor.userId ?? "local-board" + agentName: + requestType === "agent" + ? req.body.agentName ?? + existingJoinRequestForInvite?.agentName ?? + null : null, - requestEmailSnapshot: - requestType === "human" ? actorEmail : null, - agentName: requestType === "agent" ? req.body.agentName : null, - adapterType: requestType === "agent" ? adapterType : null, capabilities: requestType === "agent" - ? req.body.capabilities ?? null + ? req.body.capabilities ?? + existingJoinRequestForInvite?.capabilities ?? + null : null, + adapterType: requestType === "agent" ? adapterType : null, agentDefaultsPayload: requestType === "agent" ? joinDefaults.normalized : null, - claimSecretHash, - claimSecretExpiresAt + updatedAt: new Date() }) + .where(eq(joinRequests.id, replayJoinRequestId as string)) .returning() .then((rows) => rows[0]); - return row; - }) - : await db - .update(joinRequests) - .set({ - requestIp: requestIp(req), - agentName: - requestType === "agent" - ? req.body.agentName ?? - existingJoinRequestForInvite?.agentName ?? - null - : null, - capabilities: - requestType === "agent" - ? req.body.capabilities ?? - existingJoinRequestForInvite?.capabilities ?? - null - : null, - adapterType: requestType === "agent" ? adapterType : null, - agentDefaultsPayload: - requestType === "agent" ? joinDefaults.normalized : null, - updatedAt: new Date() - }) - .where(eq(joinRequests.id, replayJoinRequestId as string)) - .returning() - .then((rows) => rows[0]); if (!created) { throw conflict("Join request not found"); @@ -2488,7 +3576,7 @@ export function accessRoutes( req.actor.type === "agent" ? req.actor.agentId ?? "invite-agent" : req.actor.userId ?? - (requestType === "agent" ? "invite-anon" : "board"), + (requestType === "agent" ? "invite-anon" : "board"), action: inviteAlreadyAccepted ? "join.request_replayed" : "join.requested", @@ -2496,21 +3584,23 @@ export function accessRoutes( entityId: created.id, details: { requestType, - requestIp: created.requestIp, - inviteReplay: inviteAlreadyAccepted + requestIp: requestIp(req), + inviteReplay: inviteAlreadyAccepted, + reusedExistingJoinRequest: + Boolean(existingHumanJoinRequest) && !inviteAlreadyAccepted } }); const response = toJoinRequestResponse(created); if (claimSecret) { - const companyName = await getInviteCompanyName(invite.companyId); + const companyBranding = await getInviteCompanyBranding(invite.companyId); const onboardingManifest = buildInviteOnboardingManifest( req, token, invite, { ...opts, - companyName + companyName: companyBranding.name } ); res.status(202).json({ @@ -2572,22 +3662,26 @@ export function accessRoutes( res.json(revoked); }); + router.get("/companies/:companyId/invites", async (req, res) => { + const companyId = req.params.companyId as string; + await assertCompanyPermission(req, companyId, "users:invite"); + const query = listCompanyInvitesQuerySchema.parse(req.query); + const invitesForCompany = await loadCompanyInviteRecords(db, companyId, query); + res.json(invitesForCompany); + }); + router.get("/companies/:companyId/join-requests", async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "joins:approve"); const query = listJoinRequestsQuerySchema.parse(req.query); - const all = await db - .select() - .from(joinRequests) - .where(eq(joinRequests.companyId, companyId)) - .orderBy(desc(joinRequests.createdAt)); + const all = await loadJoinRequestRecords(db, companyId); const filtered = all.filter((row) => { if (query.status && row.status !== query.status) return false; if (query.requestType && row.requestType !== query.requestType) return false; return true; }); - res.json(filtered.map(toJoinRequestResponse)); + res.json(filtered); }); router.post( @@ -2622,16 +3716,19 @@ export function accessRoutes( if (existing.requestType === "human") { if (!existing.requestingUserId) throw conflict("Join request missing user identity"); + const membershipRole = resolveHumanInviteRole( + invite.defaultsPayload as Record | null, + ); await access.ensureMembership( companyId, "user", existing.requestingUserId, - "member", + membershipRole, "active" ); - const grants = grantsFromDefaults( + const grants = humanJoinGrantsFromDefaults( invite.defaultsPayload as Record | null, - "human" + membershipRole ); await access.setPrincipalGrants( companyId, @@ -2668,7 +3765,7 @@ export function accessRoutes( adapterType: existing.adapterType ?? "process", adapterConfig: existing.agentDefaultsPayload && - typeof existing.agentDefaultsPayload === "object" + typeof existing.agentDefaultsPayload === "object" ? (existing.agentDefaultsPayload as Record) : {}, runtimeConfig: {}, @@ -2729,7 +3826,7 @@ export function accessRoutes( source: "join_request", sourceId: requestId, approvedAt: new Date() - }).catch(() => { }); + }).catch(() => {}); } res.json(toJoinRequestResponse(approved)); @@ -2868,10 +3965,288 @@ export function accessRoutes( router.get("/companies/:companyId/members", async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "users:manage_permissions"); - const members = await access.listMembers(companyId); - res.json(members); + const [members, currentAccess] = await Promise.all([ + loadCompanyMemberRecords(db, companyId), + loadCompanyAccessSummary(req, access, companyId), + ]); + res.json({ + members: await addCompanyMemberRemovalAccess(req, db, access, companyId, members), + access: currentAccess, + }); + }); + + router.get("/companies/:companyId/user-directory", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const users = await loadCompanyUserDirectory(db, companyId); + res.json({ users }); }); + router.patch( + "/companies/:companyId/members/:memberId", + validate(updateCompanyMemberSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const memberId = req.params.memberId as string; + await assertCompanyPermission(req, companyId, "users:manage_permissions"); + const memberToUpdate = await access.getMemberById(companyId, memberId); + if (!memberToUpdate) throw notFound("Member not found"); + await assertCanManageCompanyMember(req, access, companyId, memberToUpdate); + + const updated = await db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.id, memberId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + + const nextMembershipRole = + req.body.membershipRole !== undefined + ? req.body.membershipRole + : existing.membershipRole; + const nextStatus = req.body.status ?? existing.status; + + if ( + existing.principalType === "user" && + existing.status === "active" && + existing.membershipRole === "owner" && + (nextStatus !== "active" || nextMembershipRole !== "owner") + ) { + const activeOwnerCount = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + return tx + .update(companyMemberships) + .set({ + membershipRole: nextMembershipRole, + status: nextStatus, + updatedAt: new Date(), + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + }); + if (!updated) throw notFound("Member not found"); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "company_member.updated", + entityType: "company_membership", + entityId: memberId, + details: { + membershipRole: updated.membershipRole, + status: updated.status, + }, + }); + + const member = (await loadCompanyMemberRecords(db, companyId)).find( + (entry) => entry.id === memberId, + ); + if (!member) throw notFound("Member not found"); + res.json(member); + } + ); + + router.patch( + "/companies/:companyId/members/:memberId/role-and-grants", + validate(updateCompanyMemberWithPermissionsSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const memberId = req.params.memberId as string; + await assertCompanyPermission(req, companyId, "users:manage_permissions"); + const memberToUpdate = await access.getMemberById(companyId, memberId); + if (!memberToUpdate) throw notFound("Member not found"); + await assertCanManageCompanyMember(req, access, companyId, memberToUpdate); + + const updated = await db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.id, memberId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + + const nextMembershipRole = + req.body.membershipRole !== undefined + ? req.body.membershipRole + : existing.membershipRole; + const nextStatus = req.body.status ?? existing.status; + + if ( + existing.principalType === "user" && + existing.status === "active" && + existing.membershipRole === "owner" && + (nextStatus !== "active" || nextMembershipRole !== "owner") + ) { + const activeOwnerCount = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + const now = new Date(); + const updatedMember = await tx + .update(companyMemberships) + .set({ + membershipRole: nextMembershipRole, + status: nextStatus, + updatedAt: now, + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, existing.principalType), + eq(principalPermissionGrants.principalId, existing.principalId), + ), + ); + + const grants = (req.body.grants ?? []) as MemberGrantPayload[]; + if (grants.length > 0) { + await tx.insert(principalPermissionGrants).values( + grants.map((grant) => ({ + companyId, + principalType: existing.principalType, + principalId: existing.principalId, + permissionKey: grant.permissionKey, + scope: grant.scope ?? null, + grantedByUserId: req.actor.userId ?? null, + createdAt: now, + updatedAt: now, + })), + ); + } + + return updatedMember; + }); + if (!updated) throw notFound("Member not found"); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "company_member.access_updated", + entityType: "company_membership", + entityId: memberId, + details: { + membershipRole: updated.membershipRole, + status: updated.status, + grantCount: req.body.grants?.length ?? 0, + }, + }); + + const member = (await loadCompanyMemberRecords(db, companyId)).find( + (entry) => entry.id === memberId, + ); + if (!member) throw notFound("Member not found"); + res.json(member); + } + ); + + router.post( + "/companies/:companyId/members/:memberId/archive", + validate(archiveCompanyMemberSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const memberId = req.params.memberId as string; + await assertCompanyPermission(req, companyId, "users:manage_permissions"); + const memberToArchive = await access.getMemberById(companyId, memberId); + if (!memberToArchive) throw notFound("Member not found"); + await assertCanManageCompanyMember(req, access, companyId, memberToArchive, "archive"); + + const result = await access.archiveMember(companyId, memberId, { + reassignment: req.body.reassignment ?? null, + }); + if (!result) throw notFound("Member not found"); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "company_member.archived", + entityType: "company_membership", + entityId: memberId, + details: { + principalId: result.member.principalId, + reassignedIssueCount: result.reassignedIssueCount, + reassignment: req.body.reassignment ?? null, + }, + }); + + const member = (await loadCompanyMemberRecords(db, companyId, { includeArchived: true })).find( + (entry) => entry.id === memberId, + ); + if (!member) throw notFound("Member not found"); + res.json({ + member, + reassignedIssueCount: result.reassignedIssueCount, + }); + } + ); + router.patch( "/companies/:companyId/members/:memberId/permissions", validate(updateMemberPermissionsSchema), @@ -2879,6 +4254,9 @@ export function accessRoutes( const companyId = req.params.companyId as string; const memberId = req.params.memberId as string; await assertCompanyPermission(req, companyId, "users:manage_permissions"); + const memberToUpdate = await access.getMemberById(companyId, memberId); + if (!memberToUpdate) throw notFound("Member not found"); + await assertCanManageCompanyMember(req, access, companyId, memberToUpdate); const updated = await access.setMemberPermissions( companyId, memberId, @@ -2886,7 +4264,22 @@ export function accessRoutes( req.actor.userId ?? null ); if (!updated) throw notFound("Member not found"); - res.json(updated); + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "company_member.permissions_updated", + entityType: "company_membership", + entityId: memberId, + details: { + grantCount: req.body.grants?.length ?? 0, + }, + }); + const member = (await loadCompanyMemberRecords(db, companyId)).find( + (entry) => entry.id === memberId, + ); + if (!member) throw notFound("Member not found"); + res.json(member); } ); @@ -2900,6 +4293,66 @@ export function accessRoutes( } ); + router.get("/admin/users", async (req, res) => { + await assertInstanceAdmin(req); + const query = searchAdminUsersQuerySchema.parse(req.query); + const needle = query.query.trim().toLowerCase(); + const users = await db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .orderBy(desc(authUsers.updatedAt)); + const filteredUsers = needle + ? users.filter((user) => + [user.name, user.email] + .filter((value): value is string => Boolean(value)) + .some((value) => value.toLowerCase().includes(needle)), + ) + : users; + const userIds = filteredUsers.slice(0, 50).map((user) => user.id); + const memberships = userIds.length + ? await db + .select({ + principalId: companyMemberships.principalId, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + inArray(companyMemberships.principalId, userIds), + ), + ) + : []; + const membershipCountByUserId = new Map(); + for (const membership of memberships) { + membershipCountByUserId.set( + membership.principalId, + (membershipCountByUserId.get(membership.principalId) ?? 0) + 1, + ); + } + const adminIds = new Set( + await Promise.all( + userIds.map(async (userId) => + (await access.isInstanceAdmin(userId)) ? userId : null, + ), + ).then((values) => values.filter((value): value is string => Boolean(value))), + ); + + res.json( + filteredUsers.slice(0, 50).map((user) => ({ + ...toUserProfile(user), + isInstanceAdmin: adminIds.has(user.id), + activeCompanyMembershipCount: + membershipCountByUserId.get(user.id) ?? 0, + })), + ); + }); + router.post( "/admin/users/:userId/demote-instance-admin", async (req, res) => { @@ -2914,8 +4367,7 @@ export function accessRoutes( router.get("/admin/users/:userId/company-access", async (req, res) => { await assertInstanceAdmin(req); const userId = req.params.userId as string; - const memberships = await access.listUserCompanyAccess(userId); - res.json(memberships); + res.json(await loadUserCompanyAccessResponse(db, access, userId)); }); router.put( @@ -2924,11 +4376,12 @@ export function accessRoutes( async (req, res) => { await assertInstanceAdmin(req); const userId = req.params.userId as string; - const memberships = await access.setUserCompanyAccess( + await access.setUserCompanyAccess( userId, - req.body.companyIds ?? [] + req.body.companyIds ?? [], + { actorUserId: req.actor.userId ?? null }, ); - res.json(memberships); + res.json(await loadUserCompanyAccessResponse(db, access, userId)); } ); diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 0f45c6b..ac8071c 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -2,13 +2,13 @@ import { Router } from "express"; import { z } from "zod"; import type { Db } from "@taskcore/db"; import { validate } from "../middleware/validate.js"; -import { activityService } from "../services/activity.js"; -import { assertBoard, assertCompanyAccess } from "./authz.js"; +import { activityService, normalizeActivityLimit } from "../services/activity.js"; +import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js"; import { heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; const createActivitySchema = z.object({ - actorType: z.enum(["agent", "user", "system"]).optional().default("system"), + actorType: z.enum(["agent", "user", "system", "plugin"]).optional().default("system"), actorId: z.string().min(1), action: z.string().min(1), entityType: z.string().min(1), @@ -39,6 +39,7 @@ export function activityRoutes(db: Db) { agentId: req.query.agentId as string | undefined, entityType: req.query.entityType as string | undefined, entityId: req.query.entityId as string | undefined, + limit: normalizeActivityLimit(Number(req.query.limit)), }; const result = await svc.list(filters); res.json(result); @@ -81,6 +82,7 @@ export function activityRoutes(db: Db) { }); router.get("/heartbeat-runs/:runId/issues", async (req, res) => { + assertAuthenticated(req); const runId = req.params.runId as string; const run = await heartbeat.getRun(runId); if (!run) { diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 354cb88..a3698a4 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -6,7 +6,9 @@ * - Installing external adapters from npm packages or local paths * - Unregistering external adapters * - * All routes require board-level authentication (assertBoard middleware). + * Read-only routes require board org access. Mutating adapter management + * routes require instance-admin access because they can install, reload, or + * toggle server-side adapter code for the whole Taskcore instance. * * @module server/routes/adapters */ @@ -41,7 +43,7 @@ import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js"; import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js"; import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js"; import { logger } from "../middleware/logger.js"; -import { assertBoard } from "./authz.js"; +import { assertBoardOrgAccess, assertInstanceAdmin } from "./authz.js"; import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js"; const execFileAsync = promisify(execFile); @@ -59,6 +61,13 @@ interface AdapterInstallRequest { version?: string; } +interface AdapterCapabilities { + supportsInstructionsBundle: boolean; + supportsSkills: boolean; + supportsLocalAgentJwt: boolean; + requiresMaterializedRuntimeSkills: boolean; +} + interface AdapterInfo { type: string; label: string; @@ -66,6 +75,7 @@ interface AdapterInfo { modelsCount: number; loaded: boolean; disabled: boolean; + capabilities: AdapterCapabilities; /** True when an external plugin has replaced a built-in adapter of the same type. */ overriddenBuiltin?: boolean; /** True when the external override for a builtin type is currently paused. */ @@ -103,6 +113,15 @@ function readAdapterPackageVersionFromDisk(record: AdapterPluginRecord): string } } +function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabilities { + return { + supportsInstructionsBundle: adapter.supportsInstructionsBundle ?? false, + supportsSkills: Boolean(adapter.listSkills || adapter.syncSkills), + supportsLocalAgentJwt: adapter.supportsLocalAgentJwt ?? false, + requiresMaterializedRuntimeSkills: adapter.requiresMaterializedRuntimeSkills ?? false, + }; +} + function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set): AdapterInfo { const fromDisk = externalRecord ? readAdapterPackageVersionFromDisk(externalRecord) : undefined; return { @@ -112,6 +131,7 @@ function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterP modelsCount: (adapter.models ?? []).length, loaded: true, // If it's in the registry, it's loaded disabled: disabledSet.has(adapter.type), + capabilities: buildAdapterCapabilities(adapter), overriddenBuiltin: externalRecord ? BUILTIN_ADAPTER_TYPES.has(adapter.type) : undefined, overridePaused: BUILTIN_ADAPTER_TYPES.has(adapter.type) ? isOverridePaused(adapter.type) : undefined, // Prefer on-disk package.json so the UI reflects bumps without relying on store-only fields. @@ -174,7 +194,10 @@ export function adapterRoutes() { * its model count, and load status. */ router.get("/adapters", async (_req, res) => { - assertBoard(_req); + // Adapter inventory is needed by ordinary board members when creating or + // editing company agents. Mutating adapter management routes below remain + // instance-admin only because they affect the whole server runtime. + assertBoardOrgAccess(_req); const registeredAdapters = listServerAdapters(); const externalRecords = new Map( @@ -200,7 +223,7 @@ export function adapterRoutes() { * - version?: string — target version for npm packages */ router.post("/adapters/install", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest; @@ -332,7 +355,7 @@ export function adapterRoutes() { * Request body: { "disabled": boolean } */ router.patch("/adapters/:type", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const adapterType = req.params.type; const { disabled } = req.body as { disabled?: boolean }; @@ -367,7 +390,7 @@ export function adapterRoutes() { * keep the adapter they started with. */ router.patch("/adapters/:type/override", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const adapterType = req.params.type; const { paused } = req.body as { paused?: boolean }; @@ -395,7 +418,7 @@ export function adapterRoutes() { * Unregister an external adapter. Built-in adapters cannot be removed. */ router.delete("/adapters/:type", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const adapterType = req.params.type; @@ -470,7 +493,7 @@ export function adapterRoutes() { * Cannot be used on built-in adapter types. */ router.post("/adapters/:type/reload", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const type = req.params.type; @@ -522,7 +545,7 @@ export function adapterRoutes() { // This is a convenience shortcut for remove + install with the same // package name, but without the risk of losing the store record. router.post("/adapters/:type/reinstall", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const type = req.params.type; @@ -595,7 +618,9 @@ export function adapterRoutes() { const CONFIG_SCHEMA_TTL_MS = 30_000; router.get("/adapters/:type/config-schema", async (req, res) => { - assertBoard(req); + // Config schemas are read-only form metadata used when org members create + // or edit agents; they do not install or execute new adapter code. + assertBoardOrgAccess(req); const { type } = req.params; const adapter = findActiveServerAdapter(type); @@ -633,7 +658,9 @@ export function adapterRoutes() { // The adapter package must export a "./ui-parser" entry in package.json // pointing to a self-contained ESM module with zero runtime dependencies. router.get("/adapters/:type/ui-parser.js", (req, res) => { - assertBoard(req); + // UI parsers are read-only assets for displaying existing run output. + // Runtime-changing adapter management routes above require instance admin. + assertBoardOrgAccess(req); const { type } = req.params; const source = getOrExtractUiParserSource(type); if (!source) { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 6e8be7e..f80d410 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,4 +1,4 @@ -import { Router, type Request } from "express"; +import { Router, type Request, type Response } from "express"; import { generateKeyPairSync, randomUUID } from "node:crypto"; import path from "node:path"; import type { Db } from "@taskcore/db"; @@ -7,6 +7,7 @@ import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { agentSkillSyncSchema, agentMineInboxQuerySchema, + AGENT_DEFAULT_MAX_CONCURRENT_RUNS, createAgentKeySchema, createAgentHireSchema, createAgentSchema, @@ -37,6 +38,7 @@ import { companySkillService, budgetService, heartbeatService, + ISSUE_LIST_DEFAULT_LIMIT, issueApprovalService, issueService, logActivity, @@ -46,6 +48,10 @@ import { } from "../services/index.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; +import { + assertNoAgentHostWorkspaceCommandMutation, + collectAgentAdapterWorkspaceCommandPaths, +} from "./workspace-command-authz.js"; import { detectAdapterModel, findActiveServerAdapter, @@ -71,7 +77,18 @@ import { } from "../services/default-agent-instructions.js"; import { getTelemetryClient } from "../telemetry.js"; +const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000; +const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024; + +function readRunLogLimitBytes(value: unknown) { + const parsed = Number(value ?? RUN_LOG_DEFAULT_LIMIT_BYTES); + if (!Number.isFinite(parsed)) return RUN_LOG_DEFAULT_LIMIT_BYTES; + return Math.max(1, Math.min(RUN_LOG_MAX_LIMIT_BYTES, Math.trunc(parsed))); +} + export function agentRoutes(db: Db) { + // Legacy hardcoded maps — used as fallback when adapter module does not + // declare capability flags explicitly. const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", @@ -83,6 +100,22 @@ export function agentRoutes(db: Db) { pi_local: "instructionsFilePath", }; const DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES = new Set(Object.keys(DEFAULT_INSTRUCTIONS_PATH_KEYS)); + + /** Check if an adapter supports the managed instructions bundle. */ + function adapterSupportsInstructionsBundle(adapterType: string): boolean { + const adapter = findActiveServerAdapter(adapterType); + if (adapter?.supportsInstructionsBundle !== undefined) return adapter.supportsInstructionsBundle; + return DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(adapterType); + } + + /** Resolve the adapter config key for the instructions file path. */ + function resolveInstructionsPathKey(adapterType: string): string | null { + const adapter = findActiveServerAdapter(adapterType); + if (adapter?.instructionsPathKey) return adapter.instructionsPathKey; + if (adapter?.supportsInstructionsBundle === true) return "instructionsFilePath"; + if (adapter?.supportsInstructionsBundle === false) return null; + return DEFAULT_INSTRUCTIONS_PATH_KEYS[adapterType] ?? null; + } const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]); const KNOWN_INSTRUCTIONS_BUNDLE_KEYS = [ "instructionsBundleMode", @@ -213,10 +246,33 @@ export function agentRoutes(db: Db) { return actorAgent; } + async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) { + assertBoard(req); + assertCompanyAccess(req, companyId); + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); + if (!allowed) { + throw forbidden("Missing permission: agents:create"); + } + } + async function assertCanReadConfigurations(req: Request, companyId: string) { return assertCanCreateAgentsForCompany(req, companyId); } + async function getAccessibleAgent(req: Request, res: Response, id: string) { + const agent = await svc.getById(id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return null; + } + assertCompanyAccess(req, agent.companyId); + if (req.actor.type === "board") { + await assertBoardCanManageAgentsForCompany(req, agent.companyId); + } + return agent; + } + async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") { @@ -299,7 +355,10 @@ export function agentRoutes(db: Db) { async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); - if (req.actor.type === "board") return; + if (req.actor.type === "board") { + await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId); + return; + } if (!req.actor.agentId) throw forbidden("Agent authentication required"); const actorAgent = await svc.getById(req.actor.agentId); @@ -321,7 +380,10 @@ export function agentRoutes(db: Db) { async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); - if (req.actor.type === "board") return; + if (req.actor.type === "board") { + await assertCanReadConfigurations(req, targetAgent.companyId); + return; + } if (!req.actor.agentId) throw forbidden("Agent authentication required"); const actorAgent = await svc.getById(req.actor.agentId); @@ -463,6 +525,9 @@ export function agentRoutes(db: Db) { if (parseBooleanLike(heartbeat.enabled) == null) { heartbeat.enabled = false; } + if (parseNumberLike(heartbeat.maxConcurrentRuns) == null) { + heartbeat.maxConcurrentRuns = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; + } normalizedRuntimeConfig.heartbeat = heartbeat; return normalizedRuntimeConfig; @@ -557,7 +622,7 @@ export function agentRoutes(db: Db) { adapterType: string; adapterConfig: unknown; }>(agent: T): Promise { - if (!DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(agent.adapterType)) { + if (!adapterSupportsInstructionsBundle(agent.adapterType)) { return agent; } @@ -592,19 +657,24 @@ export function agentRoutes(db: Db) { async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); - if (req.actor.type === "board") return; - if (!req.actor.agentId) throw forbidden("Agent authentication required"); - - const actorAgent = await svc.getById(req.actor.agentId); - if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) { - throw forbidden("Agent key cannot access another company"); + if (req.actor.type !== "board") { + throw forbidden( + "Only board-authenticated callers can manage instructions path or bundle configuration", + ); } - if (actorAgent.id === targetAgent.id) return; - - const chainOfCommand = await svc.getChainOfCommand(targetAgent.id); - if (chainOfCommand.some((manager) => manager.id === actorAgent.id)) return; + await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId); + } - throw forbidden("Only the target agent or an ancestor manager can update instructions path"); + function assertNoAgentInstructionsConfigMutation( + req: Request, + adapterConfig: Record | null | undefined, + ) { + if (req.actor.type !== "agent" || !adapterConfig) return; + const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS.filter((key) => adapterConfig[key] !== undefined); + if (changedSensitiveKeys.length === 0) return; + throw forbidden( + `Agent-authenticated callers cannot modify instructions path or bundle configuration (${changedSensitiveKeys.join(", ")})`, + ); } function summarizeAgentUpdateDetails(patch: Record) { @@ -638,7 +708,9 @@ export function agentRoutes(db: Db) { }; } - const ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS = new Set([ + // Legacy hardcoded set — used as fallback when adapter module does not + // declare requiresMaterializedRuntimeSkills explicitly. + const LEGACY_MATERIALIZED_SKILLS_SET = new Set([ "cursor", "gemini_local", "opencode_local", @@ -646,7 +718,11 @@ export function agentRoutes(db: Db) { ]); function shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) { - return ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS.has(adapterType); + const adapter = findActiveServerAdapter(adapterType); + if (adapter?.requiresMaterializedRuntimeSkills !== undefined) { + return adapter.requiresMaterializedRuntimeSkills; + } + return LEGACY_MATERIALIZED_SKILLS_SET.has(adapterType); } async function buildRuntimeSkillConfig( @@ -924,18 +1000,18 @@ export function agentRoutes(db: Db) { }; const snapshot = adapter?.syncSkills ? await adapter.syncSkills({ - agentId: updated.id, - companyId: updated.companyId, - adapterType: updated.adapterType, - config: runtimeSkillConfig, - }, desiredSkills) - : adapter?.listSkills - ? await adapter.listSkills({ agentId: updated.id, companyId: updated.companyId, adapterType: updated.adapterType, config: runtimeSkillConfig, - }) + }, desiredSkills) + : adapter?.listSkills + ? await adapter.listSkills({ + agentId: updated.id, + companyId: updated.companyId, + adapterType: updated.adapterType, + config: runtimeSkillConfig, + }) : buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkills); await logActivity(db, { @@ -973,7 +1049,7 @@ export function agentRoutes(db: Db) { } const result = await svc.list(companyId); const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId); - if (canReadConfigs || req.actor.type === "board") { + if (canReadConfigs) { res.json(result); return; } @@ -1105,7 +1181,13 @@ export function agentRoutes(db: Db) { const rows = await issuesSvc.list(req.actor.companyId, { assigneeAgentId: req.actor.agentId, status: "todo,in_progress,blocked", + includeRoutineExecutions: true, + limit: ISSUE_LIST_DEFAULT_LIMIT, }); + const dependencyReadiness = await issuesSvc.listDependencyReadiness( + req.actor.companyId, + rows.map((issue) => issue.id), + ); res.json( rows.map((issue) => ({ @@ -1119,6 +1201,9 @@ export function agentRoutes(db: Db) { parentId: issue.parentId, updatedAt: issue.updatedAt, activeRun: issue.activeRun, + dependencyReady: dependencyReadiness.get(issue.id)?.isDependencyReady ?? true, + unresolvedBlockerCount: dependencyReadiness.get(issue.id)?.unresolvedBlockerCount ?? 0, + unresolvedBlockerIssueIds: dependencyReadiness.get(issue.id)?.unresolvedBlockerIssueIds ?? [], })), ); }); @@ -1135,6 +1220,7 @@ export function agentRoutes(db: Db) { touchedByUserId: query.userId, inboxArchivedByUserId: query.userId, status: query.status, + limit: ISSUE_LIST_DEFAULT_LIMIT, }); res.json(rows); @@ -1148,12 +1234,13 @@ export function agentRoutes(db: Db) { return; } assertCompanyAccess(req, agent.companyId); - if (req.actor.type === "agent" && req.actor.agentId !== id) { - const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId); - if (!canRead) { - res.json(await buildAgentDetail(agent, { restricted: true })); - return; - } + const isSelf = req.actor.type === "agent" && req.actor.agentId === id; + const canReadSensitiveDetail = isSelf + ? true + : await actorCanReadConfigurationsForCompany(req, agent.companyId); + if (!canReadSensitiveDetail) { + res.json(await buildAgentDetail(agent, { restricted: true })); + return; } res.json(await buildAgentDetail(agent)); }); @@ -1241,6 +1328,7 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } + await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); const state = await heartbeat.getRuntimeState(id); @@ -1255,6 +1343,7 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } + await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); const sessions = await heartbeat.listTaskSessions(id); @@ -1274,6 +1363,7 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } + await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); const taskKey = @@ -1306,6 +1396,14 @@ export function agentRoutes(db: Db) { ...hireInput } = req.body; hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectAgentAdapterWorkspaceCommandPaths(hireInput.adapterConfig), + ); + assertNoAgentInstructionsConfigMutation( + req, + (hireInput.adapterConfig ?? {}) as Record, + ); const requestedAdapterConfig = applyCreateDefaultsByAdapterType( hireInput.adapterType, ((hireInput.adapterConfig ?? {}) as Record), @@ -1461,10 +1559,21 @@ export function agentRoutes(db: Db) { router.post("/companies/:companyId/agents", validate(createAgentSchema), async (req, res) => { const companyId = req.params.companyId as string; - assertCompanyAccess(req, companyId); + await assertCanCreateAgentsForCompany(req, companyId); - if (req.actor.type === "agent") { - assertBoard(req); + const company = await db + .select() + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + if (!company) { + res.status(404).json({ error: "Company not found" }); + return; + } + if (company.requireBoardApprovalForNewAgents) { + throw conflict( + "Direct agent creation requires board approval. Use POST /api/companies/:companyId/agent-hires to create a pending hire approval.", + ); } const { @@ -1472,6 +1581,14 @@ export function agentRoutes(db: Db) { ...createInput } = req.body; createInput.adapterType = assertKnownAdapterType(createInput.adapterType); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectAgentAdapterWorkspaceCommandPaths(createInput.adapterConfig), + ); + assertNoAgentInstructionsConfigMutation( + req, + (createInput.adapterConfig ?? {}) as Record, + ); const requestedAdapterConfig = applyCreateDefaultsByAdapterType( createInput.adapterType, ((createInput.adapterConfig ?? {}) as Record), @@ -1565,6 +1682,8 @@ export function agentRoutes(db: Db) { res.status(403).json({ error: "Only CEO can manage permissions" }); return; } + } else { + await assertBoardCanManageAgentsForCompany(req, existing.companyId); } const agent = await svc.updatePermissions(id, req.body); @@ -1605,6 +1724,10 @@ export function agentRoutes(db: Db) { }); router.patch("/agents/:id/instructions-path", validate(updateAgentInstructionsPathSchema), async (req, res) => { + if (req.actor.type !== "board") { + throw forbidden("Only board-authenticated callers can manage instructions path or bundle configuration"); + } + const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { @@ -1616,7 +1739,7 @@ export function agentRoutes(db: Db) { const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {}; const explicitKey = asNonEmptyString(req.body.adapterConfigKey); - const defaultKey = DEFAULT_INSTRUCTIONS_PATH_KEYS[existing.adapterType] ?? null; + const defaultKey = resolveInstructionsPathKey(existing.adapterType); const adapterConfigKey = explicitKey ?? defaultKey; if (!adapterConfigKey) { res.status(422).json({ @@ -1865,10 +1988,15 @@ export function agentRoutes(db: Db) { res.status(422).json({ error: "adapterConfig must be an object" }); return; } - const changingInstructionsPath = Object.keys(adapterConfig).some((key) => - KNOWN_INSTRUCTIONS_PATH_KEYS.has(key), + assertNoAgentInstructionsConfigMutation(req, adapterConfig); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectAgentAdapterWorkspaceCommandPaths(adapterConfig), + ); + const changingInstructionsConfig = Object.keys(adapterConfig).some((key) => + KNOWN_INSTRUCTIONS_BUNDLE_KEYS.includes(key as (typeof KNOWN_INSTRUCTIONS_BUNDLE_KEYS)[number]), ); - if (changingInstructionsPath) { + if (changingInstructionsConfig) { await assertCanManageInstructionsPath(req, existing); } patchData.adapterConfig = adapterConfig; @@ -1969,6 +2097,9 @@ export function agentRoutes(db: Db) { router.post("/agents/:id/pause", async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await getAccessibleAgent(req, res, id))) { + return; + } const agent = await svc.pause(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -1992,6 +2123,9 @@ export function agentRoutes(db: Db) { router.post("/agents/:id/resume", async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await getAccessibleAgent(req, res, id))) { + return; + } const agent = await svc.resume(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -2010,9 +2144,47 @@ export function agentRoutes(db: Db) { res.json(agent); }); + router.post("/agents/:id/approve", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const existing = await getAccessibleAgent(req, res, id); + if (!existing) { + return; + } + if (existing.status !== "pending_approval") { + res.status(409).json({ error: "Only pending approval agents can be approved" }); + return; + } + const approval = await svc.activatePendingApproval(id); + if (!approval) { + res.status(404).json({ error: "Agent not found" }); + return; + } + if (!approval.activated) { + res.status(409).json({ error: "Only pending approval agents can be approved" }); + return; + } + const { agent } = approval; + + await logActivity(db, { + companyId: agent.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "agent.approved", + entityType: "agent", + entityId: agent.id, + details: { source: "agent_detail" }, + }); + + res.json(agent); + }); + router.post("/agents/:id/terminate", async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await getAccessibleAgent(req, res, id))) { + return; + } const agent = await svc.terminate(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -2036,6 +2208,9 @@ export function agentRoutes(db: Db) { router.delete("/agents/:id", async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await getAccessibleAgent(req, res, id))) { + return; + } const agent = await svc.remove(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -2057,6 +2232,10 @@ export function agentRoutes(db: Db) { router.get("/agents/:id/keys", async (req, res) => { assertBoard(req); const id = req.params.id as string; + const agent = await getAccessibleAgent(req, res, id); + if (!agent) { + return; + } const keys = await svc.listKeys(id); res.json(keys); }); @@ -2064,32 +2243,56 @@ export function agentRoutes(db: Db) { router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; + const agent = await getAccessibleAgent(req, res, id); + if (!agent) { + return; + } const key = await svc.createApiKey(id, req.body.name); - const agent = await svc.getById(id); - if (agent) { - await logActivity(db, { - companyId: agent.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "agent.key_created", - entityType: "agent", - entityId: agent.id, - details: { keyId: key.id, name: key.name }, - }); - } + await logActivity(db, { + companyId: agent.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "agent.key_created", + entityType: "agent", + entityId: agent.id, + details: { keyId: key.id, name: key.name }, + }); res.status(201).json(key); }); router.delete("/agents/:id/keys/:keyId", async (req, res) => { assertBoard(req); + const id = req.params.id as string; const keyId = req.params.keyId as string; - const revoked = await svc.revokeKey(keyId); + const agent = await getAccessibleAgent(req, res, id); + if (!agent) { + return; + } + + const key = await svc.getKeyById(keyId); + if (!key || key.agentId !== agent.id) { + res.status(404).json({ error: "Key not found" }); + return; + } + + const revoked = await svc.revokeKey(agent.id, keyId); if (!revoked) { res.status(404).json({ error: "Key not found" }); return; } + + await logActivity(db, { + companyId: agent.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "agent.key_revoked", + entityType: "agent", + entityId: agent.id, + details: { keyId: key.id, name: key.name }, + }); + res.json({ ok: true }); }); @@ -2102,9 +2305,13 @@ export function agentRoutes(db: Db) { } assertCompanyAccess(req, agent.companyId); - if (req.actor.type === "agent" && req.actor.agentId !== id) { - res.status(403).json({ error: "Agent can only invoke itself" }); - return; + if (req.actor.type === "agent") { + if (req.actor.agentId !== id) { + res.status(403).json({ error: "Agent can only invoke itself" }); + return; + } + } else { + await assertBoardCanManageAgentsForCompany(req, agent.companyId); } const run = await heartbeat.wakeup(id, { @@ -2152,9 +2359,13 @@ export function agentRoutes(db: Db) { } assertCompanyAccess(req, agent.companyId); - if (req.actor.type === "agent" && req.actor.agentId !== id) { - res.status(403).json({ error: "Agent can only invoke itself" }); - return; + if (req.actor.type === "agent") { + if (req.actor.agentId !== id) { + res.status(403).json({ error: "Agent can only invoke itself" }); + return; + } + } else { + await assertBoardCanManageAgentsForCompany(req, agent.companyId); } const run = await heartbeat.invoke( @@ -2200,6 +2411,7 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } + await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); if (agent.adapterType !== "claude_local") { res.status(400).json({ error: "Login is only supported for claude_local agents" }); @@ -2251,6 +2463,11 @@ export function agentRoutes(db: Db) { agentId: heartbeatRuns.agentId, agentName: agentsTable.name, adapterType: agentsTable.adapterType, + livenessState: heartbeatRuns.livenessState, + livenessReason: heartbeatRuns.livenessReason, + continuationAttempt: heartbeatRuns.continuationAttempt, + lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, + nextAction: heartbeatRuns.nextAction, issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"), }; @@ -2297,7 +2514,13 @@ export function agentRoutes(db: Db) { return; } assertCompanyAccess(req, run.companyId); - res.json(redactCurrentUserValue(run, await getCurrentUserRedactionOptions())); + const retryExhaustedReason = await heartbeat.getRetryExhaustedReason(runId); + res.json( + redactCurrentUserValue( + { ...run, retryExhaustedReason }, + await getCurrentUserRedactionOptions(), + ), + ); }); router.post("/heartbeat-runs/:runId/cancel", async (req, res) => { @@ -2348,7 +2571,7 @@ export function agentRoutes(db: Db) { router.get("/heartbeat-runs/:runId/log", async (req, res) => { const runId = req.params.runId as string; - const run = await heartbeat.getRun(runId); + const run = await heartbeat.getRunLogAccess(runId); if (!run) { res.status(404).json({ error: "Heartbeat run not found" }); return; @@ -2356,12 +2579,13 @@ export function agentRoutes(db: Db) { assertCompanyAccess(req, run.companyId); const offset = Number(req.query.offset ?? 0); - const limitBytes = Number(req.query.limitBytes ?? 256000); - const result = await heartbeat.readLog(runId, { + const limitBytes = readRunLogLimitBytes(req.query.limitBytes); + const result = await heartbeat.readLog(run, { offset: Number.isFinite(offset) ? offset : 0, - limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000, + limitBytes, }); + res.set("Cache-Control", "no-cache, no-store"); res.json(result); }); @@ -2390,12 +2614,13 @@ export function agentRoutes(db: Db) { assertCompanyAccess(req, operation.companyId); const offset = Number(req.query.offset ?? 0); - const limitBytes = Number(req.query.limitBytes ?? 256000); + const limitBytes = readRunLogLimitBytes(req.query.limitBytes); const result = await workspaceOperations.readLog(operationId, { offset: Number.isFinite(offset) ? offset : 0, - limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000, + limitBytes, }); + res.set("Cache-Control", "no-cache, no-store"); res.json(result); }); @@ -2422,6 +2647,11 @@ export function agentRoutes(db: Db) { agentId: heartbeatRuns.agentId, agentName: agentsTable.name, adapterType: agentsTable.adapterType, + livenessState: heartbeatRuns.livenessState, + livenessReason: heartbeatRuns.livenessReason, + continuationAttempt: heartbeatRuns.continuationAttempt, + lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, + nextAction: heartbeatRuns.nextAction, }) .from(heartbeatRuns) .innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id)) diff --git a/server/src/routes/approvals.ts b/server/src/routes/approvals.ts index 6d509ca..e6d062f 100644 --- a/server/src/routes/approvals.ts +++ b/server/src/routes/approvals.ts @@ -74,10 +74,10 @@ export function approvalRoutes(db: Db) { const normalizedPayload = approvalInput.type === "hire_agent" ? await secretsSvc.normalizeHireApprovalPayloadForPersistence( - companyId, - approvalInput.payload, - { strictMode: strictSecretsMode }, - ) + companyId, + approvalInput.payload, + { strictMode: strictSecretsMode }, + ) : approvalInput.payload; const actor = getActorInfo(req); @@ -134,11 +134,8 @@ export function approvalRoutes(db: Db) { res.status(404).json({ error: "Approval not found" }); return; } - const { approval, applied } = await svc.approve( - id, - req.body.decidedByUserId ?? "board", - req.body.decisionNote, - ); + const decidedByUserId = req.actor.userId ?? "board"; + const { approval, applied } = await svc.approve(id, decidedByUserId, req.body.decisionNote); if (applied) { const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id); @@ -233,11 +230,8 @@ export function approvalRoutes(db: Db) { res.status(404).json({ error: "Approval not found" }); return; } - const { approval, applied } = await svc.reject( - id, - req.body.decidedByUserId ?? "board", - req.body.decisionNote, - ); + const decidedByUserId = req.actor.userId ?? "board"; + const { approval, applied } = await svc.reject(id, decidedByUserId, req.body.decisionNote); if (applied) { await logActivity(db, { @@ -264,11 +258,8 @@ export function approvalRoutes(db: Db) { res.status(404).json({ error: "Approval not found" }); return; } - const approval = await svc.requestRevision( - id, - req.body.decidedByUserId ?? "board", - req.body.decisionNote, - ); + const decidedByUserId = req.actor.userId ?? "board"; + const approval = await svc.requestRevision(id, decidedByUserId, req.body.decisionNote); await logActivity(db, { companyId: approval.companyId, @@ -301,10 +292,10 @@ export function approvalRoutes(db: Db) { const normalizedPayload = req.body.payload ? existing.type === "hire_agent" ? await secretsSvc.normalizeHireApprovalPayloadForPersistence( - existing.companyId, - req.body.payload, - { strictMode: strictSecretsMode }, - ) + existing.companyId, + req.body.payload, + { strictMode: strictSecretsMode }, + ) : req.body.payload : undefined; const approval = await svc.resubmit(id, normalizedPayload); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..781bb98 --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,100 @@ +import { Router } from "express"; +import { eq } from "drizzle-orm"; +import type { Db } from "@taskcore/db"; +import { authUsers } from "@taskcore/db"; +import { + authSessionSchema, + currentUserProfileSchema, + updateCurrentUserProfileSchema, +} from "@taskcore/shared"; +import { unauthorized } from "../errors.js"; +import { validate } from "../middleware/validate.js"; + +async function loadCurrentUserProfile(db: Db, userId: string) { + const user = await db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .where(eq(authUsers.id, userId)) + .then((rows) => rows[0] ?? null); + + if (!user) { + throw unauthorized("Signed-in user not found"); + } + + return currentUserProfileSchema.parse({ + id: user.id, + email: user.email ?? null, + name: user.name ?? null, + image: user.image ?? null, + }); +} + +export function authRoutes(db: Db) { + const router = Router(); + + router.get("/get-session", async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + + const user = await loadCurrentUserProfile(db, req.actor.userId); + res.json(authSessionSchema.parse({ + session: { + id: `taskcore:${req.actor.source ?? "none"}:${req.actor.userId}`, + userId: req.actor.userId, + }, + user, + })); + }); + + router.get("/profile", async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + + res.json(await loadCurrentUserProfile(db, req.actor.userId)); + }); + + router.patch("/profile", validate(updateCurrentUserProfileSchema), async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + + const patch = updateCurrentUserProfileSchema.parse(req.body); + const now = new Date(); + + const updated = await db + .update(authUsers) + .set({ + name: patch.name, + ...(patch.image !== undefined ? { image: patch.image } : {}), + updatedAt: now, + }) + .where(eq(authUsers.id, req.actor.userId)) + .returning({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .then((rows) => rows[0] ?? null); + + if (!updated) { + throw unauthorized("Signed-in user not found"); + } + + res.json(currentUserProfileSchema.parse({ + id: updated.id, + email: updated.email ?? null, + name: updated.name ?? null, + image: updated.image ?? null, + })); + }); + + return router; +} diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index a881d4f..ec6c496 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -1,12 +1,36 @@ import type { Request } from "express"; import { forbidden, unauthorized } from "../errors.js"; +export function assertAuthenticated(req: Request) { + if (req.actor.type === "none") { + throw unauthorized(); + } +} + export function assertBoard(req: Request) { if (req.actor.type !== "board") { throw forbidden("Board access required"); } } +export function hasBoardOrgAccess(req: Request) { + if (req.actor.type !== "board") { + return false; + } + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { + return true; + } + return Array.isArray(req.actor.companyIds) && req.actor.companyIds.length > 0; +} + +export function assertBoardOrgAccess(req: Request) { + assertBoard(req); + if (hasBoardOrgAccess(req)) { + return; + } + throw forbidden("Company membership or instance admin access required"); +} + export function assertInstanceAdmin(req: Request) { assertBoard(req); if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { @@ -16,24 +40,31 @@ export function assertInstanceAdmin(req: Request) { } export function assertCompanyAccess(req: Request, companyId: string) { - if (req.actor.type === "none") { - throw unauthorized(); - } + assertAuthenticated(req); if (req.actor.type === "agent" && req.actor.companyId !== companyId) { throw forbidden("Agent key cannot access another company"); } - if (req.actor.type === "board" && req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) { + if (req.actor.type === "board" && req.actor.source !== "local_implicit") { const allowedCompanies = req.actor.companyIds ?? []; if (!allowedCompanies.includes(companyId)) { throw forbidden("User does not have access to this company"); } + const method = typeof req.method === "string" ? req.method.toUpperCase() : "GET"; + const isSafeMethod = ["GET", "HEAD", "OPTIONS"].includes(method); + if (!isSafeMethod && !req.actor.isInstanceAdmin && Array.isArray(req.actor.memberships)) { + const membership = req.actor.memberships.find((item) => item.companyId === companyId); + if (!membership || membership.status !== "active") { + throw forbidden("User does not have active company access"); + } + if (membership.membershipRole === "viewer") { + throw forbidden("Viewer access is read-only"); + } + } } } export function getActorInfo(req: Request) { - if (req.actor.type === "none") { - throw unauthorized(); - } + assertAuthenticated(req); if (req.actor.type === "agent") { return { actorType: "agent" as const, diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 07c97a2..9f3d5af 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -164,7 +164,7 @@ export function companyRoutes(db: Db, storage?: StorageService) { router.post("/:companyId/export", validate(companyPortabilityExportSchema), async (req, res) => { const companyId = req.params.companyId as string; - assertCompanyAccess(req, companyId); + await assertCanManagePortability(req, companyId, "exports"); const result = await portability.exportBundle(companyId, req.body); res.json(result); }); diff --git a/server/src/routes/costs.ts b/server/src/routes/costs.ts index d0c6e8d..08d4ec1 100644 --- a/server/src/routes/costs.ts +++ b/server/src/routes/costs.ts @@ -290,13 +290,7 @@ export function costRoutes(db: Db) { } assertCompanyAccess(req, agent.companyId); - - if (req.actor.type === "agent") { - if (req.actor.agentId !== agentId) { - res.status(403).json({ error: "Agent can only change its own budget" }); - return; - } - } + assertBoard(req); const updated = await agents.update(agentId, { budgetMonthlyCents: req.body.budgetMonthlyCents }); if (!updated) { diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index 3987c74..2ec7de5 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -8,6 +8,7 @@ import { updateExecutionWorkspaceSchema, workspaceRuntimeControlTargetSchema, } from "@taskcore/shared"; +import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@taskcore/shared"; import { validate } from "../middleware/validate.js"; import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js"; import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js"; @@ -23,6 +24,14 @@ import { stopRuntimeServicesForExecutionWorkspace, } from "../services/workspace-runtime.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { + assertNoAgentHostWorkspaceCommandMutation, + collectExecutionWorkspaceCommandPaths, +} from "./workspace-command-authz.js"; +import { assertCanManageExecutionWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js"; +import { appendWithCap } from "../adapters/utils.js"; + +const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024; export function executionWorkspaceRoutes(db: Db) { const router = Router(); @@ -32,13 +41,16 @@ export function executionWorkspaceRoutes(db: Db) { router.get("/companies/:companyId/execution-workspaces", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const workspaces = await svc.list(companyId, { + const filters = { projectId: req.query.projectId as string | undefined, projectWorkspaceId: req.query.projectWorkspaceId as string | undefined, issueId: req.query.issueId as string | undefined, status: req.query.status as string | undefined, reuseEligible: req.query.reuseEligible === "true", - }); + }; + const workspaces = req.query.summary === "true" + ? await svc.listSummaries(companyId, filters) + : await svc.list(companyId, filters); res.json(workspaces); }); @@ -96,6 +108,12 @@ export function executionWorkspaceRoutes(db: Db) { } assertCompanyAccess(req, existing.companyId); + await assertCanManageExecutionWorkspaceRuntimeServices(db, req, { + companyId: existing.companyId, + executionWorkspaceId: existing.id, + sourceIssueId: existing.sourceIssueId, + }); + const workspaceCwd = existing.cwd; if (!workspaceCwd) { res.status(422).json({ error: "Execution workspace needs a local path before Taskcore can run workspace commands" }); @@ -104,39 +122,39 @@ export function executionWorkspaceRoutes(db: Db) { const projectWorkspace = existing.projectWorkspaceId ? await db - .select({ - id: projectWorkspaces.id, - cwd: projectWorkspaces.cwd, - repoUrl: projectWorkspaces.repoUrl, - repoRef: projectWorkspaces.repoRef, - defaultRef: projectWorkspaces.defaultRef, - metadata: projectWorkspaces.metadata, - }) - .from(projectWorkspaces) - .where( - and( - eq(projectWorkspaces.id, existing.projectWorkspaceId), - eq(projectWorkspaces.companyId, existing.companyId), - ), - ) - .then((rows) => rows[0] ?? null) + .select({ + id: projectWorkspaces.id, + cwd: projectWorkspaces.cwd, + repoUrl: projectWorkspaces.repoUrl, + repoRef: projectWorkspaces.repoRef, + defaultRef: projectWorkspaces.defaultRef, + metadata: projectWorkspaces.metadata, + }) + .from(projectWorkspaces) + .where( + and( + eq(projectWorkspaces.id, existing.projectWorkspaceId), + eq(projectWorkspaces.companyId, existing.companyId), + ), + ) + .then((rows) => rows[0] ?? null) : null; const projectWorkspaceRuntime = readProjectWorkspaceRuntimeConfig( (projectWorkspace?.metadata as Record | null) ?? null, )?.workspaceRuntime ?? null; const projectPolicy = existing.projectId ? await db - .select({ - executionWorkspacePolicy: projects.executionWorkspacePolicy, - }) - .from(projects) - .where( - and( - eq(projects.id, existing.projectId), - eq(projects.companyId, existing.companyId), - ), - ) - .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)) + .select({ + executionWorkspacePolicy: projects.executionWorkspacePolicy, + }) + .from(projects) + .where( + and( + eq(projects.id, existing.projectId), + eq(projects.companyId, existing.companyId), + ), + ) + .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)) : null; const effectiveRuntimeConfig = existing.config?.workspaceRuntime ?? projectWorkspaceRuntime ?? null; const target = req.body as { workspaceCommandId?: string | null; runtimeServiceId?: string | null; serviceIndex?: number | null }; @@ -195,8 +213,8 @@ export function executionWorkspaceRoutes(db: Db) { executionWorkspaceId: existing.id, }); let runtimeServiceCount = existing.runtimeServices?.length ?? 0; - const stdout: string[] = []; - const stderr: string[] = []; + let stdout = ""; + let stderr = ""; const operation = await recorder.recordOperation({ phase: action === "stop" ? "workspace_teardown" : "workspace_provision", @@ -242,10 +260,10 @@ export function executionWorkspaceRoutes(db: Db) { }, issue: existing.sourceIssueId ? { - id: existing.sourceIssueId, - identifier: null, - title: existing.name, - } + id: existing.sourceIssueId, + identifier: null, + title: existing.name, + } : null, agent: { id: actor.agentId ?? null, @@ -271,10 +289,10 @@ export function executionWorkspaceRoutes(db: Db) { }, issue: existing.sourceIssueId ? { - id: existing.sourceIssueId, - identifier: null, - title: existing.name, - } + id: existing.sourceIssueId, + identifier: null, + title: existing.name, + } : null, workspace: availableWorkspace, command: workspaceCommand.rawConfig, @@ -296,8 +314,8 @@ export function executionWorkspaceRoutes(db: Db) { } const onLog = async (stream: "stdout" | "stderr", chunk: string) => { - if (stream === "stdout") stdout.push(chunk); - else stderr.push(chunk); + if (stream === "stdout") stdout = appendWithCap(stdout, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); + else stderr = appendWithCap(stderr, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); }; if (action === "stop" || action === "restart") { @@ -323,10 +341,10 @@ export function executionWorkspaceRoutes(db: Db) { }, issue: existing.sourceIssueId ? { - id: existing.sourceIssueId, - identifier: null, - title: existing.name, - } + id: existing.sourceIssueId, + identifier: null, + title: existing.name, + } : null, workspace: availableWorkspace, executionWorkspaceId: existing.id, @@ -340,20 +358,20 @@ export function executionWorkspaceRoutes(db: Db) { runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (existing.runtimeServices?.length ?? 1) - 1) : 0; } - const currentDesiredState: "running" | "stopped" = + const currentDesiredState: WorkspaceRuntimeDesiredState = existing.config?.desiredState ?? ((existing.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running") ? "running" : "stopped"); const nextRuntimeState: { - desiredState: "running" | "stopped"; - serviceStates: Record | null | undefined; + desiredState: WorkspaceRuntimeDesiredState; + serviceStates: WorkspaceRuntimeServiceStateMap | null | undefined; } = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null) - ? { + ? { desiredState: currentDesiredState, serviceStates: existing.config?.serviceStates ?? null, } - : buildWorkspaceRuntimeDesiredStatePatch({ + : buildWorkspaceRuntimeDesiredStatePatch({ config: { workspaceRuntime: effectiveRuntimeConfig }, currentDesiredState, currentServiceStates: existing.config?.serviceStates ?? null, @@ -368,8 +386,8 @@ export function executionWorkspaceRoutes(db: Db) { return { status: "succeeded", - stdout: stdout.join(""), - stderr: stderr.join(""), + stdout, + stderr, system: action === "stop" ? "Stopped execution workspace runtime services.\n" @@ -428,6 +446,13 @@ export function executionWorkspaceRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectExecutionWorkspaceCommandPaths({ + config: req.body.config, + metadata: req.body.metadata, + }), + ); const patch: Record = { ...(req.body.name === undefined ? {} : { name: req.body.name }), ...(req.body.cwd === undefined ? {} : { cwd: req.body.cwd }), @@ -506,27 +531,27 @@ export function executionWorkspaceRoutes(db: Db) { }); const projectWorkspace = existing.projectWorkspaceId ? await db - .select({ - cwd: projectWorkspaces.cwd, - cleanupCommand: projectWorkspaces.cleanupCommand, - }) - .from(projectWorkspaces) + .select({ + cwd: projectWorkspaces.cwd, + cleanupCommand: projectWorkspaces.cleanupCommand, + }) + .from(projectWorkspaces) .where( - and( - eq(projectWorkspaces.id, existing.projectWorkspaceId), - eq(projectWorkspaces.companyId, existing.companyId), - ), - ) - .then((rows) => rows[0] ?? null) + and( + eq(projectWorkspaces.id, existing.projectWorkspaceId), + eq(projectWorkspaces.companyId, existing.companyId), + ), + ) + .then((rows) => rows[0] ?? null) : null; const projectPolicy = existing.projectId ? await db - .select({ - executionWorkspacePolicy: projects.executionWorkspacePolicy, - }) - .from(projects) - .where(and(eq(projects.id, existing.projectId), eq(projects.companyId, existing.companyId))) - .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)) + .select({ + executionWorkspacePolicy: projects.executionWorkspacePolicy, + }) + .from(projects) + .where(and(eq(projects.id, existing.projectId), eq(projects.companyId, existing.companyId))) + .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)) : null; const cleanupResult = await cleanupExecutionWorkspaceArtifacts({ workspace: existing, @@ -588,17 +613,5 @@ export function executionWorkspaceRoutes(db: Db) { res.json(workspace); }); - router.get("/execution-workspaces/:id/files", async (req, res) => { - const id = req.params.id as string; - const workspace = await svc.getById(id); - if (!workspace) { - res.status(404).json({ error: "Execution workspace not found" }); - return; - } - assertCompanyAccess(req, workspace.companyId); - const files = await svc.listFiles(id); - res.json(files); - }); - return router; } diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index c3e2292..5f1d6d1 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -1,12 +1,33 @@ +import { timingSafeEqual } from "node:crypto"; import { Router } from "express"; import type { Db } from "@taskcore/db"; import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import { heartbeatRuns, instanceUserRoles, invites } from "@taskcore/db"; import type { DeploymentExposure, DeploymentMode } from "@taskcore/shared"; import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js"; +import { logger } from "../middleware/logger.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { serverVersion } from "../version.js"; +function shouldExposeFullHealthDetails( + actorType: "none" | "board" | "agent" | null | undefined, + deploymentMode: DeploymentMode, +) { + if (deploymentMode !== "authenticated") return true; + return actorType === "board" || actorType === "agent"; +} + +function hasDevServerStatusToken(providedToken: string | undefined) { + const expectedToken = process.env.TASKCORE_DEV_SERVER_STATUS_TOKEN?.trim(); + const token = providedToken?.trim(); + if (!expectedToken || !token) return false; + + const expected = Buffer.from(expectedToken); + const provided = Buffer.from(token); + if (expected.length !== provided.length) return false; + return timingSafeEqual(expected, provided); +} + export function healthRoutes( db?: Db, opts: { @@ -15,27 +36,40 @@ export function healthRoutes( authReady: boolean; companyDeletionEnabled: boolean; } = { - deploymentMode: "local_trusted", - deploymentExposure: "private", - authReady: true, - companyDeletionEnabled: true, - }, + deploymentMode: "local_trusted", + deploymentExposure: "private", + authReady: true, + companyDeletionEnabled: true, + }, ) { const router = Router(); - router.get("/", async (_req, res) => { + router.get("/", async (req, res) => { + const actorType = "actor" in req ? req.actor?.type : null; + const exposeFullDetails = shouldExposeFullHealthDetails( + actorType, + opts.deploymentMode, + ); + const exposeDevServerDetails = + exposeFullDetails || hasDevServerStatusToken(req.get("x-taskcore-dev-server-status-token")); + if (!db) { - res.json({ status: "ok", version: serverVersion }); + res.json( + exposeFullDetails + ? { status: "ok", version: serverVersion } + : { status: "ok", deploymentMode: opts.deploymentMode }, + ); return; } try { await db.execute(sql`SELECT 1`); - } catch { + } catch (error) { + logger.warn({ err: error }, "Health check database probe failed"); res.status(503).json({ status: "unhealthy", version: serverVersion, - error: "database_unreachable", + error: "database_unreachable" }); return; } @@ -70,7 +104,7 @@ export function healthRoutes( const persistedDevServerStatus = readPersistedDevServerStatus(); let devServer: ReturnType | undefined; - if (persistedDevServerStatus) { + if (exposeDevServerDetails && persistedDevServerStatus && typeof (db as { select?: unknown }).select === "function") { const instanceSettings = instanceSettingsService(db); const experimentalSettings = await instanceSettings.getExperimental(); const activeRunCount = await db @@ -85,6 +119,17 @@ export function healthRoutes( }); } + if (!exposeFullDetails) { + res.json({ + status: "ok", + deploymentMode: opts.deploymentMode, + bootstrapStatus, + bootstrapInviteActive, + ...(devServer ? { devServer } : {}), + }); + return; + } + res.json({ status: "ok", version: serverVersion, diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 4d4472b..8d954a9 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -17,4 +17,4 @@ export { inboxDismissalRoutes } from "./inbox-dismissals.js"; export { llmRoutes } from "./llms.js"; export { accessRoutes } from "./access.js"; export { instanceSettingsRoutes } from "./instance-settings.js"; -export { onboardingRoutes } from "./onboarding.js"; +export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js"; diff --git a/server/src/routes/instance-database-backups.ts b/server/src/routes/instance-database-backups.ts new file mode 100644 index 0000000..1277108 --- /dev/null +++ b/server/src/routes/instance-database-backups.ts @@ -0,0 +1,30 @@ +import { Router } from "express"; +import type { BackupRetentionPolicy, RunDatabaseBackupResult } from "@taskcore/db"; +import { assertInstanceAdmin } from "./authz.js"; + +export type InstanceDatabaseBackupTrigger = "manual" | "scheduled"; + +export type InstanceDatabaseBackupRunResult = RunDatabaseBackupResult & { + trigger: InstanceDatabaseBackupTrigger; + backupDir: string; + retention: BackupRetentionPolicy; + startedAt: string; + finishedAt: string; + durationMs: number; +}; + +export type InstanceDatabaseBackupService = { + runManualBackup(): Promise; +}; + +export function instanceDatabaseBackupRoutes(service: InstanceDatabaseBackupService) { + const router = Router(); + + router.post("/instance/database-backups", async (req, res) => { + assertInstanceAdmin(req); + const result = await service.runManualBackup(); + res.status(201).json(result); + }); + + return router; +} diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index e28a53b..8ea11d4 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -4,7 +4,7 @@ import { patchInstanceExperimentalSettingsSchema, patchInstanceGeneralSettingsSc import { forbidden } from "../errors.js"; import { validate } from "../middleware/validate.js"; import { instanceSettingsService, logActivity } from "../services/index.js"; -import { getActorInfo } from "./authz.js"; +import { assertBoardOrgAccess, getActorInfo } from "./authz.js"; function assertCanManageInstanceSettings(req: Request) { if (req.actor.type !== "board") { @@ -22,10 +22,8 @@ export function instanceSettingsRoutes(db: Db) { router.get("/instance/settings/general", async (req, res) => { // 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"); - } + // authenticated org member or instance admin. Only PATCH requires instance-admin. + assertBoardOrgAccess(req); res.json(await svc.getGeneral()); }); @@ -60,11 +58,9 @@ export function instanceSettingsRoutes(db: Db) { ); router.get("/instance/settings/experimental", async (req, res) => { - // Experimental settings are readable by any authenticated board user. - // Only PATCH requires instance-admin. - if (req.actor.type !== "board") { - throw forbidden("Board access required"); - } + // Experimental settings are readable by any authenticated org member + // or instance admin. Only PATCH requires instance-admin. + assertBoardOrgAccess(req); res.json(await svc.getExperimental()); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 56fa74c..0dc76a8 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -6,10 +6,13 @@ import type { Db } from "@taskcore/db"; import { issueExecutionDecisions } from "@taskcore/db"; import { addIssueCommentSchema, + acceptIssueThreadInteractionSchema, createIssueAttachmentMetadataSchema, + createIssueThreadInteractionSchema, createIssueWorkProductSchema, createIssueLabelSchema, checkoutIssueSchema, + createChildIssueSchema, createIssueSchema, feedbackTargetTypeSchema, feedbackTraceStatusSchema, @@ -17,7 +20,10 @@ import { upsertIssueFeedbackVoteSchema, linkIssueApprovalSchema, issueDocumentKeySchema, + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + rejectIssueThreadInteractionSchema, restoreIssueDocumentRevisionSchema, + respondIssueThreadInteractionSchema, updateIssueWorkProductSchema, upsertIssueDocumentSchema, updateIssueSchema, @@ -38,7 +44,12 @@ import { heartbeatService, instanceSettingsService, issueApprovalService, + issueThreadInteractionService, + ISSUE_LIST_DEFAULT_LIMIT, + ISSUE_LIST_MAX_LIMIT, + issueReferenceService, issueService, + clampIssueListLimit, documentService, logActivity, projectService, @@ -47,7 +58,11 @@ import { } from "../services/index.js"; import { logger } from "../middleware/logger.js"; import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js"; -import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { + assertNoAgentHostWorkspaceCommandMutation, + collectIssueWorkspaceCommandPaths, +} from "./workspace-command-authz.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; import { isInlineAttachmentContentType, @@ -124,6 +139,23 @@ function summarizeIssueRelationForActivity(relation: { }; } +function summarizeIssueReferenceActivityDetails(input: + | { + addedReferencedIssues: ActivityIssueRelationSummary[]; + removedReferencedIssues: ActivityIssueRelationSummary[]; + currentReferencedIssues: ActivityIssueRelationSummary[]; + } + | null + | undefined, +) { + if (!input) return {}; + return { + ...(input.addedReferencedIssues.length > 0 ? { addedReferencedIssues: input.addedReferencedIssues } : {}), + ...(input.removedReferencedIssues.length > 0 ? { removedReferencedIssues: input.removedReferencedIssues } : {}), + ...(input.currentReferencedIssues.length > 0 ? { currentReferencedIssues: input.currentReferencedIssues } : {}), + }; +} + function activityExecutionParticipantKey(participant: ActivityExecutionParticipant): string { return participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`; } @@ -146,18 +178,77 @@ function isClosedIssueStatus(status: string | null | undefined): status is "done return status === "done" || status === "cancelled"; } -function shouldImplicitlyReopenCommentForAgent(input: { +function shouldImplicitlyMoveCommentedIssueToTodoForAgent(input: { issueStatus: string | null | undefined; assigneeAgentId: string | null | undefined; actorType: "agent" | "user"; actorId: string; }) { - if (!isClosedIssueStatus(input.issueStatus)) return false; + if (!isClosedIssueStatus(input.issueStatus) && input.issueStatus !== "blocked") return false; if (typeof input.assigneeAgentId !== "string" || input.assigneeAgentId.length === 0) return false; if (input.actorType === "agent" && input.actorId === input.assigneeAgentId) return false; return true; } +function queueResolvedInteractionContinuationWakeup(input: { + heartbeat: ReturnType; + issue: { id: string; assigneeAgentId: string | null; status: string }; + interaction: { + id: string; + kind: string; + status: string; + continuationPolicy: string; + sourceCommentId?: string | null; + sourceRunId?: string | null; + }; + actor: { actorType: "user" | "agent"; actorId: string }; + source: string; +}) { + if ( + input.interaction.continuationPolicy !== "wake_assignee" + && input.interaction.continuationPolicy !== "wake_assignee_on_accept" + ) return; + if ( + input.interaction.continuationPolicy === "wake_assignee_on_accept" + && input.interaction.status !== "accepted" + ) return; + if (input.interaction.status === "expired") return; + if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return; + + void input.heartbeat.wakeup(input.issue.assigneeAgentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { + issueId: input.issue.id, + interactionId: input.interaction.id, + interactionKind: input.interaction.kind, + interactionStatus: input.interaction.status, + sourceCommentId: input.interaction.sourceCommentId ?? null, + sourceRunId: input.interaction.sourceRunId ?? null, + mutation: "interaction", + }, + requestedByActorType: input.actor.actorType, + requestedByActorId: input.actor.actorId, + contextSnapshot: { + issueId: input.issue.id, + taskId: input.issue.id, + interactionId: input.interaction.id, + interactionKind: input.interaction.kind, + interactionStatus: input.interaction.status, + sourceCommentId: input.interaction.sourceCommentId ?? null, + sourceRunId: input.interaction.sourceRunId ?? null, + wakeReason: "issue_commented", + source: input.source, + }, + }).catch((err) => logger.warn({ + err, + issueId: input.issue.id, + interactionId: input.interaction.id, + agentId: input.issue.assigneeAgentId, + }, "failed to wake assignee on issue interaction resolution")); +} + function diffExecutionParticipants( previousPolicy: NormalizedExecutionPolicy | null, nextPolicy: NormalizedExecutionPolicy | null, @@ -305,6 +396,7 @@ export function issueRoutes( const executionWorkspacesSvc = executionWorkspaceService(db); const workProductsSvc = workProductService(db); const documentsSvc = documentService(db); + const issueReferencesSvc = issueReferenceService(db); const routinesSvc = routineService(db); const feedbackExportService = opts?.feedbackExportService; const upload = multer({ @@ -323,6 +415,34 @@ export function issueRoutes( return value === true || value === "true" || value === "1"; } + async function logExpiredRequestConfirmations(input: { + issue: { id: string; companyId: string; identifier?: string | null }; + interactions: Array<{ id: string; kind: string; status: string; result?: unknown }>; + actor: ReturnType; + source: string; + }) { + for (const interaction of input.interactions) { + await logActivity(db, { + companyId: input.issue.companyId, + actorType: input.actor.actorType, + actorId: input.actor.actorId, + agentId: input.actor.agentId, + runId: input.actor.runId, + action: "issue.thread_interaction_expired", + entityType: "issue", + entityId: input.issue.id, + details: { + identifier: input.issue.identifier ?? null, + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + source: input.source, + result: interaction.result ?? null, + }, + }); + } + } + function parseDateQuery(value: unknown, field: string) { if (typeof value !== "string" || value.trim().length === 0) return undefined; const parsed = new Date(value); @@ -398,7 +518,39 @@ export function issueRoutes( return null; } - async function assertAgentRunCheckoutOwnership( + async function hasActiveCheckoutManagementOverride( + actorAgentId: string, + companyId: string, + assigneeAgentId: string, + ) { + const allowedByGrant = await access.hasPermission( + companyId, + "agent", + actorAgentId, + "tasks:manage_active_checkouts", + ); + if (allowedByGrant) return true; + + const companyAgents = await agentsSvc.list(companyId); + const agentsById = new Map(companyAgents.map((agent) => [agent.id, agent])); + const actorAgent = agentsById.get(actorAgentId); + if (!actorAgent) return false; + if (canCreateAgentsLegacy(actorAgent)) return true; + + // Reporting-chain managers may intervene in an agent's active checkout + // without taking the task over. Peers must own the checkout/run first. + let cursor: string | null = assigneeAgentId; + for (let depth = 0; cursor && depth < 50; depth += 1) { + const assignee = agentsById.get(cursor); + if (!assignee) return false; + if (assignee.reportsTo === actorAgentId) return true; + cursor = assignee.reportsTo; + } + + return false; + } + + async function assertAgentIssueMutationAllowed( req: Request, res: Response, issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null }, @@ -409,9 +561,23 @@ export function issueRoutes( res.status(403).json({ error: "Agent authentication required" }); return false; } - if (issue.status !== "in_progress" || issue.assigneeAgentId !== actorAgentId) { + if (issue.status !== "in_progress" || issue.assigneeAgentId === null) { return true; } + if (issue.assigneeAgentId !== actorAgentId) { + if (await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)) { + return true; + } + res.status(409).json({ + error: "Issue is checked out by another agent", + details: { + issueId: issue.id, + assigneeAgentId: issue.assigneeAgentId, + actorAgentId, + }, + }); + return false; + } const runId = requireAgentRunId(req, res); if (!runId) return false; const ownership = await svc.assertCheckoutOwner(issue.id, actorAgentId, runId); @@ -447,9 +613,9 @@ export function issueRoutes( const activeRun = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId); const activeIssueId = activeRun && - activeRun.contextSnapshot && - typeof activeRun.contextSnapshot === "object" && - typeof (activeRun.contextSnapshot as Record).issueId === "string" + activeRun.contextSnapshot && + typeof activeRun.contextSnapshot === "object" && + typeof (activeRun.contextSnapshot as Record).issueId === "string" ? ((activeRun.contextSnapshot as Record).issueId as string) : null; if (activeRun && activeRun.status === "running" && activeIssueId === issue.id) { @@ -612,8 +778,10 @@ export function issueRoutes( ? req.actor.userId : unreadForUserFilterRaw; const rawLimit = req.query.limit as string | undefined; - const parsedLimit = rawLimit ? Number.parseInt(rawLimit, 10) : null; - const limit = parsedLimit ?? undefined; + const parsedLimit = rawLimit !== undefined && /^\d+$/.test(rawLimit) + ? Number.parseInt(rawLimit, 10) + : null; + const limit = parsedLimit === null ? ISSUE_LIST_DEFAULT_LIMIT : clampIssueListLimit(parsedLimit); if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) { res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); @@ -632,7 +800,7 @@ export function issueRoutes( return; } if (rawLimit !== undefined && (parsedLimit === null || !Number.isInteger(parsedLimit) || parsedLimit <= 0)) { - res.status(400).json({ error: "limit must be a positive integer" }); + res.status(400).json({ error: `limit must be a positive integer up to ${ISSUE_LIST_MAX_LIMIT}` }); return; } @@ -645,6 +813,7 @@ export function issueRoutes( inboxArchivedByUserId, unreadForUserId, projectId: req.query.projectId as string | undefined, + workspaceId: req.query.workspaceId as string | undefined, executionWorkspaceId: req.query.executionWorkspaceId as string | undefined, parentId: req.query.parentId as string | undefined, labelId: req.query.labelId as string | undefined, @@ -652,6 +821,8 @@ export function issueRoutes( originId: req.query.originId as string | undefined, includeRoutineExecutions: req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1", + excludeRoutineExecutions: + req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1", q: req.query.q as string | undefined, limit, }); @@ -712,43 +883,6 @@ export function issueRoutes( res.json(removed); }); - router.get("/issues/:id", async (req, res) => { - const id = req.params.id as string; - const issue = await svc.getById(id); - if (!issue) { - res.status(404).json({ error: "Issue not found" }); - return; - } - assertCompanyAccess(req, issue.companyId); - const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations] = await Promise.all([ - resolveIssueProjectAndGoal(issue), - svc.getAncestors(issue.id), - svc.findMentionedProjectIds(issue.id), - documentsSvc.getIssueDocumentPayload(issue), - svc.getRelationSummaries(issue.id), - ]); - const mentionedProjects = mentionedProjectIds.length > 0 - ? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds) - : []; - const currentExecutionWorkspace = issue.executionWorkspaceId - ? await executionWorkspacesSvc.getById(issue.executionWorkspaceId) - : null; - const workProducts = await workProductsSvc.listForIssue(issue.id); - res.json({ - ...issue, - goalId: goal?.id ?? issue.goalId, - ancestors, - blockedBy: relations.blockedBy, - blocks: relations.blocks, - ...documentPayload, - project: project ?? null, - goal: goal ?? null, - mentionedProjects, - currentExecutionWorkspace, - workProducts, - }); - }); - router.get("/issues/:id/heartbeat-context", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); @@ -763,7 +897,19 @@ export function issueRoutes( ? req.query.wakeCommentId.trim() : null; - const [{ project, goal }, ancestors, commentCursor, wakeComment, relations, attachments] = + const currentExecutionWorkspacePromise = issue.executionWorkspaceId + ? executionWorkspacesSvc.getById(issue.executionWorkspaceId) + : Promise.resolve(null); + const [ + { project, goal }, + ancestors, + commentCursor, + wakeComment, + relations, + attachments, + continuationSummary, + currentExecutionWorkspace, + ] = await Promise.all([ resolveIssueProjectAndGoal(issue), svc.getAncestors(issue.id), @@ -771,6 +917,8 @@ export function issueRoutes( wakeCommentId ? svc.getComment(wakeCommentId) : null, svc.getRelationSummaries(issue.id), svc.listAttachments(issue.id), + documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY), + currentExecutionWorkspacePromise, ]); res.json({ @@ -799,20 +947,20 @@ export function issueRoutes( })), project: project ? { - id: project.id, - name: project.name, - status: project.status, - targetDate: project.targetDate, - } + id: project.id, + name: project.name, + status: project.status, + targetDate: project.targetDate, + } : null, goal: goal ? { - id: goal.id, - title: goal.title, - status: goal.status, - level: goal.level, - parentId: goal.parentId, - } + id: goal.id, + title: goal.title, + status: goal.status, + level: goal.level, + parentId: goal.parentId, + } : null, commentCursor, wakeComment: @@ -827,6 +975,57 @@ export function issueRoutes( contentPath: withContentPath(a).contentPath, createdAt: a.createdAt, })), + continuationSummary: continuationSummary + ? { + key: continuationSummary.key, + title: continuationSummary.title, + body: continuationSummary.body, + latestRevisionId: continuationSummary.latestRevisionId, + latestRevisionNumber: continuationSummary.latestRevisionNumber, + updatedAt: continuationSummary.updatedAt, + } + : null, + currentExecutionWorkspace, + }); + }); + + router.get("/issues/:id", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations, referenceSummary] = await Promise.all([ + resolveIssueProjectAndGoal(issue), + svc.getAncestors(issue.id), + svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }), + documentsSvc.getIssueDocumentPayload(issue), + svc.getRelationSummaries(issue.id), + issueReferencesSvc.listIssueReferenceSummary(issue.id), + ]); + const mentionedProjects = mentionedProjectIds.length > 0 + ? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds) + : []; + const currentExecutionWorkspace = issue.executionWorkspaceId + ? await executionWorkspacesSvc.getById(issue.executionWorkspaceId) + : null; + const workProducts = await workProductsSvc.listForIssue(issue.id); + res.json({ + ...issue, + goalId: goal?.id ?? issue.goalId, + ancestors, + blockedBy: relations.blockedBy, + blocks: relations.blocks, + relatedWork: referenceSummary, + referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id), + ...documentPayload, + project: project ?? null, + goal: goal ?? null, + mentionedProjects, + currentExecutionWorkspace, + workProducts, }); }); @@ -850,7 +1049,9 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); - const docs = await documentsSvc.listIssueDocuments(issue.id); + const docs = await documentsSvc.listIssueDocuments(issue.id, { + includeSystem: req.query.includeSystem === "true", + }); res.json(docs); }); @@ -883,6 +1084,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); if (!keyParsed.success) { res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); @@ -890,6 +1092,7 @@ export function issueRoutes( } const actor = getActorInfo(req); + const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const result = await documentsSvc.upsertIssueDocument({ issueId: issue.id, key: keyParsed.data, @@ -903,6 +1106,9 @@ export function issueRoutes( createdByRunId: actor.runId ?? null, }); const doc = result.document; + await issueReferencesSvc.syncDocument(doc.id); + const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); + const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter); await logActivity(db, { companyId: issue.companyId, @@ -919,9 +1125,36 @@ export function issueRoutes( title: doc.title, format: doc.format, revisionNumber: doc.latestRevisionNumber, + ...summarizeIssueReferenceActivityDetails({ + addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), + removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), + currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), + }), }, }); + if (!result.created) { + const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( + issue, + { + id: doc.id, + key: doc.key, + latestRevisionId: doc.latestRevisionId, + latestRevisionNumber: doc.latestRevisionNumber, + }, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + ); + await logExpiredRequestConfirmations({ + issue, + interactions: expiredInteractions, + actor, + source: "issue.document_updated", + }); + } + res.status(result.created ? 201 : 200).json(doc); }); @@ -954,6 +1187,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); if (!keyParsed.success) { res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); @@ -961,6 +1195,7 @@ export function issueRoutes( } const actor = getActorInfo(req); + const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const result = await documentsSvc.restoreIssueDocumentRevision({ issueId: issue.id, key: keyParsed.data, @@ -968,6 +1203,9 @@ export function issueRoutes( createdByAgentId: actor.agentId ?? null, createdByUserId: actor.actorType === "user" ? actor.actorId : null, }); + await issueReferencesSvc.syncDocument(result.document.id); + const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); + const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter); await logActivity(db, { companyId: issue.companyId, @@ -986,7 +1224,32 @@ export function issueRoutes( revisionNumber: result.document.latestRevisionNumber, restoredFromRevisionId: result.restoredFromRevisionId, restoredFromRevisionNumber: result.restoredFromRevisionNumber, + ...summarizeIssueReferenceActivityDetails({ + addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), + removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), + currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), + }), + }, + }); + + const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( + issue, + { + id: result.document.id, + key: result.document.key, + latestRevisionId: result.document.latestRevisionId, + latestRevisionNumber: result.document.latestRevisionNumber, + }, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, }, + ); + await logExpiredRequestConfirmations({ + issue, + interactions: expiredInteractions, + actor, + source: "issue.document_restored", }); res.json(result.document); @@ -1010,11 +1273,15 @@ export function issueRoutes( res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); return; } + const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const removed = await documentsSvc.deleteIssueDocument(issue.id, keyParsed.data); if (!removed) { res.status(404).json({ error: "Document not found" }); return; } + await issueReferencesSvc.deleteDocumentSource(removed.id); + const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); + const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter); const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, @@ -1029,8 +1296,32 @@ export function issueRoutes( key: removed.key, documentId: removed.id, title: removed.title, + ...summarizeIssueReferenceActivityDetails({ + addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), + removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), + currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), + }), }, }); + const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( + issue, + { + id: removed.id, + key: removed.key, + latestRevisionId: null, + latestRevisionNumber: null, + }, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + ); + await logExpiredRequestConfirmations({ + issue, + interactions: expiredInteractions, + actor, + source: "issue.document_deleted", + }); res.json({ ok: true }); }); @@ -1042,6 +1333,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, { ...req.body, projectId: req.body.projectId ?? issue.projectId ?? null, @@ -1073,6 +1365,12 @@ export function issueRoutes( return; } assertCompanyAccess(req, existing.companyId); + const issue = await svc.getById(existing.issueId); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const product = await workProductsSvc.update(id, req.body); if (!product) { res.status(404).json({ error: "Work product not found" }); @@ -1101,6 +1399,12 @@ export function issueRoutes( return; } assertCompanyAccess(req, existing.companyId); + const issue = await svc.getById(existing.issueId); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const removed = await workProductsSvc.remove(id); if (!removed) { res.status(404).json({ error: "Work product not found" }); @@ -1268,6 +1572,8 @@ export function issueRoutes( res.status(404).json({ error: "Issue not found" }); return; } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return; const actor = getActorInfo(req); @@ -1300,6 +1606,8 @@ export function issueRoutes( res.status(404).json({ error: "Issue not found" }); return; } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return; await issueApprovalsSvc.unlink(id, approvalId); @@ -1323,6 +1631,7 @@ export function issueRoutes( router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (req.body.assigneeAgentId || req.body.assigneeUserId) { await assertCanAssignTasks(req, companyId); } @@ -1335,6 +1644,12 @@ export function issueRoutes( createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, }); + await issueReferencesSvc.syncIssue(issue.id); + const referenceSummary = await issueReferencesSvc.listIssueReferenceSummary(issue.id); + const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary( + issueReferencesSvc.emptySummary(), + referenceSummary, + ); await logActivity(db, { companyId, @@ -1349,6 +1664,11 @@ export function issueRoutes( title: issue.title, identifier: issue.identifier, ...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}), + ...summarizeIssueReferenceActivityDetails({ + addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), + removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), + currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), + }), }, }); @@ -1362,6 +1682,66 @@ export function issueRoutes( requestedByActorId: actor.actorId, }); + res.status(201).json({ + ...issue, + relatedWork: referenceSummary, + referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id), + }); + }); + + router.post("/issues/:id/children", validate(createChildIssueSchema), async (req, res) => { + const parentId = req.params.id as string; + const parent = await svc.getById(parentId); + if (!parent) { + res.status(404).json({ error: "Parent issue not found" }); + return; + } + assertCompanyAccess(req, parent.companyId); + assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); + if (req.body.assigneeAgentId || req.body.assigneeUserId) { + await assertCanAssignTasks(req, parent.companyId); + } + + const actor = getActorInfo(req); + const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy); + const { issue, parentBlockerAdded } = await svc.createChild(parent.id, { + ...req.body, + executionPolicy, + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + actorAgentId: actor.agentId, + actorUserId: actor.actorType === "user" ? actor.actorId : null, + }); + + await logActivity(db, { + companyId: parent.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.child_created", + entityType: "issue", + entityId: issue.id, + details: { + parentId: parent.id, + identifier: issue.identifier, + title: issue.title, + inheritedExecutionWorkspaceFromIssueId: parent.id, + ...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}), + ...(parentBlockerAdded ? { parentBlockerAdded: true } : {}), + }, + }); + + void queueIssueAssignmentWakeup({ + heartbeat, + issue, + reason: "issue_assigned", + mutation: "create", + contextSource: "issue.child_create", + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + }); + res.status(201).json(issue); }); @@ -1373,14 +1753,17 @@ export function issueRoutes( return; } assertCompanyAccess(req, existing.companyId); - if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return; + assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); + if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return; const actor = getActorInfo(req); const isClosed = isClosedIssueStatus(existing.status); + const isBlocked = existing.status === "blocked"; const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference( existing.companyId, req.body.assigneeAgentId as string | null | undefined, ); + const titleOrDescriptionChanged = req.body.title !== undefined || req.body.description !== undefined; const existingRelations = Array.isArray(req.body.blockedByIssueIds) ? await svc.getRelationSummaries(existing.id) @@ -1394,15 +1777,22 @@ export function issueRoutes( } = req.body; const requestedAssigneeAgentId = normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId; - const effectiveReopenRequested = + const effectiveMoveToTodoRequested = reopenRequested || (!!commentBody && - shouldImplicitlyReopenCommentForAgent({ + shouldImplicitlyMoveCommentedIssueToTodoForAgent({ issueStatus: existing.status, assigneeAgentId: requestedAssigneeAgentId, actorType: actor.actorType, actorId: actor.actorId, })); + const updateReferenceSummaryBefore = titleOrDescriptionChanged + ? await issueReferencesSvc.listIssueReferenceSummary(existing.id) + : null; + const hasUnresolvedFirstClassBlockers = + isBlocked && effectiveMoveToTodoRequested + ? (await svc.getDependencyReadiness(existing.id)).unresolvedBlockerCount > 0 + : false; let interruptedRunId: string | null = null; const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing); const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0; @@ -1445,7 +1835,12 @@ export function issueRoutes( if (hiddenAtRaw !== undefined) { updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; } - if (commentBody && effectiveReopenRequested && isClosed && updateFields.status === undefined) { + if ( + commentBody && + effectiveMoveToTodoRequested && + (isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers)) && + updateFields.status === undefined + ) { updateFields.status = "todo"; } if (req.body.executionPolicy !== undefined) { @@ -1574,7 +1969,21 @@ export function issueRoutes( res.status(404).json({ error: "Issue not found" }); return; } - let issueResponse: typeof issue & { blockedBy?: unknown; blocks?: unknown } = issue; + if (titleOrDescriptionChanged) { + await issueReferencesSvc.syncIssue(issue.id); + } + const updateReferenceSummaryAfter = titleOrDescriptionChanged + ? await issueReferencesSvc.listIssueReferenceSummary(issue.id) + : null; + const updateReferenceDiff = updateReferenceSummaryBefore && updateReferenceSummaryAfter + ? issueReferencesSvc.diffIssueReferenceSummary(updateReferenceSummaryBefore, updateReferenceSummaryAfter) + : null; + let issueResponse: typeof issue & { + blockedBy?: unknown; + blocks?: unknown; + relatedWork?: Awaited>; + referencedIssueIdentifiers?: string[]; + } = issue; let updatedRelations: Awaited> | null = null; if (issue && Array.isArray(req.body.blockedByIssueIds)) { updatedRelations = await svc.getRelationSummaries(issue.id); @@ -1605,8 +2014,8 @@ export function issueRoutes( const hasFieldChanges = Object.keys(previous).length > 0; const reopened = commentBody && - effectiveReopenRequested && - isClosed && + effectiveMoveToTodoRequested && + (isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers)) && previous.status !== undefined && issue.status === "todo"; const reopenFromStatus = reopened ? existing.status : null; @@ -1626,6 +2035,15 @@ export function issueRoutes( ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), _previous: hasFieldChanges ? previous : undefined, + ...summarizeIssueReferenceActivityDetails( + updateReferenceDiff + ? { + addedReferencedIssues: updateReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), + removedReferencedIssues: updateReferenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), + currentReferencedIssues: updateReferenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), + } + : null, + ), }, }); @@ -1721,11 +2139,26 @@ export function issueRoutes( let comment = null; if (commentBody) { + const commentReferenceSummaryBefore = updateReferenceSummaryAfter + ?? await issueReferencesSvc.listIssueReferenceSummary(issue.id); comment = await svc.addComment(id, commentBody, { agentId: actor.agentId ?? undefined, userId: actor.actorType === "user" ? actor.actorId : undefined, runId: actor.runId, }); + await issueReferencesSvc.syncComment(comment.id); + const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); + const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary( + commentReferenceSummaryBefore, + commentReferenceSummaryAfter, + ); + issueResponse = { + ...issueResponse, + relatedWork: commentReferenceSummaryAfter, + referencedIssueIdentifiers: commentReferenceSummaryAfter.outbound.map( + (item) => item.issue.identifier ?? item.issue.id, + ), + }; await logActivity(db, { companyId: issue.companyId, @@ -1744,10 +2177,39 @@ export function issueRoutes( ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), ...(hasFieldChanges ? { updated: true } : {}), + ...summarizeIssueReferenceActivityDetails({ + addedReferencedIssues: commentReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), + removedReferencedIssues: commentReferenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), + currentReferencedIssues: commentReferenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), + }), }, }); + const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment( + issue, + comment, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + ); + await logExpiredRequestConfirmations({ + issue, + interactions: expiredInteractions, + actor, + source: "issue.comment", + }); + + } else if (updateReferenceSummaryAfter) { + issueResponse = { + ...issueResponse, + relatedWork: updateReferenceSummaryAfter, + referencedIssueIdentifiers: updateReferenceSummaryAfter.outbound.map( + (item) => item.issue.identifier ?? item.issue.id, + ), + }; } + const assigneeChanged = issue.assigneeAgentId !== existing.assigneeAgentId || issue.assigneeUserId !== existing.assigneeUserId; const statusChangedFromBacklog = @@ -1757,7 +2219,7 @@ export function issueRoutes( const statusChangedFromBlockedToTodo = existing.status === "blocked" && issue.status === "todo" && - req.body.status !== undefined; + (req.body.status !== undefined || reopened); const previousExecutionState = parseIssueExecutionState(existing.executionState); const nextExecutionState = parseIssueExecutionState(issue.executionState); const executionStageWakeup = buildExecutionStageWakeup({ @@ -1800,10 +2262,10 @@ export function issueRoutes( issueId: issue.id, ...(comment ? { - taskId: issue.id, - commentId: comment.id, - wakeCommentId: comment.id, - } + taskId: issue.id, + commentId: comment.id, + wakeCommentId: comment.id, + } : {}), source: "issue.update", ...(interruptedRunId ? { interruptedRunId } : {}), @@ -1932,6 +2394,8 @@ export function issueRoutes( issueId: parent.id, completedChildIssueId: issue.id, childIssueIds: parent.childIssueIds, + childIssueSummaries: parent.childIssueSummaries, + childIssueSummaryTruncated: parent.childIssueSummaryTruncated, }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, @@ -1942,6 +2406,8 @@ export function issueRoutes( source: "issue.children_completed", completedChildIssueId: issue.id, childIssueIds: parent.childIssueIds, + childIssueSummaries: parent.childIssueSummaries, + childIssueSummaryTruncated: parent.childIssueSummaryTruncated, }, }); } @@ -1965,6 +2431,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, existing.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return; const attachments = await svc.listAttachments(id); const issue = await svc.remove(id); @@ -2078,7 +2545,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, existing.companyId); - if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return; + if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return; const actorRunId = requireAgentRunId(req, res); if (req.actor.type === "agent" && !actorRunId) return; @@ -2141,6 +2608,269 @@ export function issueRoutes( res.json(comments); }); + router.get("/issues/:id/interactions", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + const interactions = await issueThreadInteractionService(db).listForIssue(id); + res.json(interactions); + }); + + router.post("/issues/:id/interactions", validate(createIssueThreadInteractionSchema), async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (req.actor.type === "agent") { + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + } else { + assertBoard(req); + } + + const actor = getActorInfo(req); + const agentSourceRunId = req.actor.type === "agent" ? requireAgentRunId(req, res) : null; + if (req.actor.type === "agent" && !agentSourceRunId) return; + + const interaction = await issueThreadInteractionService(db).create(issue, { + ...req.body, + sourceRunId: req.actor.type === "agent" ? agentSourceRunId : req.body.sourceRunId ?? null, + }, { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }); + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.thread_interaction_created", + entityType: "issue", + entityId: issue.id, + details: { + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + continuationPolicy: interaction.continuationPolicy, + }, + }); + + res.status(201).json(interaction); + }); + + router.post( + "/issues/:id/interactions/:interactionId/accept", + validate(acceptIssueThreadInteractionSchema), + async (req, res) => { + const id = req.params.id as string; + const interactionId = req.params.interactionId as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + assertBoard(req); + + const actor = getActorInfo(req); + const { interaction, createdIssues, continuationIssue } = await issueThreadInteractionService(db).acceptInteraction(issue, interactionId, req.body, { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }); + const continuationWakeIssue = continuationIssue ?? issue; + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: interaction.status === "expired" + ? "issue.thread_interaction_expired" + : "issue.thread_interaction_accepted", + entityType: "issue", + entityId: issue.id, + details: { + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + createdTaskCount: + interaction.kind === "suggest_tasks" + ? (interaction.result?.createdTasks?.length ?? 0) + : 0, + skippedTaskCount: + interaction.kind === "suggest_tasks" + ? (interaction.result?.skippedClientKeys?.length ?? 0) + : 0, + }, + }); + + if (continuationIssue) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.updated", + entityType: "issue", + entityId: issue.id, + details: { + identifier: issue.identifier, + status: continuationIssue.status, + assigneeAgentId: continuationIssue.assigneeAgentId ?? null, + assigneeUserId: continuationIssue.assigneeUserId ?? null, + source: "request_confirmation_accept", + interactionId: interaction.id, + _previous: { + status: issue.status, + assigneeAgentId: issue.assigneeAgentId ?? null, + assigneeUserId: issue.assigneeUserId ?? null, + }, + }, + }); + } + + for (const createdIssue of createdIssues) { + void queueIssueAssignmentWakeup({ + heartbeat, + issue: createdIssue, + reason: "issue_assigned", + mutation: "interaction_accept", + contextSource: "issue.interaction.accept", + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + }); + } + + queueResolvedInteractionContinuationWakeup({ + heartbeat, + issue: continuationWakeIssue, + interaction, + actor, + source: "issue.interaction.accept", + }); + + res.json(interaction); + }, + ); + + router.post( + "/issues/:id/interactions/:interactionId/reject", + validate(rejectIssueThreadInteractionSchema), + async (req, res) => { + const id = req.params.id as string; + const interactionId = req.params.interactionId as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + assertBoard(req); + + const actor = getActorInfo(req); + const interaction = await issueThreadInteractionService(db).rejectInteraction(issue, interactionId, req.body, { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }); + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: interaction.status === "expired" + ? "issue.thread_interaction_expired" + : "issue.thread_interaction_rejected", + entityType: "issue", + entityId: issue.id, + details: { + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + rejectionReason: + interaction.kind === "suggest_tasks" + ? (interaction.result?.rejectionReason ?? null) + : interaction.kind === "request_confirmation" + ? (interaction.result?.reason ?? null) + : null, + }, + }); + + queueResolvedInteractionContinuationWakeup({ + heartbeat, + issue, + interaction, + actor, + source: "issue.interaction.reject", + }); + + res.json(interaction); + }, + ); + + router.post( + "/issues/:id/interactions/:interactionId/respond", + validate(respondIssueThreadInteractionSchema), + async (req, res) => { + const id = req.params.id as string; + const interactionId = req.params.interactionId as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + assertBoard(req); + + const actor = getActorInfo(req); + const interaction = await issueThreadInteractionService(db).answerQuestions(issue, interactionId, req.body, { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }); + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.thread_interaction_answered", + entityType: "issue", + entityId: issue.id, + details: { + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + answeredQuestionCount: + interaction.kind === "ask_user_questions" + ? (interaction.result?.answers?.length ?? 0) + : 0, + }, + }); + + queueResolvedInteractionContinuationWakeup({ + heartbeat, + issue, + interaction, + actor, + source: "issue.interaction.respond", + }); + + res.json(interaction); + }, + ); + router.get("/issues/:id/comments/:commentId", async (req, res) => { const id = req.params.id as string; const commentId = req.params.commentId as string; @@ -2167,7 +2897,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); - if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return; + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const comment = await svc.getComment(commentId); if (!comment || comment.issueId !== id) { @@ -2312,7 +3042,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); - if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return; + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue); if (closedExecutionWorkspace) { respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); @@ -2323,20 +3053,26 @@ export function issueRoutes( const reopenRequested = req.body.reopen === true; const interruptRequested = req.body.interrupt === true; const isClosed = isClosedIssueStatus(issue.status); - const effectiveReopenRequested = + const isBlocked = issue.status === "blocked"; + const effectiveMoveToTodoRequested = reopenRequested || - shouldImplicitlyReopenCommentForAgent({ + shouldImplicitlyMoveCommentedIssueToTodoForAgent({ issueStatus: issue.status, assigneeAgentId: issue.assigneeAgentId, actorType: actor.actorType, actorId: actor.actorId, }); + const hasUnresolvedFirstClassBlockers = + isBlocked && effectiveMoveToTodoRequested + ? (await svc.getDependencyReadiness(issue.id)).unresolvedBlockerCount > 0 + : false; let reopened = false; let reopenFromStatus: string | null = null; let interruptedRunId: string | null = null; let currentIssue = issue; + const commentReferenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id); - if (effectiveReopenRequested && isClosed) { + if (effectiveMoveToTodoRequested && (isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers))) { const reopenedIssue = await svc.update(id, { status: "todo" }); if (!reopenedIssue) { res.status(404).json({ error: "Issue not found" }); @@ -2396,6 +3132,12 @@ export function issueRoutes( userId: actor.actorType === "user" ? actor.actorId : undefined, runId: actor.runId, }); + await issueReferencesSvc.syncComment(comment.id); + const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id); + const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary( + commentReferenceSummaryBefore, + commentReferenceSummaryAfter, + ); if (actor.runId) { await heartbeat.reportRunActivity(actor.runId).catch((err) => @@ -2418,7 +3160,27 @@ export function issueRoutes( issueTitle: currentIssue.title, ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), + ...summarizeIssueReferenceActivityDetails({ + addedReferencedIssues: commentReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), + removedReferencedIssues: commentReferenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), + currentReferencedIssues: commentReferenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), + }), + }, + }); + + const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment( + currentIssue, + comment, + { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, }, + ); + await logExpiredRequestConfirmations({ + issue: currentIssue, + interactions: expiredInteractions, + actor, + source: "issue.comment", }); // Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs. @@ -2629,12 +3391,6 @@ export function issueRoutes( res.json(attachments.map(withContentPath)); }); - router.get("/issues/:id/artifacts", async (req, res) => { - const { id: issueId } = req.params; - const artifacts = await svc.listArtifacts(issueId); - res.json(artifacts); - }); - router.post("/companies/:companyId/issues/:issueId/attachments", async (req, res) => { const companyId = req.params.companyId as string; const issueId = req.params.issueId as string; @@ -2648,6 +3404,7 @@ export function issueRoutes( res.status(422).json({ error: "Issue does not belong to company" }); return; } + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; try { await runSingleFileUpload(req, res); @@ -2758,6 +3515,12 @@ export function issueRoutes( return; } assertCompanyAccess(req, attachment.companyId); + const issue = await svc.getById(attachment.issueId); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; try { await storage.deleteObject(attachment.companyId, attachment.objectKey); @@ -2789,56 +3552,5 @@ export function issueRoutes( res.json({ ok: true }); }); - router.get("/artifacts/:artifactId/content", async (req, res, next) => { - const artifactId = req.params.artifactId as string; - const artifact = await svc.getArtifactById(artifactId); - if (!artifact) { - res.status(404).json({ error: "Artifact not found" }); - return; - } - assertCompanyAccess(req, artifact.companyId); - - const object = await storage.getObject(artifact.companyId, artifact.objectKey); - const responseContentType = normalizeContentType(artifact.mimeType || object.contentType); - res.setHeader("Content-Type", responseContentType); - res.setHeader("Content-Length", String(artifact.sizeBytes || object.contentLength || 0)); - res.setHeader("Cache-Control", "private, max-age=60"); - res.setHeader("X-Content-Type-Options", "nosniff"); - - const filename = artifact.title ?? "artifact"; - const disposition = isInlineAttachmentContentType(responseContentType) ? "inline" : "attachment"; - res.setHeader("Content-Disposition", `${disposition}; filename=\"${filename.replaceAll("\"", "")}\"`); - - object.stream.on("error", (err) => { - next(err); - }); - object.stream.pipe(res); - }); - - router.get("/artifacts/:artifactId/download", async (req, res, next) => { - const artifactId = req.params.artifactId as string; - const artifact = await svc.getArtifactById(artifactId); - if (!artifact) { - res.status(404).json({ error: "Artifact not found" }); - return; - } - assertCompanyAccess(req, artifact.companyId); - - const object = await storage.getObject(artifact.companyId, artifact.objectKey); - const responseContentType = normalizeContentType(artifact.mimeType || object.contentType); - res.setHeader("Content-Type", responseContentType); - res.setHeader("Content-Length", String(artifact.sizeBytes || object.contentLength || 0)); - res.setHeader("Cache-Control", "private, max-age=60"); - res.setHeader("X-Content-Type-Options", "nosniff"); - - const filename = artifact.title ?? "artifact"; - res.setHeader("Content-Disposition", `attachment; filename=\"${filename.replaceAll("\"", "")}\"`); - - object.stream.on("error", (err) => { - next(err); - }); - object.stream.pipe(res); - }); - return router; } diff --git a/server/src/routes/llms.ts b/server/src/routes/llms.ts index f5c3d8d..32f0b4d 100644 --- a/server/src/routes/llms.ts +++ b/server/src/routes/llms.ts @@ -78,7 +78,7 @@ export function llmRoutes(db: Db) { .type("text/plain") .send( adapter.agentConfigurationDoc ?? - `# ${adapterType} agent configuration\n\nNo adapter-specific documentation registered.`, + `# ${adapterType} agent configuration\n\nNo adapter-specific documentation registered.`, ); }); diff --git a/server/src/routes/onboarding.ts b/server/src/routes/onboarding.ts deleted file mode 100644 index 90b7019..0000000 --- a/server/src/routes/onboarding.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Router } from "express"; -import { onboardingService } from "../services/onboarding.js"; -import { badRequest } from "../errors.js"; - -export function onboardingRoutes() { - const router = Router(); - const svc = onboardingService(); - - router.post("/recommendation", async (req, res) => { - const { mission } = req.body; - if (typeof mission !== "string" || !mission.trim()) { - throw badRequest("Mission is required"); - } - - const recommendation = await svc.generateRecommendation(mission); - res.json(recommendation); - }); - - return router; -} diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 5d18042..a79f0f0 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -11,7 +11,8 @@ * - Retrieving UI slot contributions for frontend rendering * - Discovering and executing plugin-contributed agent tools * - * All routes require board-level authentication (assertBoard middleware). + * All routes require board-level authentication, and sensitive instance-wide + * mutations such as install/upgrade require instance-admin privileges. * * @module server/routes/plugins * @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification @@ -22,11 +23,19 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import { Router } from "express"; -import type { Request } from "express"; +import type { Request, Response } from "express"; import { and, desc, eq, gte } from "drizzle-orm"; import type { Db } from "@taskcore/db"; -import { companies, pluginLogs, pluginWebhookDeliveries } from "@taskcore/db"; +import { + agents, + companies, + heartbeatRuns, + pluginLogs, + pluginWebhookDeliveries, + projects, +} from "@taskcore/db"; import type { + PluginApiRouteDeclaration, PluginStatus, TaskcorePluginManifestV1, PluginBridgeErrorCode, @@ -40,6 +49,7 @@ import { pluginLifecycleManager } from "../services/plugin-lifecycle.js"; import { getPluginUiContributionMetadata, pluginLoader } from "../services/plugin-loader.js"; import { logActivity } from "../services/activity-log.js"; import { publishGlobalLiveEvent } from "../services/live-events.js"; +import { issueService } from "../services/issues.js"; import type { PluginJobScheduler } from "../services/plugin-job-scheduler.js"; import type { PluginJobStore } from "../services/plugin-job-store.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; @@ -47,8 +57,16 @@ import type { PluginStreamBus } from "../services/plugin-stream-bus.js"; import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js"; import type { ToolRunContext } from "@taskcore/plugin-sdk"; import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@taskcore/plugin-sdk"; -import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { + assertAuthenticated, + assertBoard, + assertBoardOrgAccess, + assertCompanyAccess, + assertInstanceAdmin, + getActorInfo, +} from "./authz.js"; import { validateInstanceConfig } from "../services/plugin-config-validator.js"; +import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js"; /** UI slot declaration extracted from plugin manifest */ type PluginUiSlotDeclaration = NonNullable["slots"]>[number]; @@ -111,6 +129,14 @@ interface PluginHealthCheckResult { const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const PLUGIN_API_BODY_LIMIT_BYTES = 1_000_000; +const PLUGIN_SCOPED_API_RESPONSE_HEADER_ALLOWLIST = new Set([ + "cache-control", + "etag", + "last-modified", + "x-request-id", +]); + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, "../../.."); @@ -139,6 +165,14 @@ const BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [ localPath: "packages/plugins/examples/plugin-kitchen-sink-example", tag: "example", }, + { + packageName: "@taskcore/plugin-orchestration-smoke-example", + pluginKey: "taskcore.plugin-orchestration-smoke-example", + displayName: "Orchestration Smoke (Example)", + description: "Acceptance fixture for scoped plugin routes, restricted database namespaces, issue orchestration, documents, wakeups, summaries, and UI status surfaces.", + localPath: "packages/plugins/examples/plugin-orchestration-smoke-example", + tag: "example", + }, ]; function listBundledPluginExamples(): AvailablePluginExample[] { @@ -245,6 +279,30 @@ export interface PluginRouteBridgeDeps { streamBus?: PluginStreamBus; } +interface PluginScopedApiRequest { + routeKey: string; + method: string; + path: string; + params: Record; + query: Record; + body: unknown; + actor: { + actorType: "user" | "agent"; + actorId: string; + agentId?: string | null; + userId?: string | null; + runId?: string | null; + }; + companyId: string; + headers: Record; +} + +interface PluginScopedApiResponse { + status?: number; + headers?: Record; + body?: unknown; +} + /** Request body for POST /api/plugins/tools/execute */ interface PluginToolExecuteRequest { /** Fully namespaced tool name (e.g., "acme.linear:search-issues"). */ @@ -313,6 +371,146 @@ export function pluginRoutes( loader, workerManager: bridgeDeps?.workerManager ?? webhookDeps?.workerManager, }); + const issuesSvc = issueService(db); + + function matchScopedApiRoute(route: PluginApiRouteDeclaration, method: string, requestPath: string) { + if (route.method !== method) return null; + const normalize = (value: string) => value.replace(/\/+$/, "") || "/"; + const routeSegments = normalize(route.path).split("/").filter(Boolean); + const requestSegments = normalize(requestPath).split("/").filter(Boolean); + if (routeSegments.length !== requestSegments.length) return null; + const params: Record = {}; + for (let i = 0; i < routeSegments.length; i += 1) { + const routeSegment = routeSegments[i]!; + const requestSegment = requestSegments[i]!; + if (routeSegment.startsWith(":")) { + params[routeSegment.slice(1)] = decodeURIComponent(requestSegment); + continue; + } + if (routeSegment !== requestSegment) return null; + } + return params; + } + + function sanitizePluginRequestHeaders(req: Request): Record { + const safeHeaderNames = new Set([ + "accept", + "content-type", + "user-agent", + "x-taskcore-run-id", + "x-request-id", + ]); + const headers: Record = {}; + for (const [name, value] of Object.entries(req.headers)) { + const lower = name.toLowerCase(); + if (!safeHeaderNames.has(lower)) continue; + if (Array.isArray(value)) { + headers[lower] = value.join(", "); + } else if (typeof value === "string") { + headers[lower] = value; + } + } + return headers; + } + + function applyPluginScopedApiResponseHeaders( + res: Response, + headers: Record | undefined, + ): void { + for (const [name, value] of Object.entries(headers ?? {})) { + const lower = name.toLowerCase(); + if (!PLUGIN_SCOPED_API_RESPONSE_HEADER_ALLOWLIST.has(lower)) continue; + res.setHeader(lower, value); + } + } + + function normalizeQuery(query: Request["query"]): Record { + const normalized: Record = {}; + for (const [key, value] of Object.entries(query)) { + if (typeof value === "string") { + normalized[key] = value; + } else if (Array.isArray(value)) { + normalized[key] = value.map((entry) => String(entry)); + } + } + return normalized; + } + + async function resolveScopedApiCompanyId( + route: PluginApiRouteDeclaration, + params: Record, + req: Request, + ) { + const resolution = route.companyResolution; + if (!resolution) { + if (req.actor.type === "agent" && req.actor.companyId) return req.actor.companyId; + return null; + } + + if (resolution.from === "body") { + const body = req.body as Record | undefined; + const companyId = body?.[resolution.key ?? ""]; + return typeof companyId === "string" ? companyId : null; + } + + if (resolution.from === "query") { + const value = req.query[resolution.key ?? ""]; + return typeof value === "string" ? value : null; + } + + const issueId = params[resolution.param ?? ""]; + if (!issueId) return null; + const issue = await issuesSvc.getById(issueId); + return issue?.companyId ?? null; + } + + function assertScopedApiAuth(req: Request, route: PluginApiRouteDeclaration) { + if (route.auth === "board") { + assertBoard(req); + return; + } + if (route.auth === "agent") { + assertAuthenticated(req); + if (req.actor.type !== "agent") throw forbidden("Agent access required"); + return; + } + if (route.auth === "webhook") { + throw unprocessable("Webhook-scoped plugin API routes require a signature verifier and are not enabled"); + } + assertAuthenticated(req); + if (req.actor.type !== "board" && req.actor.type !== "agent") { + throw forbidden("Board or agent access required"); + } + } + + async function enforceScopedApiCheckout( + req: Request, + route: PluginApiRouteDeclaration, + params: Record, + companyId: string, + ) { + const policy = route.checkoutPolicy ?? "none"; + if (policy === "none" || req.actor.type !== "agent") return; + const issueId = params.issueId; + if (!issueId) { + throw unprocessable("Checkout-protected plugin API routes require an issueId route parameter"); + } + const issue = await issuesSvc.getById(issueId); + if (!issue || issue.companyId !== companyId) { + throw notFound("Issue not found"); + } + if (policy === "required-for-agent-in-progress") { + if (issue.status !== "in_progress" || issue.assigneeAgentId !== req.actor.agentId) return; + } + const runId = req.actor.runId?.trim(); + if (!runId) { + throw unauthorized("Agent run id required"); + } + if (!req.actor.agentId) { + throw forbidden("Agent authentication required"); + } + await issuesSvc.assertCheckoutOwner(issueId, req.actor.agentId, runId); + } async function resolvePluginAuditCompanyIds(req: Request): Promise { if (typeof (db as { select?: unknown }).select === "function") { @@ -357,6 +555,52 @@ export function pluginRoutes( }))); } + function assertPluginBridgeScope(req: Request, companyId: unknown): string | undefined { + if (companyId === undefined || companyId === null) { + assertInstanceAdmin(req); + return undefined; + } + if (typeof companyId !== "string" || companyId.trim().length === 0) { + throw badRequest('"companyId" must be a non-empty string when provided'); + } + assertCompanyAccess(req, companyId); + return companyId; + } + + async function validateToolRunContextScope(runContext: ToolRunContext): Promise { + const [agent] = await db + .select({ companyId: agents.companyId }) + .from(agents) + .where(eq(agents.id, runContext.agentId)) + .limit(1); + if (!agent || agent.companyId !== runContext.companyId) { + return '"runContext.agentId" does not belong to "runContext.companyId"'; + } + + const [run] = await db + .select({ companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runContext.runId)) + .limit(1); + if (!run || run.companyId !== runContext.companyId) { + return '"runContext.runId" does not belong to "runContext.companyId"'; + } + if (run.agentId !== runContext.agentId) { + return '"runContext.runId" does not belong to "runContext.agentId"'; + } + + const [project] = await db + .select({ companyId: projects.companyId }) + .from(projects) + .where(eq(projects.id, runContext.projectId)) + .limit(1); + if (!project || project.companyId !== runContext.companyId) { + return '"runContext.projectId" does not belong to "runContext.companyId"'; + } + + return null; + } + /** * GET /api/plugins * @@ -371,7 +615,7 @@ export function pluginRoutes( * Response: `PluginRecord[]` */ router.get("/plugins", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const rawStatus = req.query.status; if (rawStatus !== undefined) { if (typeof rawStatus !== "string" || !(PLUGIN_STATUSES as readonly string[]).includes(rawStatus)) { @@ -395,7 +639,7 @@ export function pluginRoutes( * These can be installed through the normal local-path install flow. */ router.get("/plugins/examples", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); res.json(listBundledPluginExamples()); }); @@ -440,7 +684,7 @@ export function pluginRoutes( * Response: PluginUiContribution[] */ router.get("/plugins/ui-contributions", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const plugins = await registry.listByStatus("ready"); const contributions: PluginUiContribution[] = plugins @@ -483,7 +727,7 @@ export function pluginRoutes( * Errors: 501 if tool dispatcher is not configured */ router.get("/plugins/tools", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!toolDeps) { res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); @@ -517,7 +761,7 @@ export function pluginRoutes( * - 502 if the plugin worker is unavailable or the RPC call fails */ router.post("/plugins/tools/execute", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!toolDeps) { res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); @@ -551,6 +795,11 @@ export function pluginRoutes( } assertCompanyAccess(req, runContext.companyId); + const scopeError = await validateToolRunContextScope(runContext); + if (scopeError) { + res.status(403).json({ error: scopeError }); + return; + } // Verify the tool exists const registeredTool = toolDeps.toolDispatcher.getTool(tool); @@ -583,6 +832,9 @@ export function pluginRoutes( * * Install a plugin from npm or a local filesystem path. * + * Instance-wide plugin installation is restricted to instance admins because + * the install flow fetches and inspects package contents on the host. + * * Request body: * - packageName: npm package name or local path (required) * - version: Target version for npm packages (optional) @@ -601,7 +853,7 @@ export function pluginRoutes( * - `500` — installation succeeded but manifest is missing (indicates a loader bug) */ router.post("/plugins/install", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { packageName, version, isLocalPath } = req.body as PluginInstallRequest; // Input validation @@ -793,7 +1045,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/bridge/data", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -826,9 +1078,7 @@ export function pluginRoutes( return; } - if (body.companyId) { - assertCompanyAccess(req, body.companyId); - } + assertPluginBridgeScope(req, body.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -876,7 +1126,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/bridge/action", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -909,9 +1159,7 @@ export function pluginRoutes( return; } - if (body.companyId) { - assertCompanyAccess(req, body.companyId); - } + assertPluginBridgeScope(req, body.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -960,7 +1208,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/data/:key", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -992,9 +1240,7 @@ export function pluginRoutes( renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } | undefined; - if (body?.companyId) { - assertCompanyAccess(req, body.companyId); - } + assertPluginBridgeScope(req, body?.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1039,7 +1285,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/actions/:key", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1071,9 +1317,7 @@ export function pluginRoutes( renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } | undefined; - if (body?.companyId) { - assertCompanyAccess(req, body.companyId); - } + assertPluginBridgeScope(req, body?.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1120,7 +1364,7 @@ export function pluginRoutes( * - 501 if bridge deps or stream bus are not configured */ router.get("/plugins/:pluginId/bridge/stream/:channel", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps?.streamBus) { res.status(501).json({ error: "Plugin stream bridge is not enabled" }); @@ -1185,6 +1429,113 @@ export function pluginRoutes( res.on("error", safeUnsubscribe); }); + router.use("/plugins/:pluginId/api", async (req, res) => { + if (!bridgeDeps) { + res.status(501).json({ error: "Plugin scoped API routes are not enabled" }); + return; + } + + const { pluginId } = req.params; + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + if (plugin.status !== "ready") { + res.status(503).json({ error: `Plugin is not ready (current status: ${plugin.status})` }); + return; + } + const isWorkerRunning = typeof bridgeDeps.workerManager.isRunning === "function" + ? bridgeDeps.workerManager.isRunning(plugin.id) + : true; + if (!isWorkerRunning) { + res.status(503).json({ error: "Plugin worker is not running" }); + return; + } + if (!plugin.manifestJson.capabilities.includes("api.routes.register")) { + res.status(404).json({ error: "Plugin does not expose scoped API routes" }); + return; + } + + const requestPath = req.path || "/"; + const routes = plugin.manifestJson.apiRoutes ?? []; + const match = routes + .map((route) => ({ route, params: matchScopedApiRoute(route, req.method, requestPath) })) + .find((candidate) => candidate.params !== null); + if (!match || !match.params) { + res.status(404).json({ error: "Plugin API route not found" }); + return; + } + + try { + assertScopedApiAuth(req, match.route); + const companyId = await resolveScopedApiCompanyId(match.route, match.params, req); + if (!companyId) { + res.status(400).json({ error: "Unable to resolve company for plugin API route" }); + return; + } + assertCompanyAccess(req, companyId); + await enforceScopedApiCheckout(req, match.route, match.params, companyId); + if (req.method !== "GET" && req.headers["content-type"] && !req.is("application/json")) { + res.status(415).json({ error: "Plugin API routes accept JSON requests only" }); + return; + } + const requestBody = req.body ?? null; + const bodySize = Buffer.byteLength(JSON.stringify(requestBody)); + if (bodySize > PLUGIN_API_BODY_LIMIT_BYTES) { + res.status(413).json({ error: "Plugin API request body is too large" }); + return; + } + + const actor = getActorInfo(req); + const input: PluginScopedApiRequest = { + routeKey: match.route.routeKey, + method: req.method, + path: requestPath, + params: match.params, + query: normalizeQuery(req.query), + body: requestBody, + actor: { + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + runId: actor.runId, + }, + companyId, + headers: sanitizePluginRequestHeaders(req), + }; + + const result = await bridgeDeps.workerManager.call( + plugin.id, + "handleApiRequest", + input, + ) as PluginScopedApiResponse; + const status = Number.isInteger(result.status) && Number(result.status) >= 200 && Number(result.status) <= 599 + ? Number(result.status) + : 200; + applyPluginScopedApiResponseHeaders(res, result.headers); + if (status === 204) { + res.status(status).end(); + } else { + res.status(status).json(result.body ?? null); + } + } catch (err) { + const status = typeof (err as { status?: unknown }).status === "number" + ? (err as { status: number }).status + : err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED + ? 403 + : err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED + ? 501 + : err instanceof JsonRpcCallError + ? 502 + : 500; + res.status(status).json({ + error: err instanceof Error ? err.message : String(err), + }); + } + }); + /** * GET /api/plugins/:pluginId * @@ -1198,7 +1549,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { @@ -1228,7 +1579,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.delete("/plugins/:pluginId", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const purge = req.query.purge === "true"; @@ -1264,7 +1615,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/enable", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1302,7 +1653,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/disable", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const body = req.body as { reason?: string } | undefined; const reason = body?.reason; @@ -1343,7 +1694,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/health", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1411,7 +1762,7 @@ export function pluginRoutes( * Response: Array of log entries, newest first. */ router.get("/plugins/:pluginId/logs", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1450,6 +1801,9 @@ export function pluginRoutes( * * Upgrade a plugin to a newer version. * + * Upgrades are restricted to instance admins because they fetch and inspect + * new package contents on the host before activation. + * * Request body (optional): * - version: Target version (defaults to latest) * @@ -1461,7 +1815,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/upgrade", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const body = req.body as { version?: string } | undefined; const version = body?.version; @@ -1510,7 +1864,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/config", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1540,7 +1894,7 @@ export function pluginRoutes( * - 404 if plugin not found */ router.post("/plugins/:pluginId/config", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1645,7 +1999,7 @@ export function pluginRoutes( * - 502 if the worker is unavailable */ router.post("/plugins/:pluginId/config/test", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1742,7 +2096,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/jobs", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!jobDeps) { res.status(501).json({ error: "Job scheduling is not enabled" }); return; @@ -1788,7 +2142,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/jobs/:jobId/runs", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!jobDeps) { res.status(501).json({ error: "Job scheduling is not enabled" }); return; @@ -1836,7 +2190,7 @@ export function pluginRoutes( * - 400 if job not found, not active, already running, or worker unavailable */ router.post("/plugins/:pluginId/jobs/:jobId/trigger", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); if (!jobDeps) { res.status(501).json({ error: "Job scheduling is not enabled" }); return; @@ -2042,7 +2396,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/dashboard", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 9aedc97..bb6b138 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -10,6 +10,7 @@ import { updateProjectWorkspaceSchema, workspaceRuntimeControlTargetSchema, } from "@taskcore/shared"; +import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@taskcore/shared"; import { trackProjectCreated } from "@taskcore/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js"; @@ -22,7 +23,16 @@ import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace, } from "../services/workspace-runtime.js"; +import { + assertNoAgentHostWorkspaceCommandMutation, + collectProjectExecutionWorkspaceCommandPaths, + collectProjectWorkspaceCommandPaths, +} from "./workspace-command-authz.js"; +import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js"; import { getTelemetryClient } from "../telemetry.js"; +import { appendWithCap } from "../adapters/utils.js"; + +const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024; export function projectRoutes(db: Db) { const router = Router(); @@ -93,6 +103,13 @@ export function projectRoutes(db: Db) { }; const { workspace, ...projectData } = req.body as CreateProjectPayload; + assertNoAgentHostWorkspaceCommandMutation( + req, + [ + ...collectProjectExecutionWorkspaceCommandPaths(projectData.executionWorkspacePolicy), + ...collectProjectWorkspaceCommandPaths(workspace, "workspace"), + ], + ); if (projectData.env !== undefined) { projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence( companyId, @@ -144,6 +161,10 @@ export function projectRoutes(db: Db) { } assertCompanyAccess(req, existing.companyId); const body = { ...req.body }; + assertNoAgentHostWorkspaceCommandMutation( + req, + collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy), + ); if (typeof body.archivedAt === "string") { body.archivedAt = new Date(body.archivedAt); } @@ -200,6 +221,10 @@ export function projectRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectProjectWorkspaceCommandPaths(req.body), + ); const workspace = await svc.createWorkspace(id, req.body); if (!workspace) { res.status(422).json({ error: "Invalid project workspace payload" }); @@ -238,6 +263,10 @@ export function projectRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectProjectWorkspaceCommandPaths(req.body), + ); const workspaceExists = (await svc.listWorkspaces(id)).some((workspace) => workspace.id === workspaceId); if (!workspaceExists) { res.status(404).json({ error: "Project workspace not found" }); @@ -290,6 +319,11 @@ export function projectRoutes(db: Db) { return; } + await assertCanManageProjectWorkspaceRuntimeServices(db, req, { + companyId: project.companyId, + projectWorkspaceId: workspace.id, + }); + const workspaceCwd = workspace.cwd; if (!workspaceCwd) { res.status(422).json({ error: "Project workspace needs a local path before Taskcore can run workspace commands" }); @@ -347,8 +381,8 @@ export function projectRoutes(db: Db) { const actor = getActorInfo(req); const recorder = workspaceOperations.createRecorder({ companyId: project.companyId }); let runtimeServiceCount = workspace.runtimeServices?.length ?? 0; - const stdout: string[] = []; - const stderr: string[] = []; + let stdout = ""; + let stderr = ""; const operation = await recorder.recordOperation({ phase: action === "stop" ? "workspace_teardown" : "workspace_provision", @@ -410,8 +444,8 @@ export function projectRoutes(db: Db) { } const onLog = async (stream: "stdout" | "stderr", chunk: string) => { - if (stream === "stdout") stdout.push(chunk); - else stderr.push(chunk); + if (stream === "stdout") stdout = appendWithCap(stdout, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); + else stderr = appendWithCap(stderr, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); }; if (action === "stop" || action === "restart") { @@ -455,20 +489,20 @@ export function projectRoutes(db: Db) { runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (workspace.runtimeServices?.length ?? 1) - 1) : 0; } - const currentDesiredState: "running" | "stopped" = + const currentDesiredState: WorkspaceRuntimeDesiredState = workspace.runtimeConfig?.desiredState ?? ((workspace.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running") ? "running" : "stopped"); const nextRuntimeState: { - desiredState: "running" | "stopped"; - serviceStates: Record | null | undefined; + desiredState: WorkspaceRuntimeDesiredState; + serviceStates: WorkspaceRuntimeServiceStateMap | null | undefined; } = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null) - ? { + ? { desiredState: currentDesiredState, serviceStates: workspace.runtimeConfig?.serviceStates ?? null, } - : buildWorkspaceRuntimeDesiredStatePatch({ + : buildWorkspaceRuntimeDesiredStatePatch({ config: { workspaceRuntime: runtimeConfig }, currentDesiredState, currentServiceStates: workspace.runtimeConfig?.serviceStates ?? null, @@ -484,8 +518,8 @@ export function projectRoutes(db: Db) { return { status: "succeeded", - stdout: stdout.join(""), - stderr: stderr.join(""), + stdout, + stderr, system: action === "stop" ? "Stopped project workspace runtime services.\n" diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts index c121c02..6ac89f3 100644 --- a/server/src/routes/sidebar-badges.ts +++ b/server/src/routes/sidebar-badges.ts @@ -5,6 +5,7 @@ import { inboxDismissals, joinRequests } from "@taskcore/db"; import { sidebarBadgeService } from "../services/sidebar-badges.js"; import { accessService } from "../services/access.js"; import { dashboardService } from "../services/dashboard.js"; +import { collapseDuplicatePendingHumanJoinRequests } from "../lib/join-request-dedupe.js"; import { assertCompanyAccess } from "./authz.js"; function buildDismissedAtByKey( @@ -35,14 +36,24 @@ export function sidebarBadgeRoutes(db: Db) { } const visibleJoinRequests = canApproveJoins - ? await db - .select({ - id: joinRequests.id, - updatedAt: joinRequests.updatedAt, - createdAt: joinRequests.createdAt, - }) - .from(joinRequests) - .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval"))) + ? collapseDuplicatePendingHumanJoinRequests( + await db + .select({ + id: joinRequests.id, + requestType: joinRequests.requestType, + status: joinRequests.status, + requestingUserId: joinRequests.requestingUserId, + requestEmailSnapshot: joinRequests.requestEmailSnapshot, + updatedAt: joinRequests.updatedAt, + createdAt: joinRequests.createdAt, + }) + .from(joinRequests) + .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval"))) + ).map(({ id, updatedAt, createdAt }) => ({ + id, + updatedAt, + createdAt, + })) : []; const dismissedAtByKey = diff --git a/server/src/routes/user-profiles.ts b/server/src/routes/user-profiles.ts new file mode 100644 index 0000000..376b74d --- /dev/null +++ b/server/src/routes/user-profiles.ts @@ -0,0 +1,436 @@ +import { Router } from "express"; +import { and, desc, eq, gte, isNull, sql } from "drizzle-orm"; +import type { Db } from "@taskcore/db"; +import { + activityLog, + agents, + authUsers, + companyMemberships, + costEvents, + issueComments, + issues, +} from "@taskcore/db"; +import type { + UserProfileDailyPoint, + UserProfileIdentity, + UserProfileResponse, + UserProfileWindowStats, +} from "@taskcore/shared"; +import { notFound } from "../errors.js"; +import { assertCompanyAccess } from "./authz.js"; + +type CompanyUserRow = { + id: string; + principalId: string; + status: string; + membershipRole: string | null; + createdAt: Date; + userId: string | null; + name: string | null; + email: string | null; + image: string | null; +}; + +const PROFILE_WINDOWS = [ + { key: "last7", label: "Last 7 days", days: 7 }, + { key: "last30", label: "Last 30 days", days: 30 }, + { key: "all", label: "All time", days: null }, +] as const; + +function slugifyUserPart(value: string | null | undefined) { + const normalized = value + ?.trim() + .toLowerCase() + .replace(/['"]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || null; +} + +function userSlugCandidates(row: CompanyUserRow) { + const candidates = new Set(); + const add = (value: string | null | undefined) => { + const slug = slugifyUserPart(value); + if (slug) candidates.add(slug); + }; + add(row.name); + add(row.email?.split("@")[0]); + add(row.email); + add(row.principalId); + return [...candidates]; +} + +async function resolveCompanyUser(db: Db, companyId: string, rawSlug: string): Promise { + const slug = slugifyUserPart(rawSlug); + if (!slug) return null; + + const rows = await db + .select({ + id: companyMemberships.id, + principalId: companyMemberships.principalId, + status: companyMemberships.status, + membershipRole: companyMemberships.membershipRole, + createdAt: companyMemberships.createdAt, + userId: authUsers.id, + name: authUsers.name, + email: authUsers.email, + image: authUsers.image, + }) + .from(companyMemberships) + .leftJoin(authUsers, eq(authUsers.id, companyMemberships.principalId)) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + ), + ) + .orderBy(desc(companyMemberships.updatedAt)) + .limit(200); + + return rows.find((row) => userSlugCandidates(row).includes(slug)) ?? null; +} + +function userIssueInvolvementSql(companyId: string, userId: string) { + return sql` + ( + ${issues.createdByUserId} = ${userId} + OR ${issues.assigneeUserId} = ${userId} + OR EXISTS ( + SELECT 1 + FROM ${issueComments} + WHERE ${issueComments.companyId} = ${companyId} + AND ${issueComments.issueId} = ${issues.id} + AND ${issueComments.authorUserId} = ${userId} + ) + ) + `; +} + +function windowStart(days: number | null) { + if (!days) return null; + return new Date(Date.now() - days * 24 * 60 * 60 * 1000); +} + +function startOfUtcDay(date: Date) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +function isoDay(date: Date) { + return startOfUtcDay(date).toISOString().slice(0, 10); +} + +function dayKeyExpr(dateSql: ReturnType) { + return sql`to_char(date_trunc('day', ${dateSql}), 'YYYY-MM-DD')`; +} + +function sumNumber(column: typeof costEvents.costCents | typeof costEvents.inputTokens | typeof costEvents.cachedInputTokens | typeof costEvents.outputTokens) { + return sql`coalesce(sum(${column}), 0)::double precision`; +} + +async function loadWindowStats( + db: Db, + companyId: string, + userId: string, + key: UserProfileWindowStats["key"], + label: string, + from: Date | null, +): Promise { + const involvement = userIssueInvolvementSql(companyId, userId); + const openStatuses = ["backlog", "todo", "in_progress", "in_review", "blocked"]; + const fromIso = from?.toISOString(); + + const [issueStats] = await db + .select({ + touchedIssues: sql`count(distinct case when ${involvement} ${fromIso ? sql`and ${issues.updatedAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`, + createdIssues: sql`count(distinct case when ${issues.createdByUserId} = ${userId} ${fromIso ? sql`and ${issues.createdAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`, + completedIssues: sql`count(distinct case when ${involvement} and ${issues.status} = 'done' ${fromIso ? sql`and ${issues.completedAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`, + assignedOpenIssues: sql`count(distinct case when ${issues.assigneeUserId} = ${userId} and ${issues.status} in (${sql.join(openStatuses.map((status) => sql`${status}`), sql`, `)}) then ${issues.id} end)::int`, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), isNull(issues.hiddenAt))); + + const commentConditions = [ + eq(issueComments.companyId, companyId), + eq(issueComments.authorUserId, userId), + ]; + if (from) commentConditions.push(gte(issueComments.createdAt, from)); + const [commentStats] = await db + .select({ count: sql`count(*)::int` }) + .from(issueComments) + .where(and(...commentConditions)); + + const activityConditions = [ + eq(activityLog.companyId, companyId), + eq(activityLog.actorType, "user"), + eq(activityLog.actorId, userId), + ]; + if (from) activityConditions.push(gte(activityLog.createdAt, from)); + const [activityStats] = await db + .select({ count: sql`count(*)::int` }) + .from(activityLog) + .where(and(...activityConditions)); + + const costConditions = [ + eq(costEvents.companyId, companyId), + userIssueInvolvementSql(companyId, userId), + ]; + if (from) costConditions.push(gte(costEvents.occurredAt, from)); + const [costStats] = await db + .select({ + costCents: sumNumber(costEvents.costCents), + inputTokens: sumNumber(costEvents.inputTokens), + cachedInputTokens: sumNumber(costEvents.cachedInputTokens), + outputTokens: sumNumber(costEvents.outputTokens), + costEventCount: sql`count(${costEvents.id})::int`, + }) + .from(costEvents) + .innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId))) + .where(and(...costConditions)); + + return { + key, + label, + touchedIssues: Number(issueStats?.touchedIssues ?? 0), + createdIssues: Number(issueStats?.createdIssues ?? 0), + completedIssues: Number(issueStats?.completedIssues ?? 0), + assignedOpenIssues: Number(issueStats?.assignedOpenIssues ?? 0), + commentCount: Number(commentStats?.count ?? 0), + activityCount: Number(activityStats?.count ?? 0), + costCents: Number(costStats?.costCents ?? 0), + inputTokens: Number(costStats?.inputTokens ?? 0), + cachedInputTokens: Number(costStats?.cachedInputTokens ?? 0), + outputTokens: Number(costStats?.outputTokens ?? 0), + costEventCount: Number(costStats?.costEventCount ?? 0), + }; +} + +async function loadDailyStats(db: Db, companyId: string, userId: string): Promise { + const firstDay = startOfUtcDay(new Date(Date.now() - 13 * 24 * 60 * 60 * 1000)); + const points = new Map(); + for (let index = 0; index < 14; index += 1) { + const date = new Date(firstDay.getTime() + index * 24 * 60 * 60 * 1000); + points.set(isoDay(date), { + date: isoDay(date), + activityCount: 0, + completedIssues: 0, + costCents: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + }); + } + + const activityDay = dayKeyExpr(sql`${activityLog.createdAt}`); + const activityRows = await db + .select({ + date: activityDay, + count: sql`count(*)::int`, + }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, companyId), + eq(activityLog.actorType, "user"), + eq(activityLog.actorId, userId), + gte(activityLog.createdAt, firstDay), + ), + ) + .groupBy(activityDay); + + for (const row of activityRows) { + const point = points.get(row.date); + if (point) point.activityCount = Number(row.count); + } + + const completedDay = dayKeyExpr(sql`${issues.completedAt}`); + const completedRows = await db + .select({ + date: completedDay, + count: sql`count(distinct ${issues.id})::int`, + }) + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + isNull(issues.hiddenAt), + eq(issues.status, "done"), + gte(issues.completedAt, firstDay), + userIssueInvolvementSql(companyId, userId), + ), + ) + .groupBy(completedDay); + + for (const row of completedRows) { + const point = points.get(row.date); + if (point) point.completedIssues = Number(row.count); + } + + const costDay = dayKeyExpr(sql`${costEvents.occurredAt}`); + const costRows = await db + .select({ + date: costDay, + costCents: sumNumber(costEvents.costCents), + inputTokens: sumNumber(costEvents.inputTokens), + cachedInputTokens: sumNumber(costEvents.cachedInputTokens), + outputTokens: sumNumber(costEvents.outputTokens), + }) + .from(costEvents) + .innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId))) + .where( + and( + eq(costEvents.companyId, companyId), + gte(costEvents.occurredAt, firstDay), + userIssueInvolvementSql(companyId, userId), + ), + ) + .groupBy(costDay); + + for (const row of costRows) { + const point = points.get(row.date); + if (!point) continue; + point.costCents = Number(row.costCents); + point.inputTokens = Number(row.inputTokens); + point.cachedInputTokens = Number(row.cachedInputTokens); + point.outputTokens = Number(row.outputTokens); + } + + return [...points.values()]; +} + +export function userProfileRoutes(db: Db) { + const router = Router(); + + router.get("/companies/:companyId/users/:userSlug/profile", async (req, res) => { + const companyId = req.params.companyId as string; + const userSlug = req.params.userSlug as string; + assertCompanyAccess(req, companyId); + + const row = await resolveCompanyUser(db, companyId, userSlug); + if (!row) throw notFound("User not found"); + const canonicalSlug = userSlugCandidates(row)[0] ?? row.principalId; + const userId = row.userId ?? row.principalId; + + const [stats, daily, recentIssues, recentActivity, topAgents, topProviders] = await Promise.all([ + Promise.all( + PROFILE_WINDOWS.map((entry) => + loadWindowStats(db, companyId, userId, entry.key, entry.label, windowStart(entry.days)), + ), + ), + loadDailyStats(db, companyId, userId), + db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + updatedAt: issues.updatedAt, + completedAt: issues.completedAt, + }) + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + isNull(issues.hiddenAt), + userIssueInvolvementSql(companyId, userId), + ), + ) + .orderBy(desc(issues.updatedAt)) + .limit(8), + db + .select({ + id: activityLog.id, + action: activityLog.action, + entityType: activityLog.entityType, + entityId: activityLog.entityId, + details: activityLog.details, + createdAt: activityLog.createdAt, + }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, companyId), + eq(activityLog.actorType, "user"), + eq(activityLog.actorId, userId), + ), + ) + .orderBy(desc(activityLog.createdAt)) + .limit(12), + db + .select({ + agentId: costEvents.agentId, + agentName: agents.name, + costCents: sumNumber(costEvents.costCents), + inputTokens: sumNumber(costEvents.inputTokens), + cachedInputTokens: sumNumber(costEvents.cachedInputTokens), + outputTokens: sumNumber(costEvents.outputTokens), + }) + .from(costEvents) + .innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId))) + .leftJoin(agents, eq(agents.id, costEvents.agentId)) + .where(and(eq(costEvents.companyId, companyId), userIssueInvolvementSql(companyId, userId))) + .groupBy(costEvents.agentId, agents.name) + .orderBy(desc(sumNumber(costEvents.costCents))) + .limit(5), + db + .select({ + provider: costEvents.provider, + biller: costEvents.biller, + model: costEvents.model, + costCents: sumNumber(costEvents.costCents), + inputTokens: sumNumber(costEvents.inputTokens), + cachedInputTokens: sumNumber(costEvents.cachedInputTokens), + outputTokens: sumNumber(costEvents.outputTokens), + }) + .from(costEvents) + .innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId))) + .where(and(eq(costEvents.companyId, companyId), userIssueInvolvementSql(companyId, userId))) + .groupBy(costEvents.provider, costEvents.biller, costEvents.model) + .orderBy(desc(sumNumber(costEvents.costCents))) + .limit(5), + ]); + + const user: UserProfileIdentity = { + id: userId, + slug: canonicalSlug, + name: row.name, + email: row.email, + image: row.image, + membershipRole: row.membershipRole, + membershipStatus: row.status, + joinedAt: row.createdAt, + }; + + const payload: UserProfileResponse = { + user, + stats, + daily, + recentIssues: recentIssues.map((issue) => ({ + ...issue, + status: issue.status as UserProfileResponse["recentIssues"][number]["status"], + priority: issue.priority as UserProfileResponse["recentIssues"][number]["priority"], + })), + recentActivity, + topAgents: topAgents.map((entry) => ({ + ...entry, + costCents: Number(entry.costCents), + inputTokens: Number(entry.inputTokens), + cachedInputTokens: Number(entry.cachedInputTokens), + outputTokens: Number(entry.outputTokens), + })), + topProviders: topProviders.map((entry) => ({ + ...entry, + costCents: Number(entry.costCents), + inputTokens: Number(entry.inputTokens), + cachedInputTokens: Number(entry.cachedInputTokens), + outputTokens: Number(entry.outputTokens), + })), + }; + + res.json(payload); + }); + + return router; +} diff --git a/server/src/routes/workspace-command-authz.ts b/server/src/routes/workspace-command-authz.ts new file mode 100644 index 0000000..e7999d1 --- /dev/null +++ b/server/src/routes/workspace-command-authz.ts @@ -0,0 +1,115 @@ +import type { Request } from "express"; +import { forbidden } from "../errors.js"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOwn(value: Record, key: string) { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function prefixPath(prefix: string, key: string) { + return prefix.length > 0 ? `${prefix}.${key}` : key; +} + +function collectWorkspaceStrategyCommandPaths(raw: unknown, prefix: string): string[] { + if (!isRecord(raw)) return []; + const paths: string[] = []; + if (hasOwn(raw, "provisionCommand")) { + paths.push(prefixPath(prefix, "provisionCommand")); + } + if (hasOwn(raw, "teardownCommand")) { + paths.push(prefixPath(prefix, "teardownCommand")); + } + return paths; +} + +function collectExecutionWorkspaceConfigCommandPaths(raw: unknown, prefix: string): string[] { + if (!isRecord(raw)) return []; + const paths: string[] = []; + if (hasOwn(raw, "provisionCommand")) { + paths.push(prefixPath(prefix, "provisionCommand")); + } + if (hasOwn(raw, "teardownCommand")) { + paths.push(prefixPath(prefix, "teardownCommand")); + } + if (hasOwn(raw, "cleanupCommand")) { + paths.push(prefixPath(prefix, "cleanupCommand")); + } + return paths; +} + +export function assertNoAgentHostWorkspaceCommandMutation(req: Request, paths: string[]) { + if (req.actor.type !== "agent" || paths.length === 0) return; + throw forbidden( + `Agent keys cannot modify host-executed workspace commands (${paths.join(", ")}).`, + ); +} + +export function collectAgentAdapterWorkspaceCommandPaths(adapterConfig: unknown): string[] { + if (!isRecord(adapterConfig)) return []; + return collectWorkspaceStrategyCommandPaths( + adapterConfig.workspaceStrategy, + "adapterConfig.workspaceStrategy", + ); +} + +export function collectProjectExecutionWorkspaceCommandPaths(policy: unknown): string[] { + if (!isRecord(policy)) return []; + return collectWorkspaceStrategyCommandPaths( + policy.workspaceStrategy, + "executionWorkspacePolicy.workspaceStrategy", + ); +} + +export function collectProjectWorkspaceCommandPaths( + workspacePatch: unknown, + prefix = "", +): string[] { + if (!isRecord(workspacePatch)) return []; + return hasOwn(workspacePatch, "cleanupCommand") + ? [prefixPath(prefix, "cleanupCommand")] + : []; +} + +export function collectIssueWorkspaceCommandPaths(input: { + executionWorkspaceSettings?: unknown; + assigneeAdapterOverrides?: unknown; +}): string[] { + const paths: string[] = []; + if (isRecord(input.executionWorkspaceSettings)) { + paths.push( + ...collectWorkspaceStrategyCommandPaths( + input.executionWorkspaceSettings.workspaceStrategy, + "executionWorkspaceSettings.workspaceStrategy", + ), + ); + } + if (isRecord(input.assigneeAdapterOverrides)) { + const adapterConfig = input.assigneeAdapterOverrides.adapterConfig; + if (isRecord(adapterConfig)) { + paths.push( + ...collectWorkspaceStrategyCommandPaths( + adapterConfig.workspaceStrategy, + "assigneeAdapterOverrides.adapterConfig.workspaceStrategy", + ), + ); + } + } + return paths; +} + +export function collectExecutionWorkspaceCommandPaths(input: { + config?: unknown; + metadata?: unknown; +}): string[] { + const paths: string[] = []; + if (input.config !== undefined) { + paths.push(...collectExecutionWorkspaceConfigCommandPaths(input.config, "config")); + } + if (isRecord(input.metadata) && hasOwn(input.metadata, "config")) { + paths.push(...collectExecutionWorkspaceConfigCommandPaths(input.metadata.config, "metadata.config")); + } + return paths; +} diff --git a/server/src/routes/workspace-runtime-service-authz.ts b/server/src/routes/workspace-runtime-service-authz.ts new file mode 100644 index 0000000..e661e3d --- /dev/null +++ b/server/src/routes/workspace-runtime-service-authz.ts @@ -0,0 +1,138 @@ +import { and, eq, inArray, isNull, ne, or } from "drizzle-orm"; +import type { Db } from "@taskcore/db"; +import { agents, issues } from "@taskcore/db"; +import type { Request } from "express"; +import { forbidden } from "../errors.js"; +import { assertCompanyAccess } from "./authz.js"; + +const WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES: string[] = [ + "backlog", + "todo", + "in_progress", + "in_review", + "blocked", +]; + +async function listReportingSubtreeAgentIds(db: Db, companyId: string, actorAgentId: string) { + const companyAgents = await db + .select({ + id: agents.id, + reportsTo: agents.reportsTo, + }) + .from(agents) + .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); + + const reportsByManager = new Map(); + for (const agent of companyAgents) { + if (!agent.reportsTo) continue; + const reports = reportsByManager.get(agent.reportsTo) ?? []; + reports.push(agent.id); + reportsByManager.set(agent.reportsTo, reports); + } + + const visited = new Set([actorAgentId]); + const queue = [actorAgentId]; + while (queue.length > 0) { + const current = queue.shift(); + if (!current) continue; + const reports = reportsByManager.get(current) ?? []; + for (const reportId of reports) { + if (visited.has(reportId)) continue; + visited.add(reportId); + queue.push(reportId); + } + } + + return [...visited]; +} + +async function assertAgentCanManageRuntimeServicesForWorkspace( + db: Db, + req: Request, + input: { + companyId: string; + projectWorkspaceId?: string | null; + executionWorkspaceId?: string | null; + sourceIssueId?: string | null; + }, +) { + if (req.actor.type !== "agent" || !req.actor.agentId) { + throw forbidden("Agent authentication required"); + } + + const actorAgent = await db + .select({ + id: agents.id, + companyId: agents.companyId, + role: agents.role, + }) + .from(agents) + .where(eq(agents.id, req.actor.agentId)) + .then((rows) => rows[0] ?? null); + + if (!actorAgent || actorAgent.companyId !== input.companyId) { + throw forbidden("Agent key cannot access another company"); + } + + if (actorAgent.role === "ceo") { + return; + } + + const eligibleAgentIds = await listReportingSubtreeAgentIds(db, input.companyId, actorAgent.id); + const workspaceScopeConditions = [ + input.projectWorkspaceId ? eq(issues.projectWorkspaceId, input.projectWorkspaceId) : null, + input.executionWorkspaceId ? eq(issues.executionWorkspaceId, input.executionWorkspaceId) : null, + input.sourceIssueId ? eq(issues.id, input.sourceIssueId) : null, + ].filter((condition): condition is NonNullable => condition !== null); + + if (workspaceScopeConditions.length === 0) { + throw forbidden("Missing permission to manage workspace runtime services"); + } + + const linkedIssue = await db + .select({ id: issues.id }) + .from(issues) + .where(and( + eq(issues.companyId, input.companyId), + isNull(issues.hiddenAt), + inArray(issues.status, WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES), + inArray(issues.assigneeAgentId, eligibleAgentIds), + workspaceScopeConditions.length === 1 + ? workspaceScopeConditions[0]! + : or(...workspaceScopeConditions), + )) + .then((rows) => rows[0] ?? null); + + if (linkedIssue) { + return; + } + + throw forbidden("Missing permission to manage workspace runtime services"); +} + +export async function assertCanManageProjectWorkspaceRuntimeServices( + db: Db, + req: Request, + input: { + companyId: string; + projectWorkspaceId: string; + }, +) { + assertCompanyAccess(req, input.companyId); + if (req.actor.type === "board") return; + await assertAgentCanManageRuntimeServicesForWorkspace(db, req, input); +} + +export async function assertCanManageExecutionWorkspaceRuntimeServices( + db: Db, + req: Request, + input: { + companyId: string; + executionWorkspaceId: string; + sourceIssueId?: string | null; + }, +) { + assertCompanyAccess(req, input.companyId); + if (req.actor.type === "board") return; + await assertAgentCanManageRuntimeServicesForWorkspace(db, req, input); +} diff --git a/server/src/services/access.ts b/server/src/services/access.ts index edb2541..ab868d6 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -1,11 +1,14 @@ -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, ne, sql } from "drizzle-orm"; import type { Db } from "@taskcore/db"; import { + agents, companyMemberships, instanceUserRoles, + issues, principalPermissionGrants, } from "@taskcore/db"; import type { PermissionKey, PrincipalType } from "@taskcore/shared"; +import { conflict } from "../errors.js"; type MembershipRow = typeof companyMemberships.$inferSelect; type GrantInput = { @@ -13,6 +16,13 @@ type GrantInput = { scope?: Record | null; }; +type MemberArchiveInput = { + reassignment?: { + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + } | null; +}; + export function accessService(db: Db) { async function isInstanceAdmin(userId: string | null | undefined): Promise { if (!userId) return false; @@ -83,6 +93,14 @@ export function accessService(db: Db) { .orderBy(sql`${companyMemberships.createdAt} desc`); } + async function getMemberById(companyId: string, memberId: string) { + return db + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) + .then((rows) => rows[0] ?? null); + } + async function listActiveUserMemberships(companyId: string) { return db .select() @@ -103,11 +121,7 @@ export function accessService(db: Db) { grants: GrantInput[], grantedByUserId: string | null, ) { - const member = await db - .select() - .from(companyMemberships) - .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) - .then((rows) => rows[0] ?? null); + const member = await getMemberById(companyId, memberId); if (!member) return null; await db.transaction(async (tx) => { @@ -139,6 +153,271 @@ export function accessService(db: Db) { return member; } + async function updateMemberAndPermissions( + companyId: string, + memberId: string, + data: { + membershipRole?: string | null; + status?: "pending" | "active" | "suspended"; + grants: GrantInput[]; + }, + grantedByUserId: string | null, + ) { + return db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + + const nextMembershipRole = + data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole; + const nextStatus = data.status ?? existing.status; + + if ( + existing.principalType === "user" && + existing.status === "active" && + existing.membershipRole === "owner" && + (nextStatus !== "active" || nextMembershipRole !== "owner") + ) { + const activeOwnerCount = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + const now = new Date(); + const updated = await tx + .update(companyMemberships) + .set({ + membershipRole: nextMembershipRole, + status: nextStatus, + updatedAt: now, + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, existing.principalType), + eq(principalPermissionGrants.principalId, existing.principalId), + ), + ); + if (data.grants.length > 0) { + await tx.insert(principalPermissionGrants).values( + data.grants.map((grant) => ({ + companyId, + principalType: existing.principalType, + principalId: existing.principalId, + permissionKey: grant.permissionKey, + scope: grant.scope ?? null, + grantedByUserId, + createdAt: now, + updatedAt: now, + })), + ); + } + + return updated; + }); + } + + async function assertCanRemoveActiveOwner( + companyId: string, + principalType: PrincipalType, + status: string, + membershipRole: string | null, + tx: Pick, + ) { + if ( + principalType !== "user" || + status !== "active" || + membershipRole !== "owner" + ) { + return; + } + + const activeOwnerCount = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + async function assertAssignableArchiveTarget( + companyId: string, + input: MemberArchiveInput["reassignment"], + tx: Pick, + ) { + if (!input?.assigneeAgentId && !input?.assigneeUserId) return; + if (input.assigneeAgentId && input.assigneeUserId) { + throw conflict("Choose either an agent or user reassignment target"); + } + if (input.assigneeUserId) { + const membership = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, input.assigneeUserId), + eq(companyMemberships.status, "active"), + ), + ) + .then((rows) => rows[0] ?? null); + if (!membership) { + throw conflict("Replacement user must be an active company member"); + } + return; + } + + const agent = await tx + .select({ + id: agents.id, + companyId: agents.companyId, + status: agents.status, + }) + .from(agents) + .where(eq(agents.id, input.assigneeAgentId!)) + .then((rows) => rows[0] ?? null); + if (!agent || agent.companyId !== companyId) { + throw conflict("Replacement agent must belong to the same company"); + } + if (agent.status === "pending_approval" || agent.status === "terminated") { + throw conflict("Replacement agent must be assignable"); + } + } + + async function archiveMember(companyId: string, memberId: string, input: MemberArchiveInput = {}) { + return db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + if (existing.principalType !== "user") { + throw conflict("Only human company members can be archived"); + } + if (existing.status === "archived") { + return { member: existing, reassignedIssueCount: 0 }; + } + if (input.reassignment?.assigneeUserId === existing.principalId) { + throw conflict("Replacement user cannot be the archived member"); + } + + await assertCanRemoveActiveOwner( + companyId, + existing.principalType, + existing.status, + existing.membershipRole, + tx, + ); + await assertAssignableArchiveTarget(companyId, input.reassignment, tx); + + const now = new Date(); + const assignmentPatch = { + assigneeAgentId: input.reassignment?.assigneeAgentId ?? null, + assigneeUserId: input.reassignment?.assigneeUserId ?? null, + updatedAt: now, + }; + const assignedOpenIssueWhere = and( + eq(issues.companyId, companyId), + eq(issues.assigneeUserId, existing.principalId), + sql`${issues.status} not in ('done', 'cancelled')`, + ); + const resetInProgress = await tx + .update(issues) + .set({ + ...assignmentPatch, + status: "todo", + startedAt: null, + checkoutRunId: null, + executionRunId: null, + executionLockedAt: null, + }) + .where(and(assignedOpenIssueWhere, eq(issues.status, "in_progress"))) + .returning({ id: issues.id }); + const reassigned = await tx + .update(issues) + .set(assignmentPatch) + .where(and(assignedOpenIssueWhere, ne(issues.status, "in_progress"))) + .returning({ id: issues.id }); + + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, existing.principalType), + eq(principalPermissionGrants.principalId, existing.principalId), + ), + ); + + const archived = await tx + .update(companyMemberships) + .set({ + status: "archived", + updatedAt: now, + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + + return { + member: archived, + reassignedIssueCount: resetInProgress.length + reassigned.length, + }; + }); + } + async function promoteInstanceAdmin(userId: string) { const existing = await db .select() @@ -172,25 +451,87 @@ export function accessService(db: Db) { .orderBy(sql`${companyMemberships.createdAt} desc`); } - async function setUserCompanyAccess(userId: string, companyIds: string[]) { + async function setUserCompanyAccess( + userId: string, + companyIds: string[], + options: { actorUserId?: string | null } = {}, + ) { const existing = await listUserCompanyAccess(userId); const existingByCompany = new Map(existing.map((row) => [row.companyId, row])); const target = new Set(companyIds); await db.transaction(async (tx) => { - const toDelete = existing.filter((row) => !target.has(row.companyId)).map((row) => row.id); - if (toDelete.length > 0) { - await tx.delete(companyMemberships).where(inArray(companyMemberships.id, toDelete)); + const toArchive = existing.filter((row) => !target.has(row.companyId) && row.status !== "archived"); + if (toArchive.length > 0 && options.actorUserId && options.actorUserId === userId) { + throw conflict("You cannot remove yourself"); + } + if (toArchive.length > 0 && (await isInstanceAdmin(userId))) { + throw conflict("Instance admins cannot be removed from company access"); + } + const protectedArchives = toArchive.filter((row) => row.membershipRole === "owner" || row.membershipRole === "admin"); + if (protectedArchives.length > 0) { + throw conflict("Owners and admins cannot be removed from company access"); + } + const activeOwnerArchives = toArchive.filter( + (row) => row.status === "active" && row.membershipRole === "owner", + ); + if (activeOwnerArchives.length > 0) { + const activeOwnerRows = await tx + .select({ companyId: companyMemberships.companyId, id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + inArray(companyMemberships.companyId, activeOwnerArchives.map((row) => row.companyId)), + ), + ); + for (const row of activeOwnerArchives) { + const remainingOwners = + activeOwnerRows.filter((owner) => owner.companyId === row.companyId).length - 1; + if (remainingOwners <= 0) { + throw conflict("Cannot remove the last active owner"); + } + } + } + if (toArchive.length > 0) { + await tx + .update(companyMemberships) + .set({ status: "archived", updatedAt: new Date() }) + .where(inArray(companyMemberships.id, toArchive.map((row) => row.id))); + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, userId), + inArray(principalPermissionGrants.companyId, toArchive.map((row) => row.companyId)), + ), + ); } for (const companyId of target) { - if (existingByCompany.has(companyId)) continue; + const existingMembership = existingByCompany.get(companyId); + if (existingMembership) { + if (existingMembership.status !== "active") { + await tx + .update(companyMemberships) + .set({ + status: "active", + membershipRole: existingMembership.membershipRole ?? "operator", + updatedAt: new Date(), + }) + .where(eq(companyMemberships.id, existingMembership.id)); + } + continue; + } await tx.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", - membershipRole: "member", + membershipRole: "operator", }); } }); @@ -359,16 +700,85 @@ export function accessService(db: Db) { }); } + async function updateMember( + companyId: string, + memberId: string, + data: { + membershipRole?: string | null; + status?: "pending" | "active" | "suspended"; + }, + ) { + return db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + + const nextMembershipRole = + data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole; + const nextStatus = data.status ?? existing.status; + + if ( + existing.principalType === "user" && + existing.status === "active" && + existing.membershipRole === "owner" && + (nextStatus !== "active" || nextMembershipRole !== "owner") + ) { + const activeOwnerCount = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + return tx + .update(companyMemberships) + .set({ + membershipRole: nextMembershipRole, + status: nextStatus, + updatedAt: new Date(), + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + }); + } + return { isInstanceAdmin, canUser, hasPermission, getMembership, + getMemberById, ensureMembership, listMembers, listActiveUserMemberships, copyActiveUserMemberships, + archiveMember, setMemberPermissions, + updateMemberAndPermissions, promoteInstanceAdmin, demoteInstanceAdmin, listUserCompanyAccess, @@ -376,5 +786,6 @@ export function accessService(db: Db) { setPrincipalGrants, listPrincipalGrants, setPrincipalPermission, + updateMember, }; } diff --git a/server/src/services/activity-log.ts b/server/src/services/activity-log.ts index 546f533..34c8c43 100644 --- a/server/src/services/activity-log.ts +++ b/server/src/services/activity-log.ts @@ -11,6 +11,20 @@ import type { PluginEventBus } from "./plugin-event-bus.js"; import { instanceSettingsService } from "./instance-settings.js"; const PLUGIN_EVENT_SET: ReadonlySet = new Set(PLUGIN_EVENT_TYPES); +const ACTIVITY_ACTION_TO_PLUGIN_EVENT: Readonly> = { + issue_comment_added: "issue.comment.created", + issue_comment_created: "issue.comment.created", + issue_document_created: "issue.document.created", + issue_document_updated: "issue.document.updated", + issue_document_deleted: "issue.document.deleted", + issue_blockers_updated: "issue.relations.updated", + approval_approved: "approval.decided", + approval_rejected: "approval.decided", + approval_revision_requested: "approval.decided", + budget_soft_threshold_crossed: "budget.incident.opened", + budget_hard_threshold_crossed: "budget.incident.opened", + budget_incident_resolved: "budget.incident.resolved", +}; let _pluginEventBus: PluginEventBus | null = null; @@ -22,9 +36,23 @@ export function setPluginEventBus(bus: PluginEventBus): void { _pluginEventBus = bus; } +function eventTypeForActivityAction(action: string): PluginEventType | null { + if (PLUGIN_EVENT_SET.has(action)) return action as PluginEventType; + return ACTIVITY_ACTION_TO_PLUGIN_EVENT[action.replaceAll(".", "_")] ?? null; +} + +export function publishPluginDomainEvent(event: PluginEvent): void { + if (!_pluginEventBus) return; + void _pluginEventBus.emit(event).then(({ errors }) => { + for (const { pluginId, error } of errors) { + logger.warn({ pluginId, eventType: event.eventType, err: error }, "plugin event handler failed"); + } + }).catch(() => {}); +} + export interface LogActivityInput { companyId: string; - actorType: "agent" | "user" | "system"; + actorType: "agent" | "user" | "system" | "plugin"; actorId: string; action: string; entityType: string; @@ -69,10 +97,11 @@ export async function logActivity(db: Db, input: LogActivityInput) { }, }); - if (_pluginEventBus && PLUGIN_EVENT_SET.has(input.action)) { + const pluginEventType = eventTypeForActivityAction(input.action); + if (pluginEventType) { const event: PluginEvent = { eventId: randomUUID(), - eventType: input.action as PluginEventType, + eventType: pluginEventType, occurredAt: new Date().toISOString(), actorId: input.actorId, actorType: input.actorType, @@ -85,10 +114,6 @@ export async function logActivity(db: Db, input: LogActivityInput) { runId: input.runId ?? null, }, }; - void _pluginEventBus.emit(event).then(({ errors }) => { - for (const { pluginId, error } of errors) { - logger.warn({ pluginId, eventType: event.eventType, err: error }, "plugin event handler failed"); - } - }).catch(() => { }); + publishPluginDomainEvent(event); } } diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index 4ddf5f3..b0387a3 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -1,15 +1,39 @@ -import { and, desc, eq, isNull, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@taskcore/db"; -import { activityLog, agents, heartbeatRuns, issues } from "@taskcore/db"; +import { + activityLog, + agents, + documentRevisions, + heartbeatRunEvents, + heartbeatRuns, + issueComments, + issueDocuments, + issues, + issueWorkProducts, + workspaceOperations, +} from "@taskcore/db"; +import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@taskcore/shared"; +import { logger } from "../middleware/logger.js"; +import { classifyRunLiveness } from "./run-liveness.js"; export interface ActivityFilters { companyId: string; agentId?: string; entityType?: string; entityId?: string; + limit?: number; +} + +const DEFAULT_ACTIVITY_LIMIT = 100; +const MAX_ACTIVITY_LIMIT = 500; + +export function normalizeActivityLimit(limit: number | undefined) { + if (!Number.isFinite(limit)) return DEFAULT_ACTIVITY_LIMIT; + return Math.max(1, Math.min(MAX_ACTIVITY_LIMIT, Math.floor(limit ?? DEFAULT_ACTIVITY_LIMIT))); } export function activityService(db: Db) { + const scheduledLivenessBackfills = new Set(); const issueIdAsText = sql`${issues.id}::text`; const summarizedUsageJson = sql | null>` case @@ -74,14 +98,234 @@ export function activityService(db: Db) { ${heartbeatRuns.resultJson} -> 'total_cost_usd', ${heartbeatRuns.resultJson} -> 'cost_usd', ${heartbeatRuns.resultJson} -> 'costUsd' - ) + ), + 'stopReason', ${heartbeatRuns.resultJson} -> 'stopReason', + 'effectiveTimeoutSec', ${heartbeatRuns.resultJson} -> 'effectiveTimeoutSec', + 'effectiveTimeoutMs', ${heartbeatRuns.resultJson} -> 'effectiveTimeoutMs', + 'timeoutConfigured', ${heartbeatRuns.resultJson} -> 'timeoutConfigured', + 'timeoutSource', ${heartbeatRuns.resultJson} -> 'timeoutSource', + 'timeoutFired', ${heartbeatRuns.resultJson} -> 'timeoutFired' )) end `.as("resultJson"); + function countValue(value: unknown) { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : 0; + } + + function dateValue(value: unknown) { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; + if (typeof value === "string" || typeof value === "number") { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + return null; + } + + function latestDate(...values: unknown[]) { + let latest: Date | null = null; + for (const value of values) { + const parsed = dateValue(value); + if (!parsed) continue; + if (!latest || parsed.getTime() > latest.getTime()) latest = parsed; + } + return latest; + } + + function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; + } + + function readNumber(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : null; + } + + async function backfillMissingRunLivenessForIssue(companyId: string, issueId: string) { + const runs = await db + .select({ + id: heartbeatRuns.id, + companyId: heartbeatRuns.companyId, + status: heartbeatRuns.status, + contextSnapshot: heartbeatRuns.contextSnapshot, + resultJson: heartbeatRuns.resultJson, + stdoutExcerpt: heartbeatRuns.stdoutExcerpt, + stderrExcerpt: heartbeatRuns.stderrExcerpt, + error: heartbeatRuns.error, + errorCode: heartbeatRuns.errorCode, + continuationAttempt: heartbeatRuns.continuationAttempt, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, companyId), + isNull(heartbeatRuns.livenessState), + sql`${heartbeatRuns.status} not in ('queued', 'running')`, + or( + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, + sql`exists ( + select 1 + from ${activityLog} + where ${activityLog.companyId} = ${companyId} + and ${activityLog.entityType} = 'issue' + and ${activityLog.entityId} = ${issueId} + and ${activityLog.runId} = ${heartbeatRuns.id} + )`, + ), + ), + ) + .limit(20); + + if (runs.length === 0) return; + + const issue = await db + .select({ + status: issues.status, + title: issues.title, + description: issues.description, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.id, issueId))) + .then((rows) => rows[0] ?? null); + + for (const run of runs) { + const context = asRecord(run.contextSnapshot); + const continuationAttempt = + readNumber(context?.continuationAttempt) ?? + readNumber(context?.livenessContinuationAttempt) ?? + run.continuationAttempt ?? + 0; + + const [commentStats] = await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${issueComments.createdAt})`, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, companyId), + eq(issueComments.issueId, issueId), + eq(issueComments.createdByRunId, run.id), + ), + ); + + const [documentStats] = await db + .select({ + count: sql`count(*)::int`, + planCount: sql`count(*) filter (where ${issueDocuments.key} = 'plan')::int`, + latestAt: sql`max(${documentRevisions.createdAt})`, + }) + .from(documentRevisions) + .innerJoin(issueDocuments, eq(documentRevisions.documentId, issueDocuments.documentId)) + .where( + and( + eq(documentRevisions.companyId, companyId), + eq(documentRevisions.createdByRunId, run.id), + eq(issueDocuments.companyId, companyId), + eq(issueDocuments.issueId, issueId), + sql`${issueDocuments.key} != ${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`, + ), + ); + + const [workProductStats] = await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${issueWorkProducts.createdAt})`, + }) + .from(issueWorkProducts) + .where( + and( + eq(issueWorkProducts.companyId, companyId), + eq(issueWorkProducts.issueId, issueId), + eq(issueWorkProducts.createdByRunId, run.id), + ), + ); + + const [workspaceOperationStats] = await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${workspaceOperations.startedAt})`, + }) + .from(workspaceOperations) + .where(and(eq(workspaceOperations.companyId, companyId), eq(workspaceOperations.heartbeatRunId, run.id))); + + const [activityStats] = await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${activityLog.createdAt})`, + }) + .from(activityLog) + .where(and(eq(activityLog.companyId, companyId), eq(activityLog.runId, run.id))); + + const [eventStats] = await db + .select({ + count: sql`count(*) filter (where ${heartbeatRunEvents.eventType} not in ('lifecycle', 'adapter.invoke', 'error'))::int`, + latestAt: sql`max(${heartbeatRunEvents.createdAt}) filter (where ${heartbeatRunEvents.eventType} not in ('lifecycle', 'adapter.invoke', 'error'))`, + }) + .from(heartbeatRunEvents) + .where(and(eq(heartbeatRunEvents.companyId, companyId), eq(heartbeatRunEvents.runId, run.id))); + + const classification = classifyRunLiveness({ + runStatus: run.status, + issue, + resultJson: asRecord(run.resultJson), + stdoutExcerpt: run.stdoutExcerpt, + stderrExcerpt: run.stderrExcerpt, + error: run.error, + errorCode: run.errorCode, + continuationAttempt, + evidence: { + issueCommentsCreated: countValue(commentStats?.count), + documentRevisionsCreated: countValue(documentStats?.count), + planDocumentRevisionsCreated: countValue(documentStats?.planCount), + workProductsCreated: countValue(workProductStats?.count), + workspaceOperationsCreated: countValue(workspaceOperationStats?.count), + activityEventsCreated: countValue(activityStats?.count), + toolOrActionEventsCreated: countValue(eventStats?.count), + latestEvidenceAt: latestDate( + commentStats?.latestAt, + documentStats?.latestAt, + workProductStats?.latestAt, + workspaceOperationStats?.latestAt, + activityStats?.latestAt, + eventStats?.latestAt, + ), + }, + }); + + await db + .update(heartbeatRuns) + .set({ + livenessState: classification.livenessState, + livenessReason: classification.livenessReason, + continuationAttempt: classification.continuationAttempt, + lastUsefulActionAt: classification.lastUsefulActionAt, + nextAction: classification.nextAction, + updatedAt: new Date(), + }) + .where(and(eq(heartbeatRuns.id, run.id), isNull(heartbeatRuns.livenessState))); + } + } + + function scheduleRunLivenessBackfill(companyId: string, issueId: string) { + const key = `${companyId}:${issueId}`; + if (scheduledLivenessBackfills.has(key)) return; + scheduledLivenessBackfills.add(key); + void backfillMissingRunLivenessForIssue(companyId, issueId) + .catch((err: unknown) => { + logger.warn({ err, companyId, issueId }, "run liveness backfill failed"); + }) + .finally(() => { + scheduledLivenessBackfills.delete(key); + }); + } + return { list: (filters: ActivityFilters) => { const conditions = [eq(activityLog.companyId, filters.companyId)]; + const limit = normalizeActivityLimit(filters.limit); if (filters.agentId) { conditions.push(eq(activityLog.agentId, filters.agentId)); @@ -113,6 +357,7 @@ export function activityService(db: Db) { ), ) .orderBy(desc(activityLog.createdAt)) + .limit(limit) .then((rows) => rows.map((r) => r.activityLog)); }, @@ -128,8 +373,9 @@ export function activityService(db: Db) { ) .orderBy(desc(activityLog.createdAt)), - runsForIssue: (companyId: string, issueId: string) => - db + runsForIssue: async (companyId: string, issueId: string) => { + scheduleRunLivenessBackfill(companyId, issueId); + const runs = await db .select({ runId: heartbeatRuns.id, status: heartbeatRuns.status, @@ -142,6 +388,15 @@ export function activityService(db: Db) { usageJson: summarizedUsageJson, resultJson: summarizedResultJson, logBytes: heartbeatRuns.logBytes, + retryOfRunId: heartbeatRuns.retryOfRunId, + scheduledRetryAt: heartbeatRuns.scheduledRetryAt, + scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt, + scheduledRetryReason: heartbeatRuns.scheduledRetryReason, + livenessState: heartbeatRuns.livenessState, + livenessReason: heartbeatRuns.livenessReason, + continuationAttempt: heartbeatRuns.continuationAttempt, + lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, + nextAction: heartbeatRuns.nextAction, }) .from(heartbeatRuns) .innerJoin( @@ -167,7 +422,36 @@ export function activityService(db: Db) { ), ), ) - .orderBy(desc(heartbeatRuns.createdAt)), + .orderBy(desc(heartbeatRuns.createdAt)); + + if (runs.length === 0) return runs; + + const exhaustionRows = await db + .select({ + runId: heartbeatRunEvents.runId, + message: heartbeatRunEvents.message, + }) + .from(heartbeatRunEvents) + .where( + and( + inArray(heartbeatRunEvents.runId, runs.map((run) => run.runId)), + eq(heartbeatRunEvents.eventType, "lifecycle"), + sql`${heartbeatRunEvents.message} like 'Bounded retry exhausted%'`, + ), + ) + .orderBy(asc(heartbeatRunEvents.runId), desc(heartbeatRunEvents.id)); + + const retryExhaustedReasonByRunId = new Map(); + for (const row of exhaustionRows) { + if (!row.message || retryExhaustedReasonByRunId.has(row.runId)) continue; + retryExhaustedReasonByRunId.set(row.runId, row.message); + } + + return runs.map((run) => ({ + ...run, + retryExhaustedReason: retryExhaustedReasonByRunId.get(run.runId) ?? null, + })); + }, issuesForRun: async (runId: string) => { const run = await db diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 9255925..c31fef1 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -16,7 +16,7 @@ import { issues, issueComments, } from "@taskcore/db"; -import { isUuidLike, normalizeAgentUrlKey } from "@taskcore/shared"; +import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, isUuidLike, normalizeAgentUrlKey } from "@taskcore/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { normalizeAgentPermissions } from "./agent-permissions.js"; import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js"; @@ -114,6 +114,25 @@ function hasConfigPatchFields(data: Partial) { return CONFIG_REVISION_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(data, field)); } +function parseFiniteNumberLike(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string") return null; + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; +} + +function normalizeRuntimeConfigForNewAgent(runtimeConfig: unknown): Record { + const normalizedRuntimeConfig = isPlainRecord(runtimeConfig) ? { ...runtimeConfig } : {}; + const heartbeat = isPlainRecord(normalizedRuntimeConfig.heartbeat) + ? { ...normalizedRuntimeConfig.heartbeat } + : {}; + if (parseFiniteNumberLike(heartbeat.maxConcurrentRuns) == null) { + heartbeat.maxConcurrentRuns = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; + } + normalizedRuntimeConfig.heartbeat = heartbeat; + return normalizedRuntimeConfig; +} + function diffConfigSnapshot( before: AgentConfigSnapshot, after: AgentConfigSnapshot, @@ -216,7 +235,7 @@ export function agentService(db: Db) { const rows = await db .select({ agentId: costEvents.agentId, - spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where( @@ -398,9 +417,10 @@ export function agentService(db: Db) { const role = data.role ?? "general"; const normalizedPermissions = normalizeAgentPermissions(data.permissions, role); + const runtimeConfig = normalizeRuntimeConfigForNewAgent(data.runtimeConfig); const created = await db .insert(agents) - .values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions }) + .values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions, runtimeConfig }) .returning() .then((rows) => rows[0]); @@ -506,18 +526,19 @@ export function agentService(db: Db) { }, activatePendingApproval: async (id: string) => { - const existing = await getById(id); - if (!existing) return null; - if (existing.status !== "pending_approval") return existing; - const updated = await db .update(agents) .set({ status: "idle", updatedAt: new Date() }) - .where(eq(agents.id, id)) + .where(and(eq(agents.id, id), eq(agents.status, "pending_approval"))) .returning() .then((rows) => rows[0] ?? null); - return updated ? normalizeAgentRow(updated) : null; + if (updated) { + return { agent: normalizeAgentRow(updated), activated: true }; + } + + const existing = await getById(id); + return existing ? { agent: existing, activated: false } : null; }, updatePermissions: async (id: string, permissions: { canCreateAgents: boolean }) => { @@ -619,11 +640,25 @@ export function agentService(db: Db) { .from(agentApiKeys) .where(eq(agentApiKeys.agentId, id)), - revokeKey: async (keyId: string) => { + getKeyById: async (keyId: string) => + db + .select({ + id: agentApiKeys.id, + agentId: agentApiKeys.agentId, + companyId: agentApiKeys.companyId, + name: agentApiKeys.name, + createdAt: agentApiKeys.createdAt, + revokedAt: agentApiKeys.revokedAt, + }) + .from(agentApiKeys) + .where(eq(agentApiKeys.id, keyId)) + .then((rows) => rows[0] ?? null), + + revokeKey: async (agentId: string, keyId: string) => { const rows = await db .update(agentApiKeys) .set({ revokedAt: new Date() }) - .where(eq(agentApiKeys.id, keyId)) + .where(and(eq(agentApiKeys.id, keyId), eq(agentApiKeys.agentId, agentId))) .returning(); return rows[0] ?? null; }, diff --git a/server/src/services/approvals.ts b/server/src/services/approvals.ts index 2e026b6..cd93ab6 100644 --- a/server/src/services/approvals.ts +++ b/server/src/services/approvals.ts @@ -161,7 +161,7 @@ export function approvalService(db: Db) { source: "approval", sourceId: id, approvedAt: now, - }).catch(() => { }); + }).catch(() => {}); } } diff --git a/server/src/services/board-auth.ts b/server/src/services/board-auth.ts index 60cd2df..7e243ac 100644 --- a/server/src/services/board-auth.ts +++ b/server/src/services/board-auth.ts @@ -62,7 +62,11 @@ export function boardAuthService(db: Db) { .where(eq(authUsers.id, userId)) .then((rows) => rows[0] ?? null), db - .select({ companyId: companyMemberships.companyId }) + .select({ + companyId: companyMemberships.companyId, + membershipRole: companyMemberships.membershipRole, + status: companyMemberships.status, + }) .from(companyMemberships) .where( and( @@ -71,7 +75,7 @@ export function boardAuthService(db: Db) { eq(companyMemberships.status, "active"), ), ) - .then((rows) => rows.map((row) => row.companyId)), + .then((rows) => rows), db .select({ id: instanceUserRoles.id }) .from(instanceUserRoles) @@ -81,7 +85,8 @@ export function boardAuthService(db: Db) { return { user, - companyIds: memberships, + companyIds: memberships.map((row) => row.companyId), + memberships, isInstanceAdmin: Boolean(adminRole), }; } @@ -214,17 +219,17 @@ export function boardAuthService(db: Db) { const [company, approvedBy] = await Promise.all([ challenge.requestedCompanyId ? db - .select({ id: companies.id, name: companies.name }) - .from(companies) - .where(eq(companies.id, challenge.requestedCompanyId)) - .then((rows) => rows[0] ?? null) + .select({ id: companies.id, name: companies.name }) + .from(companies) + .where(eq(companies.id, challenge.requestedCompanyId)) + .then((rows) => rows[0] ?? null) : Promise.resolve(null), challenge.approvedByUserId ? db - .select({ id: authUsers.id, name: authUsers.name, email: authUsers.email }) - .from(authUsers) - .where(eq(authUsers.id, challenge.approvedByUserId)) - .then((rows) => rows[0] ?? null) + .select({ id: authUsers.id, name: authUsers.name, email: authUsers.email }) + .from(authUsers) + .where(eq(authUsers.id, challenge.approvedByUserId)) + .then((rows) => rows[0] ?? null) : Promise.resolve(null), ]); @@ -241,10 +246,10 @@ export function boardAuthService(db: Db) { expiresAt: challenge.expiresAt.toISOString(), approvedByUser: approvedBy ? { - id: approvedBy.id, - name: approvedBy.name, - email: approvedBy.email, - } + id: approvedBy.id, + name: approvedBy.name, + email: approvedBy.email, + } : null, }; } diff --git a/server/src/services/budgets.ts b/server/src/services/budgets.ts index d9216ed..75622bb 100644 --- a/server/src/services/budgets.ts +++ b/server/src/services/budgets.ts @@ -156,7 +156,7 @@ async function computeObservedAmount( const [row] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where(and(...conditions)); diff --git a/server/src/services/circuitBreakers.ts b/server/src/services/circuitBreakers.ts deleted file mode 100644 index 9a44943..0000000 --- a/server/src/services/circuitBreakers.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { and, eq, sql } from "drizzle-orm"; -import type { Db } from "@taskcore/db"; -import { budgetPolicies, agentRuntimeState, agents } from "@taskcore/db"; -import { logger } from "../middleware/logger.js"; -import { logActivity } from "./activity-log.js"; - -export const circuitBreakerService = (db: Db, budgetService: any) => { - return { - async checkAndEnforce(agentId: string) { - const agent = await db.query.agents.findFirst({ - where: eq(agents.id, agentId), - }); - if (!agent) return; - - const runtime = await db.query.agentRuntimeState.findFirst({ - where: eq(agentRuntimeState.agentId, agentId), - }); - if (!runtime) return; - - const policies = await db.query.budgetPolicies.findMany({ - where: and( - eq(budgetPolicies.companyId, agent.companyId), - eq(budgetPolicies.isActive, true) - ), - }); - - for (const policy of policies) { - if (!policy.circuitBreakerJson) continue; - - const config = policy.circuitBreakerJson; - - // Scope check: if policy is agent-scoped, must match agentId. - // If company-scoped, it applies to all. - if (policy.scopeType === "agent" && policy.scopeId !== agentId) continue; - - if (runtime.consecutiveFailures >= config.failureThreshold) { - if (config.autoPause) { - logger.warn({ agentId, consecutiveFailures: runtime.consecutiveFailures, threshold: config.failureThreshold }, "Circuit breaker tripped: auto-pausing agent"); - - await budgetService.pauseAgent(agent.companyId, agentId, "budget"); // Using 'budget' as pause reason for now - - await logActivity(db, { - companyId: agent.companyId, - actorType: "system", - actorId: "circuit_breaker", - action: "agent.paused", - entityType: "agent", - entityId: agentId, - agentId, - details: { - reason: "circuit_breaker_tripped", - consecutiveFailures: runtime.consecutiveFailures, - threshold: config.failureThreshold, - policyId: policy.id - } - }); - break; // Stop after first trip - } - } - } - }, - - checkCompanyBreaker: async (companyId: string) => { - // Check if multiple agents in this company have tripped breakers recently. - const threshold = 3; // trip company if 3+ agents have tripped - const windowMs = 15 * 60 * 1000; // 15 minutes - - const recentBreakers = await db - .select() - .from(budgetIncidents) - .where( - and( - eq(budgetIncidents.companyId, companyId), - eq(budgetIncidents.type, "circuit_breaker"), - gt(budgetIncidents.createdAt, new Date(Date.now() - windowMs)) - ) - ); - - const uniqueAgents = new Set(recentBreakers.map(b => b.agentId).filter(Boolean)); - - if (uniqueAgents.size >= threshold) { - // Trip company breaker - await db.update(companies).set({ pausedAt: new Date() }).where(eq(companies.id, companyId)); - - await db.insert(budgetIncidents).values({ - companyId, - type: "circuit_breaker", - amount: "0", - currency: "USD", - details: { - reason: "Multiple agents tripped circuit breakers", - agentCount: uniqueAgents.size, - agents: Array.from(uniqueAgents) - } - }); - - logger.error({ companyId, agentCount: uniqueAgents.size }, "Company circuit breaker tripped"); - return true; - } - - return false; - } - }; -}; diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index 099afc5..7afd0ac 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -76,10 +76,10 @@ export function companyService(db: Db) { if (companyIds.length === 0) return new Map(); const { start, end } = currentUtcMonthWindow(); const rows = await database - .select({ - companyId: costEvents.companyId, - spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - }) + .select({ + companyId: costEvents.companyId, + spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, + }) .from(costEvents) .where( and( diff --git a/server/src/services/company-export-readme.ts b/server/src/services/company-export-readme.ts index 74193b4..089dc35 100644 --- a/server/src/services/company-export-readme.ts +++ b/server/src/services/company-export-readme.ts @@ -96,7 +96,7 @@ export function generateReadme( // What's Inside table lines.push("## What's Inside"); lines.push(""); - lines.push("> This is an [Agent Company](https://agentcompanies.io) package from [Taskcore](https://taskcore.khulnasoft.com)"); + lines.push("> This is an [Agent Company](https://agentcompanies.io) package from [Taskcore](https://taskcore.ing)"); lines.push(""); const counts: Array<[string, number]> = []; @@ -160,12 +160,12 @@ export function generateReadme( lines.push("pnpm taskcore company import this-github-url-or-folder"); lines.push("```"); lines.push(""); - lines.push("See [Taskcore](https://taskcore.khulnasoft.com) for more information."); + lines.push("See [Taskcore](https://taskcore.ing) for more information."); lines.push(""); // Footer lines.push("---"); - lines.push(`Exported from [Taskcore](https://taskcore.khulnasoft.com) on ${new Date().toISOString().split("T")[0]}`); + lines.push(`Exported from [Taskcore](https://taskcore.ing) on ${new Date().toISOString().split("T")[0]}`); lines.push(""); return lines.join("\n"); diff --git a/server/src/services/company-member-roles.ts b/server/src/services/company-member-roles.ts new file mode 100644 index 0000000..9062aec --- /dev/null +++ b/server/src/services/company-member-roles.ts @@ -0,0 +1,59 @@ +import { PERMISSION_KEYS } from "@taskcore/shared"; +import type { HumanCompanyMembershipRole } from "@taskcore/shared"; + +const HUMAN_COMPANY_MEMBERSHIP_ROLES: HumanCompanyMembershipRole[] = [ + "owner", + "admin", + "operator", + "viewer", +]; + +export function normalizeHumanRole( + value: unknown, + fallback: HumanCompanyMembershipRole = "operator" +): HumanCompanyMembershipRole { + if (value === "member") return "operator"; + return HUMAN_COMPANY_MEMBERSHIP_ROLES.includes(value as HumanCompanyMembershipRole) + ? (value as HumanCompanyMembershipRole) + : fallback; +} + +export function grantsForHumanRole( + role: HumanCompanyMembershipRole +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + switch (role) { + case "owner": + return [ + { permissionKey: "agents:create", scope: null }, + { permissionKey: "users:invite", scope: null }, + { permissionKey: "users:manage_permissions", scope: null }, + { permissionKey: "tasks:assign", scope: null }, + { permissionKey: "joins:approve", scope: null }, + ]; + case "admin": + return [ + { permissionKey: "agents:create", scope: null }, + { permissionKey: "users:invite", scope: null }, + { permissionKey: "tasks:assign", scope: null }, + { permissionKey: "joins:approve", scope: null }, + ]; + case "operator": + return [{ permissionKey: "tasks:assign", scope: null }]; + case "viewer": + return []; + } +} + +export function resolveHumanInviteRole( + defaultsPayload: Record | null | undefined +): HumanCompanyMembershipRole { + if (!defaultsPayload || typeof defaultsPayload !== "object") return "operator"; + const scoped = defaultsPayload.human; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + return "operator"; + } + return normalizeHumanRole((scoped as Record).role, "operator"); +} diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 939dda9..f1afb0c 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -31,6 +31,7 @@ import type { RoutineVariable, } from "@taskcore/shared"; import { + AGENT_DEFAULT_MAX_CONCURRENT_RUNS, ISSUE_PRIORITIES, ISSUE_STATUSES, PROJECT_STATUSES, @@ -47,7 +48,9 @@ import { readTaskcoreSkillSyncPreference, writeTaskcoreSkillSyncPreference, } from "@taskcore/adapter-utils/server-utils"; -import { notFound, unprocessable } from "../errors.js"; +import { ensureOpenCodeModelConfiguredAndAvailable } from "@taskcore/adapter-opencode-local/server"; +import { findServerAdapter } from "../adapters/index.js"; +import { forbidden, notFound, unprocessable } from "../errors.js"; import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; import type { StorageService } from "../storage/types.js"; import { accessService } from "./access.js"; @@ -62,6 +65,7 @@ import { validateCron } from "./cron.js"; import { issueService } from "./issues.js"; import { projectService } from "./projects.js"; import { routineService } from "./routines.js"; +import { secretService } from "./secrets.js"; /** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */ function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] { @@ -117,6 +121,7 @@ const DEFAULT_INCLUDE: CompanyPortabilityInclude = { }; const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename"; +const IMPORT_FORBIDDEN_ADAPTER_TYPES = new Set(["process", "http"]); const execFileAsync = promisify(execFile); let bundledSkillsCommitPromise: Promise | null = null; @@ -586,7 +591,7 @@ const RUNTIME_DEFAULT_RULES: Array<{ path: string[]; value: unknown }> = [ { path: ["heartbeat", "wakeOnAssignment"], value: true }, { path: ["heartbeat", "wakeOnAutomation"], value: true }, { path: ["heartbeat", "wakeOnDemand"], value: true }, - { path: ["heartbeat", "maxConcurrentRuns"], value: 3 }, + { path: ["heartbeat", "maxConcurrentRuns"], value: AGENT_DEFAULT_MAX_CONCURRENT_RUNS }, ]; const ADAPTER_DEFAULT_RULES_BY_TYPE: Record> = { @@ -737,10 +742,20 @@ function clonePortableRecord(value: unknown) { return structuredClone(value) as Record; } +function parseFiniteNumberLike(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string") return null; + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; +} + function disableImportedTimerHeartbeat(runtimeConfig: unknown) { const next = clonePortableRecord(runtimeConfig) ?? {}; const heartbeat = isPlainRecord(next.heartbeat) ? { ...next.heartbeat } : {}; heartbeat.enabled = false; + if (parseFiniteNumberLike(heartbeat.maxConcurrentRuns) == null) { + heartbeat.maxConcurrentRuns = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; + } next.heartbeat = heartbeat; return next; } @@ -1949,7 +1964,7 @@ async function buildSkillSourceEntry(skill: CompanySkill) { const commit = await resolveBundledSkillsCommit(); return { kind: "github-dir", - repo: "khulnasoft/taskcore", + repo: "taskcore/taskcore", path: `skills/${skill.slug}`, commit, trackingRef: "master", @@ -2540,14 +2555,14 @@ function buildManifestFromPackageFiles( sourceRef = commit; normalizedMetadata = owner && repoName ? { - sourceKind: "github", - ...(sourceHostname !== "github.com" ? { hostname: sourceHostname } : {}), - owner, - repo: repoName, - ref: commit, - trackingRef, - repoSkillDir: repoPath ?? `skills/${slug}`, - } + sourceKind: "github", + ...(sourceHostname !== "github.com" ? { hostname: sourceHostname } : {}), + owner, + repo: repoName, + ref: commit, + trackingRef, + repoSkillDir: repoPath ?? `skills/${slug}`, + } : null; } else if (sourceKind === "url") { sourceType = "url"; @@ -2747,6 +2762,94 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const projects = projectService(db); const issues = issueService(db); const companySkills = companySkillService(db); + const secrets = secretService(db); + const strictSecretsMode = process.env.TASKCORE_SECRETS_STRICT_MODE === "true"; + + function assertKnownImportAdapterType(type: string | null | undefined): string { + const adapterType = typeof type === "string" ? type.trim() : ""; + if (!adapterType) { + throw unprocessable("Adapter type is required"); + } + if (!findServerAdapter(adapterType)) { + throw unprocessable(`Unknown adapter type: ${adapterType}`); + } + return adapterType; + } + + async function assertImportAdapterConfigConstraints( + companyId: string, + adapterType: string, + adapterConfig: Record, + ) { + if (adapterType !== "opencode_local") return; + const { config: runtimeConfig } = await secrets.resolveAdapterConfigForRuntime(companyId, adapterConfig); + const runtimeEnv = isPlainRecord(runtimeConfig.env) ? runtimeConfig.env : {}; + try { + await ensureOpenCodeModelConfiguredAndAvailable({ + model: runtimeConfig.model, + command: runtimeConfig.command, + cwd: runtimeConfig.cwd, + env: runtimeEnv, + }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`); + } + } + + async function prepareImportedAgentAdapter( + companyId: string, + adapterType: string | null | undefined, + adapterConfig: Record, + desiredSkills: string[], + mode: ImportMode, + ) { + const effectiveAdapterType = assertKnownImportAdapterType(adapterType); + if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) { + throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`); + } + const nextAdapterConfig = writeTaskcoreSkillSyncPreference({ ...adapterConfig }, desiredSkills); + delete nextAdapterConfig.promptTemplate; + delete nextAdapterConfig.bootstrapPromptTemplate; + delete nextAdapterConfig.instructionsFilePath; + delete nextAdapterConfig.instructionsBundleMode; + delete nextAdapterConfig.instructionsRootPath; + delete nextAdapterConfig.instructionsEntryFile; + const normalizedAdapterConfig = await secrets.normalizeAdapterConfigForPersistence( + companyId, + nextAdapterConfig, + { strictMode: strictSecretsMode }, + ); + await assertImportAdapterConfigConstraints(companyId, effectiveAdapterType, normalizedAdapterConfig); + return { + adapterType: effectiveAdapterType, + adapterConfig: normalizedAdapterConfig, + }; + } + + function resolveImportedAssigneeAgentId( + assigneeSlug: string | null | undefined, + importedSlugToAgentId: Map, + existingSlugToAgentId: Map, + agentStatusById: Map, + warnings: string[], + subjectLabel: string, + ) { + if (!assigneeSlug) return null; + const assigneeAgentId = + importedSlugToAgentId.get(assigneeSlug) + ?? existingSlugToAgentId.get(assigneeSlug) + ?? null; + if (!assigneeAgentId) return null; + const assigneeStatus = agentStatusById.get(assigneeAgentId) ?? null; + if (assigneeStatus === "pending_approval" || assigneeStatus === "terminated") { + warnings.push( + `${subjectLabel} assignee ${assigneeSlug} is ${assigneeStatus}; imported work was left unassigned.`, + ); + return null; + } + return assigneeAgentId; + } async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise { if (source.type === "inline") { @@ -3501,10 +3604,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const selectedSlugs = include.agents ? ( - input.agents && input.agents !== "all" - ? Array.from(new Set(input.agents)) - : manifest.agents.map((agent) => agent.slug) - ) + input.agents && input.agents !== "all" + ? Array.from(new Set(input.agents)) + : manifest.agents.map((agent) => agent.slug) + ) : []; const selectedAgents = include.agents @@ -3856,7 +3959,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const warnings = [...plan.preview.warnings]; const include = plan.include; - let targetCompany: { id: string; name: string } | null = null; + let targetCompany: { + id: string; + name: string; + requireBoardApprovalForNewAgents?: boolean | null; + } | null = null; let companyAction: "created" | "updated" | "unchanged" = "unchanged"; if (input.target.mode === "new_company") { @@ -3977,9 +4084,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const resultProjects: CompanyPortabilityImportResult["projects"] = []; const importedSlugToAgentId = new Map(); const existingSlugToAgentId = new Map(); + const agentStatusById = new Map(); const existingAgents = await agents.list(targetCompany.id); for (const existing of existingAgents) { existingSlugToAgentId.set(normalizeAgentUrlKey(existing.name) ?? existing.id, existing.id); + agentStatusById.set(existing.id, existing.status); } const importedSlugToProjectId = new Map(); const importedProjectWorkspaceIdByProjectSlug = new Map>(); @@ -3991,8 +4100,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const importedSkills = include.skills || include.agents ? await companySkills.importPackageFiles(targetCompany.id, pickTextFiles(plan.source.files), { - onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy), - }) + onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy), + }) : []; const desiredSkillRefMap = new Map(); for (const importedSkill of importedSkills) { @@ -4049,22 +4158,18 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { // Apply adapter overrides from request if present const adapterOverride = input.adapterOverrides?.[planAgent.slug]; - const effectiveAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType; const baseAdapterConfig = adapterOverride?.adapterConfig ? { ...adapterOverride.adapterConfig } : { ...manifestAgent.adapterConfig } as Record; const desiredSkills = (manifestAgent.skills ?? []).map((skillRef) => desiredSkillRefMap.get(skillRef) ?? skillRef); - const adapterConfigWithSkills = writeTaskcoreSkillSyncPreference( + const normalizedAdapter = await prepareImportedAgentAdapter( + targetCompany.id, + adapterOverride?.adapterType ?? manifestAgent.adapterType, baseAdapterConfig, desiredSkills, + mode, ); - delete adapterConfigWithSkills.promptTemplate; - delete adapterConfigWithSkills.bootstrapPromptTemplate; // deprecated - delete adapterConfigWithSkills.instructionsFilePath; - delete adapterConfigWithSkills.instructionsBundleMode; - delete adapterConfigWithSkills.instructionsRootPath; - delete adapterConfigWithSkills.instructionsEntryFile; const patch = { name: planAgent.plannedName, role: manifestAgent.role, @@ -4072,8 +4177,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { icon: manifestAgent.icon, capabilities: manifestAgent.capabilities, reportsTo: null, - adapterType: effectiveAdapterType, - adapterConfig: adapterConfigWithSkills, + adapterType: normalizedAdapter.adapterType, + adapterConfig: normalizedAdapter.adapterConfig, runtimeConfig: disableImportedTimerHeartbeat(manifestAgent.runtimeConfig), budgetMonthlyCents: manifestAgent.budgetMonthlyCents, permissions: manifestAgent.permissions, @@ -4102,6 +4207,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } catch (err) { warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`); } + agentStatusById.set(updated.id, updated.status ?? agentStatusById.get(updated.id) ?? null); importedSlugToAgentId.set(planAgent.slug, updated.id); existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id); resultAgents.push({ @@ -4114,7 +4220,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { continue; } - let created = await agents.create(targetCompany.id, patch); + const createdStatus = "idle"; + let created = await agents.create(targetCompany.id, { + ...patch, + status: createdStatus, + }); await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active"); await access.setPrincipalPermission( targetCompany.id, @@ -4133,6 +4243,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } catch (err) { warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`); } + agentStatusById.set(created.id, created.status ?? createdStatus); importedSlugToAgentId.set(planAgent.slug, created.id); existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id); resultAgents.push({ @@ -4177,8 +4288,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const projectLeadAgentId = manifestProject.leadAgentSlug ? importedSlugToAgentId.get(manifestProject.leadAgentSlug) - ?? existingSlugToAgentId.get(manifestProject.leadAgentSlug) - ?? null + ?? existingSlugToAgentId.get(manifestProject.leadAgentSlug) + ?? null : null; const projectWorkspaceIdByKey = new Map(); const projectPatch = { @@ -4275,15 +4386,18 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const markdownRaw = readPortableTextFile(plan.source.files, manifestIssue.path); const parsed = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : null; const description = parsed?.body || manifestIssue.description || null; - const assigneeAgentId = manifestIssue.assigneeAgentSlug - ? importedSlugToAgentId.get(manifestIssue.assigneeAgentSlug) - ?? existingSlugToAgentId.get(manifestIssue.assigneeAgentSlug) - ?? null - : null; + const assigneeAgentId = resolveImportedAssigneeAgentId( + manifestIssue.assigneeAgentSlug, + importedSlugToAgentId, + existingSlugToAgentId, + agentStatusById, + warnings, + `Task ${manifestIssue.slug}`, + ); const projectId = manifestIssue.projectSlug ? importedSlugToProjectId.get(manifestIssue.projectSlug) - ?? existingProjectSlugToId.get(manifestIssue.projectSlug) - ?? null + ?? existingProjectSlugToId.get(manifestIssue.projectSlug) + ?? null : null; const projectWorkspaceId = manifestIssue.projectSlug && manifestIssue.projectWorkspaceKey ? importedProjectWorkspaceIdByProjectSlug.get(manifestIssue.projectSlug)?.get(manifestIssue.projectWorkspaceKey) ?? null @@ -4292,8 +4406,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { warnings.push(`Task ${manifestIssue.slug} references workspace key ${manifestIssue.projectWorkspaceKey}, but that workspace was not imported.`); } if (manifestIssue.recurring) { - if (!projectId || !assigneeAgentId) { - throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project or assignee required to create a routine.`); + if (!projectId) { + throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project required to create a routine.`); } const resolvedRoutine = resolvePortableRoutineDefinition(manifestIssue, parsed?.frontmatter.schedule); if (resolvedRoutine.errors.length > 0) { @@ -4373,15 +4487,20 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } continue; } + let issueStatus = manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any) + ? manifestIssue.status as typeof ISSUE_STATUSES[number] + : "backlog"; + if (!assigneeAgentId && issueStatus === "in_progress") { + warnings.push(`Task ${manifestIssue.slug} was downgraded to todo because its assignee could not be imported as assignable work.`); + issueStatus = "todo"; + } await issues.create(targetCompany.id, { projectId, projectWorkspaceId, title: manifestIssue.title, description, assigneeAgentId, - status: manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any) - ? manifestIssue.status as typeof ISSUE_STATUSES[number] - : "backlog", + status: issueStatus, priority: manifestIssue.priority && ISSUE_PRIORITIES.includes(manifestIssue.priority as any) ? manifestIssue.priority as typeof ISSUE_PRIORITIES[number] : "medium", diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 4843ae1..eb9e5c1 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -36,6 +36,50 @@ import { projectService } from "./projects.js"; import { secretService } from "./secrets.js"; type CompanySkillRow = typeof companySkills.$inferSelect; +type CompanySkillListDbRow = Pick< + CompanySkillRow, + | "id" + | "companyId" + | "key" + | "slug" + | "name" + | "description" + | "sourceType" + | "sourceLocator" + | "sourceRef" + | "trustLevel" + | "compatibility" + | "fileInventory" + | "metadata" + | "createdAt" + | "updatedAt" +>; +type CompanySkillListRow = Pick< + CompanySkill, + | "id" + | "companyId" + | "key" + | "slug" + | "name" + | "description" + | "sourceType" + | "sourceLocator" + | "sourceRef" + | "trustLevel" + | "compatibility" + | "fileInventory" + | "metadata" + | "createdAt" + | "updatedAt" +>; +type SkillReferenceTarget = Pick; +type SkillSourceInfoTarget = Pick< + CompanySkill, + | "companyId" + | "sourceType" + | "sourceLocator" + | "metadata" +>; type ImportedSkill = { key: string; @@ -984,16 +1028,14 @@ async function readUrlSkillImports( ): Promise<{ skills: ImportedSkill[]; warnings: string[] }> { const url = sourceUrl.trim(); const warnings: string[] = []; - const looksLikeRepoUrl = (() => { - try { - const parsed = new URL(url); - if (parsed.protocol !== "https:") return false; - const h = parsed.hostname.toLowerCase(); - if (h.endsWith(".githubusercontent.com") || h === "gist.github.com") return false; - const segments = parsed.pathname.split("/").filter(Boolean); - return segments.length >= 2 && !parsed.pathname.endsWith(".md"); - } catch { return false; } - })(); + const looksLikeRepoUrl = (() => { try { + const parsed = new URL(url); + if (parsed.protocol !== "https:") return false; + const h = parsed.hostname.toLowerCase(); + if (h.endsWith(".githubusercontent.com") || h === "gist.github.com") return false; + const segments = parsed.pathname.split("/").filter(Boolean); + return segments.length >= 2 && !parsed.pathname.endsWith(".md"); + } catch { return false; } })(); if (looksLikeRepoUrl) { const parsed = parseGitHubSourceUrl(url); const apiBase = gitHubApiBase(parsed.hostname); @@ -1152,6 +1194,28 @@ function toCompanySkill(row: CompanySkillRow): CompanySkill { }; } +function toCompanySkillListRow(row: CompanySkillListDbRow): CompanySkillListRow { + return { + ...row, + description: row.description ?? null, + sourceType: row.sourceType as CompanySkillSourceType, + sourceLocator: row.sourceLocator ?? null, + sourceRef: row.sourceRef ?? null, + trustLevel: row.trustLevel as CompanySkillTrustLevel, + compatibility: row.compatibility as CompanySkillCompatibility, + fileInventory: Array.isArray(row.fileInventory) + ? row.fileInventory.flatMap((entry) => { + if (!isPlainRecord(entry)) return []; + return [{ + path: String(entry.path ?? ""), + kind: (String(entry.kind ?? "other") as CompanySkillFileInventoryEntry["kind"]), + }]; + }) + : [], + metadata: isPlainRecord(row.metadata) ? row.metadata : null, + }; +} + function serializeFileInventory( fileInventory: CompanySkillFileInventoryEntry[], ): Array> { @@ -1161,14 +1225,14 @@ function serializeFileInventory( })); } -function getSkillMeta(skill: CompanySkill): SkillSourceMeta { +function getSkillMeta(skill: Pick): SkillSourceMeta { return isPlainRecord(skill.metadata) ? skill.metadata as SkillSourceMeta : {}; } function resolveSkillReference( - skills: CompanySkill[], + skills: SkillReferenceTarget[], reference: string, -): { skill: CompanySkill | null; ambiguous: boolean } { +): { skill: SkillReferenceTarget | null; ambiguous: boolean } { const trimmed = reference.trim(); if (!trimmed) { return { skill: null, ambiguous: false }; @@ -1244,7 +1308,7 @@ function resolveRequestedSkillKeysOrThrow( } function resolveDesiredSkillKeys( - skills: CompanySkill[], + skills: SkillReferenceTarget[], config: Record, ) { const preference = readTaskcoreSkillSyncPreference(config); @@ -1255,7 +1319,7 @@ function resolveDesiredSkillKeys( )); } -function normalizeSkillDirectory(skill: CompanySkill) { +function normalizeSkillDirectory(skill: SkillSourceInfoTarget) { if ((skill.sourceType !== "local_path" && skill.sourceType !== "catalog") || !skill.sourceLocator) return null; const resolved = path.resolve(skill.sourceLocator); if (path.basename(resolved).toLowerCase() === "skill.md") { @@ -1331,7 +1395,7 @@ function isMarkdownPath(filePath: string) { return fileName === "skill.md" || fileName.endsWith(".md"); } -function deriveSkillSourceInfo(skill: CompanySkill): { +function deriveSkillSourceInfo(skill: SkillSourceInfoTarget): { editable: boolean; editableReason: string | null; sourceLabel: string | null; @@ -1404,7 +1468,7 @@ function deriveSkillSourceInfo(skill: CompanySkill): { editableReason: null, sourceLabel: isProjectScan ? [projectName, workspaceName].filter((value): value is string => Boolean(value)).join(" / ") - || skill.sourceLocator + || skill.sourceLocator : skill.sourceLocator, sourceBadge: "local", sourcePath: null, @@ -1430,7 +1494,7 @@ function enrichSkill(skill: CompanySkill, attachedAgentCount: number, usedByAgen }; } -function toCompanySkillListItem(skill: CompanySkill, attachedAgentCount: number): CompanySkillListItem { +function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount: number): CompanySkillListItem { const source = deriveSkillSourceInfo(skill); return { id: skill.id, @@ -1528,7 +1592,29 @@ export function companySkillService(db: Db) { } async function list(companyId: string): Promise { - const rows = await listFull(companyId); + await ensureSkillInventoryCurrent(companyId); + const rows = await db + .select({ + id: companySkills.id, + companyId: companySkills.companyId, + key: companySkills.key, + slug: companySkills.slug, + name: companySkills.name, + description: companySkills.description, + sourceType: companySkills.sourceType, + sourceLocator: companySkills.sourceLocator, + sourceRef: companySkills.sourceRef, + trustLevel: companySkills.trustLevel, + compatibility: companySkills.compatibility, + fileInventory: companySkills.fileInventory, + metadata: companySkills.metadata, + createdAt: companySkills.createdAt, + updatedAt: companySkills.updatedAt, + }) + .from(companySkills) + .where(eq(companySkills.companyId, companyId)) + .orderBy(asc(companySkills.name), asc(companySkills.key)) + .then((entries) => entries.map((entry) => toCompanySkillListRow(entry as CompanySkillListDbRow))); const agentRows = await agents.list(companyId); return rows.map((skill) => { const attachedAgentCount = agentRows.filter((agent) => { diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts index 892cf26..5e51f91 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -12,6 +12,10 @@ export interface CostDateRange { const METERED_BILLING_TYPE = "metered_api"; const SUBSCRIPTION_BILLING_TYPES = ["subscription_included", "subscription_overage"] as const; +function sumAsNumber(column: typeof costEvents.costCents | typeof costEvents.inputTokens | typeof costEvents.cachedInputTokens | typeof costEvents.outputTokens) { + return sql`coalesce(sum(${column}), 0)::double precision`; +} + function currentUtcMonthWindow(now = new Date()) { const year = now.getUTCFullYear(); const month = now.getUTCMonth(); @@ -36,7 +40,7 @@ async function getMonthlySpendTotal( } const [row] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sumAsNumber(costEvents.costCents), }) .from(costEvents) .where(and(...conditions)); @@ -111,7 +115,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { const [{ total }] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sumAsNumber(costEvents.costCents), }) .from(costEvents) .where(and(...conditions)); @@ -140,26 +144,26 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { agentId: costEvents.agentId, agentName: agents.name, agentStatus: agents.status, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, }) .from(costEvents) .leftJoin(agents, eq(costEvents.agentId, agents.id)) .where(and(...conditions)) .groupBy(costEvents.agentId, agents.name, agents.status) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, byProvider: async (companyId: string, range?: CostDateRange) => { @@ -173,25 +177,25 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { biller: costEvents.biller, billingType: costEvents.billingType, model: costEvents.model, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, }) .from(costEvents) .where(and(...conditions)) .groupBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, byBiller: async (companyId: string, range?: CostDateRange) => { @@ -202,27 +206,27 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { return db .select({ biller: costEvents.biller, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, providerCount: sql`count(distinct ${costEvents.provider})::int`, modelCount: sql`count(distinct ${costEvents.model})::int`, }) .from(costEvents) .where(and(...conditions)) .groupBy(costEvents.biller) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, /** @@ -244,10 +248,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { .select({ provider: costEvents.provider, biller: sql`case when count(distinct ${costEvents.biller}) = 1 then min(${costEvents.biller}) else 'mixed' end`, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .where( @@ -257,7 +261,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { ), ) .groupBy(costEvents.provider) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); return rows.map((row) => ({ provider: row.provider, @@ -292,10 +296,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { biller: costEvents.biller, billingType: costEvents.billingType, model: costEvents.model, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .leftJoin(agents, eq(costEvents.agentId, agents.id)) @@ -342,16 +346,16 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from)); if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to)); - const costCentsExpr = sql`coalesce(sum(${costEvents.costCents}), 0)::int`; + const costCentsExpr = sumAsNumber(costEvents.costCents); return db .select({ projectId: effectiveProjectId, projectName: projects.name, costCents: costCentsExpr, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .leftJoin(runProjectLinks, eq(costEvents.heartbeatRunId, runProjectLinks.runId)) diff --git a/server/src/services/dashboard.ts b/server/src/services/dashboard.ts index afeb55a..ea14b98 100644 --- a/server/src/services/dashboard.ts +++ b/server/src/services/dashboard.ts @@ -1,9 +1,27 @@ import { and, eq, gte, sql } from "drizzle-orm"; import type { Db } from "@taskcore/db"; -import { agents, approvals, companies, costEvents, issues } from "@taskcore/db"; +import { agents, approvals, companies, costEvents, heartbeatRuns, issues } from "@taskcore/db"; import { notFound } from "../errors.js"; import { budgetService } from "./budgets.js"; +const DASHBOARD_RUN_ACTIVITY_DAYS = 14; + +function formatUtcDateKey(date: Date): string { + return date.toISOString().slice(0, 10); +} + +export function getUtcMonthStart(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)); +} + +function getRecentUtcDateKeys(now: Date, days: number): string[] { + const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + return Array.from({ length: days }, (_, index) => { + const dayOffset = index - (days - 1); + return formatUtcDateKey(new Date(todayUtc + dayOffset * 24 * 60 * 60 * 1000)); + }); +} + export function dashboardService(db: Db) { const budgets = budgetService(db); return { @@ -62,10 +80,12 @@ export function dashboardService(db: Db) { } const now = new Date(); - const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const monthStart = getUtcMonthStart(now); + const runActivityDays = getRecentUtcDateKeys(now, DASHBOARD_RUN_ACTIVITY_DAYS); + const runActivityStart = new Date(`${runActivityDays[0]}T00:00:00.000Z`); const [{ monthSpend }] = await db .select({ - monthSpend: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + monthSpend: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where( @@ -76,6 +96,38 @@ export function dashboardService(db: Db) { ); const monthSpendCents = Number(monthSpend); + const runActivityDayExpr = sql`to_char(${heartbeatRuns.createdAt} at time zone 'UTC', 'YYYY-MM-DD')`; + const runActivityRows = await db + .select({ + date: runActivityDayExpr, + status: heartbeatRuns.status, + count: sql`count(*)::double precision`, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, companyId), + gte(heartbeatRuns.createdAt, runActivityStart), + ), + ) + .groupBy(runActivityDayExpr, heartbeatRuns.status); + + const runActivity = new Map( + runActivityDays.map((date) => [ + date, + { date, succeeded: 0, failed: 0, other: 0, total: 0 }, + ]), + ); + for (const row of runActivityRows) { + const bucket = runActivity.get(row.date); + if (!bucket) continue; + const count = Number(row.count); + if (row.status === "succeeded") bucket.succeeded += count; + else if (row.status === "failed" || row.status === "timed_out") bucket.failed += count; + else bucket.other += count; + bucket.total += count; + } + const utilization = company.budgetMonthlyCents > 0 ? (monthSpendCents / company.budgetMonthlyCents) * 100 @@ -103,6 +155,7 @@ export function dashboardService(db: Db) { pausedAgents: budgetOverview.pausedAgentCount, pausedProjects: budgetOverview.pausedProjectCount, }, + runActivity: Array.from(runActivity.values()), }; }, }; diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts index 54dc1b9..f01c452 100644 --- a/server/src/services/documents.ts +++ b/server/src/services/documents.ts @@ -1,7 +1,7 @@ import { and, asc, desc, eq } from "drizzle-orm"; import type { Db } from "@taskcore/db"; import { documentRevisions, documents, issueDocuments, issues } from "@taskcore/db"; -import { issueDocumentKeySchema } from "@taskcore/shared"; +import { isSystemIssueDocumentKey, issueDocumentKeySchema } from "@taskcore/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; function normalizeDocumentKey(key: string) { @@ -83,8 +83,14 @@ const issueDocumentSelect = { }; export function documentService(db: Db) { + const filterSystemDocuments = (rows: T[], includeSystem: boolean) => + includeSystem ? rows : rows.filter((row) => !isSystemIssueDocumentKey(row.key)); + return { - getIssueDocumentPayload: async (issue: { id: string; description: string | null }) => { + getIssueDocumentPayload: async ( + issue: { id: string; description: string | null }, + options: { includeSystem?: boolean } = {}, + ) => { const [planDocument, documentSummaries] = await Promise.all([ db .select(issueDocumentSelect) @@ -104,25 +110,26 @@ export function documentService(db: Db) { return { planDocument: planDocument ? mapIssueDocumentRow(planDocument, true) : null, - documentSummaries: documentSummaries.map((row) => mapIssueDocumentRow(row, false)), + documentSummaries: filterSystemDocuments(documentSummaries, options.includeSystem ?? false) + .map((row) => mapIssueDocumentRow(row, false)), legacyPlanDocument: legacyPlanBody ? { - key: "plan" as const, - body: legacyPlanBody, - source: "issue_description" as const, - } + key: "plan" as const, + body: legacyPlanBody, + source: "issue_description" as const, + } : null, }; }, - listIssueDocuments: async (issueId: string) => { + listIssueDocuments: async (issueId: string, options: { includeSystem?: boolean } = {}) => { const rows = await db .select(issueDocumentSelect) .from(issueDocuments) .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) .where(eq(issueDocuments.issueId, issueId)) .orderBy(asc(issueDocuments.key), desc(documents.updatedAt)); - return rows.map((row) => mapIssueDocumentRow(row, true)); + return filterSystemDocuments(rows, options.includeSystem ?? false).map((row) => mapIssueDocumentRow(row, true)); }, getIssueDocumentByKey: async (issueId: string, rawKey: string) => { diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index ab421dc..a1310a2 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -7,10 +7,12 @@ import type { Db } from "@taskcore/db"; import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@taskcore/db"; import type { ExecutionWorkspace, + ExecutionWorkspaceSummary, ExecutionWorkspaceCloseAction, ExecutionWorkspaceCloseGitReadiness, ExecutionWorkspaceCloseReadiness, ExecutionWorkspaceConfig, + WorkspaceRuntimeDesiredState, WorkspaceRuntimeService, } from "@taskcore/shared"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; @@ -39,6 +41,20 @@ function cloneRecord(value: unknown): Record | null { return { ...value }; } +function readDesiredState(value: unknown): WorkspaceRuntimeDesiredState | null { + return value === "running" || value === "stopped" || value === "manual" ? value : null; +} + +function readServiceStates(value: unknown): ExecutionWorkspaceConfig["serviceStates"] { + if (!isRecord(value)) return null; + const entries = Object.entries(value).filter(([, state]) => + state === "running" || state === "stopped" || state === "manual" + ); + return entries.length > 0 + ? Object.fromEntries(entries) as ExecutionWorkspaceConfig["serviceStates"] + : null; +} + async function pathExists(value: string | null | undefined) { if (!value) return false; try { @@ -191,12 +207,8 @@ export function readExecutionWorkspaceConfig(metadata: Record | teardownCommand: readNullableString(raw.teardownCommand), cleanupCommand: readNullableString(raw.cleanupCommand), workspaceRuntime: cloneRecord(raw.workspaceRuntime), - desiredState: raw.desiredState === "running" || raw.desiredState === "stopped" ? raw.desiredState : null, - serviceStates: isRecord(raw.serviceStates) - ? Object.fromEntries( - Object.entries(raw.serviceStates).filter(([, state]) => state === "running" || state === "stopped"), - ) as ExecutionWorkspaceConfig["serviceStates"] - : null, + desiredState: readDesiredState(raw.desiredState), + serviceStates: readServiceStates(raw.serviceStates), }; const hasConfig = Object.values(config).some((value) => { @@ -234,18 +246,10 @@ export function mergeExecutionWorkspaceConfig( workspaceRuntime: patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime, desiredState: patch.desiredState !== undefined - ? patch.desiredState === "running" || patch.desiredState === "stopped" - ? patch.desiredState - : null + ? readDesiredState(patch.desiredState) : current.desiredState, serviceStates: - patch.serviceStates !== undefined && isRecord(patch.serviceStates) - ? Object.fromEntries( - Object.entries(patch.serviceStates).filter(([, state]) => state === "running" || state === "stopped"), - ) as ExecutionWorkspaceConfig["serviceStates"] - : patch.serviceStates !== undefined - ? null - : current.serviceStates, + patch.serviceStates !== undefined ? readServiceStates(patch.serviceStates) : current.serviceStates, }; const hasConfig = Object.values(nextConfig).some((value) => { @@ -336,6 +340,15 @@ function toExecutionWorkspace( }; } +function toExecutionWorkspaceSummary(row: Pick): ExecutionWorkspaceSummary { + return { + id: row.id, + name: row.name, + mode: row.mode as ExecutionWorkspaceSummary["mode"], + projectWorkspaceId: row.projectWorkspaceId ?? null, + }; +} + function usesInheritedProjectRuntimeServices(row: ExecutionWorkspaceRow) { if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false; return !readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null)?.workspaceRuntime; @@ -372,6 +385,33 @@ async function loadEffectiveRuntimeServicesByExecutionWorkspace( } export function executionWorkspaceService(db: Db) { + function buildListConditions( + companyId: string, + filters?: { + projectId?: string; + projectWorkspaceId?: string; + issueId?: string; + status?: string; + reuseEligible?: boolean; + }, + ) { + const conditions = [eq(executionWorkspaces.companyId, companyId)]; + if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId)); + if (filters?.projectWorkspaceId) { + conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId)); + } + if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId)); + if (filters?.status) { + const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean); + if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!)); + else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses)); + } + if (filters?.reuseEligible) { + conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"])); + } + return conditions; + } + return { list: async (companyId: string, filters?: { projectId?: string; @@ -380,21 +420,7 @@ export function executionWorkspaceService(db: Db) { status?: string; reuseEligible?: boolean; }) => { - const conditions = [eq(executionWorkspaces.companyId, companyId)]; - if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId)); - if (filters?.projectWorkspaceId) { - conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId)); - } - if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId)); - if (filters?.status) { - const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean); - if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!)); - else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses)); - } - if (filters?.reuseEligible) { - conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"])); - } - + const conditions = buildListConditions(companyId, filters); const rows = await db .select() .from(executionWorkspaces) @@ -409,6 +435,27 @@ export function executionWorkspaceService(db: Db) { ); }, + listSummaries: async (companyId: string, filters?: { + projectId?: string; + projectWorkspaceId?: string; + issueId?: string; + status?: string; + reuseEligible?: boolean; + }) => { + const conditions = buildListConditions(companyId, filters); + const rows = await db + .select({ + id: executionWorkspaces.id, + name: executionWorkspaces.name, + mode: executionWorkspaces.mode, + projectWorkspaceId: executionWorkspaces.projectWorkspaceId, + }) + .from(executionWorkspaces) + .where(and(...conditions)) + .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); + return rows.map((row) => toExecutionWorkspaceSummary(row)); + }, + getById: async (id: string) => { const row = await db .select() @@ -446,46 +493,46 @@ export function executionWorkspaceService(db: Db) { const projectWorkspace = workspace.projectWorkspaceId ? await db - .select({ - id: projectWorkspaces.id, - cwd: projectWorkspaces.cwd, - cleanupCommand: projectWorkspaces.cleanupCommand, - isPrimary: projectWorkspaces.isPrimary, - }) - .from(projectWorkspaces) - .where( - and( - eq(projectWorkspaces.companyId, workspace.companyId), - eq(projectWorkspaces.id, workspace.projectWorkspaceId), - ), - ) - .then((rows) => rows[0] ?? null) + .select({ + id: projectWorkspaces.id, + cwd: projectWorkspaces.cwd, + cleanupCommand: projectWorkspaces.cleanupCommand, + isPrimary: projectWorkspaces.isPrimary, + }) + .from(projectWorkspaces) + .where( + and( + eq(projectWorkspaces.companyId, workspace.companyId), + eq(projectWorkspaces.id, workspace.projectWorkspaceId), + ), + ) + .then((rows) => rows[0] ?? null) : null; const primaryProjectWorkspace = workspace.projectId ? await db - .select({ - id: projectWorkspaces.id, - }) - .from(projectWorkspaces) - .where( - and( - eq(projectWorkspaces.companyId, workspace.companyId), - eq(projectWorkspaces.projectId, workspace.projectId), - eq(projectWorkspaces.isPrimary, true), - ), - ) - .then((rows) => rows[0] ?? null) + .select({ + id: projectWorkspaces.id, + }) + .from(projectWorkspaces) + .where( + and( + eq(projectWorkspaces.companyId, workspace.companyId), + eq(projectWorkspaces.projectId, workspace.projectId), + eq(projectWorkspaces.isPrimary, true), + ), + ) + .then((rows) => rows[0] ?? null) : null; const projectPolicy = workspace.projectId ? await db - .select({ - executionWorkspacePolicy: projects.executionWorkspacePolicy, - }) - .from(projects) - .where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, workspace.companyId))) - .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)) + .select({ + executionWorkspacePolicy: projects.executionWorkspacePolicy, + }) + .from(projects) + .where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, workspace.companyId))) + .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)) : null; const executionWorkspace = toExecutionWorkspace(workspace, runtimeServices); @@ -636,9 +683,9 @@ export function executionWorkspaceService(db: Db) { const resolvedProjectWorkspacePath = projectWorkspace?.cwd ? path.resolve(projectWorkspace.cwd) : null; const containsProjectWorkspace = resolvedProjectWorkspacePath ? ( - resolvedWorkspacePath === resolvedProjectWorkspacePath || - resolvedProjectWorkspacePath.startsWith(`${resolvedWorkspacePath}${path.sep}`) - ) + resolvedWorkspacePath === resolvedProjectWorkspacePath || + resolvedProjectWorkspacePath.startsWith(`${resolvedWorkspacePath}${path.sep}`) + ) : false; if (containsProjectWorkspace) { warnings.push(`Taskcore will archive this workspace but keep "${workspacePath}" because it contains the project workspace.`); @@ -692,29 +739,6 @@ export function executionWorkspaceService(db: Db) { .then((rows) => rows[0] ?? null); return row ? toExecutionWorkspace(row) : null; }, - - listFiles: async (id: string) => { - const workspace = await db - .select({ cwd: executionWorkspaces.cwd }) - .from(executionWorkspaces) - .where(eq(executionWorkspaces.id, id)) - .then((rows) => rows[0] ?? null); - - if (!workspace?.cwd) return []; - - try { - const entries = await fs.readdir(workspace.cwd, { recursive: true, withFileTypes: true }); - return entries - .filter((entry) => entry.isFile()) - .map((entry) => { - const fullPath = path.join(entry.path, entry.name); - return path.relative(workspace.cwd!, fullPath); - }); - } catch (error) { - console.error(`Failed to list files for workspace ${id}:`, error); - return []; - } - }, }; } diff --git a/server/src/services/finance.ts b/server/src/services/finance.ts index 338c2fc..dfde248 100644 --- a/server/src/services/finance.ts +++ b/server/src/services/finance.ts @@ -35,9 +35,9 @@ function rangeConditions(companyId: string, range?: FinanceDateRange) { } export function financeService(db: Db) { - const debitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::int`; - const creditExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::int`; - const estimatedDebitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::int`; + const debitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`; + const creditExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`; + const estimatedDebitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::double precision`; return { createEvent: async (companyId: string, data: Omit) => { @@ -95,12 +95,12 @@ export function financeService(db: Db) { estimatedDebitCents: estimatedDebitExpr, eventCount: sql`count(*)::int`, kindCount: sql`count(distinct ${financeEvents.eventKind})::int`, - netCents: sql`(${debitExpr} - ${creditExpr})::int`, + netCents: sql`(${debitExpr} - ${creditExpr})::double precision`, }) .from(financeEvents) .where(and(...conditions)) .groupBy(financeEvents.biller) - .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.biller); + .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.biller); }, byKind: async (companyId: string, range?: FinanceDateRange) => { @@ -113,12 +113,12 @@ export function financeService(db: Db) { estimatedDebitCents: estimatedDebitExpr, eventCount: sql`count(*)::int`, billerCount: sql`count(distinct ${financeEvents.biller})::int`, - netCents: sql`(${debitExpr} - ${creditExpr})::int`, + netCents: sql`(${debitExpr} - ${creditExpr})::double precision`, }) .from(financeEvents) .where(and(...conditions)) .groupBy(financeEvents.eventKind) - .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.eventKind); + .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.eventKind); }, list: async (companyId: string, range?: FinanceDateRange, limit: number = 100) => { diff --git a/server/src/services/heartbeat-run-summary.ts b/server/src/services/heartbeat-run-summary.ts index 4d6335e..479957d 100644 --- a/server/src/services/heartbeat-run-summary.ts +++ b/server/src/services/heartbeat-run-summary.ts @@ -1,4 +1,8 @@ -function truncateSummaryText(value: unknown, maxLength = 500) { +export const HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS = 500; +export const HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS = 4_096; +export const HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES = 64 * 1024; + +function truncateSummaryText(value: unknown, maxLength = HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS) { if (typeof value !== "string") return null; return value.length > maxLength ? value.slice(0, maxLength) : value; } @@ -65,6 +69,26 @@ export function summarizeHeartbeatRunResultJson( } } + for (const key of ["stopReason", "timeoutSource"] as const) { + const value = readCommentText(resultJson[key]); + if (value !== null) { + summary[key] = value; + } + } + + for (const key of ["effectiveTimeoutSec", "effectiveTimeoutMs"] as const) { + const value = readNumericField(resultJson, key); + if (value !== undefined && value !== null) { + summary[key] = value; + } + } + + for (const key of ["timeoutConfigured", "timeoutFired"] as const) { + if (typeof resultJson[key] === "boolean") { + summary[key] = resultJson[key]; + } + } + return Object.keys(summary).length > 0 ? summary : null; } diff --git a/server/src/services/heartbeat-stop-metadata.test.ts b/server/src/services/heartbeat-stop-metadata.test.ts new file mode 100644 index 0000000..fa0b636 --- /dev/null +++ b/server/src/services/heartbeat-stop-metadata.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + buildHeartbeatRunStopMetadata, + mergeHeartbeatRunStopMetadata, + resolveHeartbeatRunTimeoutPolicy, +} from "./heartbeat-stop-metadata.js"; + +describe("heartbeat stop metadata", () => { + it("keeps local coding adapters at no timeout by default", () => { + for (const adapterType of [ + "codex_local", + "claude_local", + "cursor", + "gemini_local", + "opencode_local", + "pi_local", + "process", + ]) { + expect(resolveHeartbeatRunTimeoutPolicy(adapterType, {})).toEqual({ + effectiveTimeoutSec: 0, + timeoutConfigured: false, + timeoutSource: "default", + }); + } + }); + + it("records configured timeout policy and timeout stop reason", () => { + const metadata = buildHeartbeatRunStopMetadata({ + adapterType: "codex_local", + adapterConfig: { timeoutSec: 45 }, + outcome: "timed_out", + errorCode: "timeout", + errorMessage: "Timed out after 45s", + }); + + expect(metadata).toEqual({ + effectiveTimeoutSec: 45, + timeoutConfigured: true, + timeoutSource: "config", + stopReason: "timeout", + timeoutFired: true, + }); + }); + + it("distinguishes budget cancellation from manual cancellation", () => { + expect( + buildHeartbeatRunStopMetadata({ + adapterType: "codex_local", + adapterConfig: {}, + outcome: "cancelled", + errorCode: "cancelled", + errorMessage: "Cancelled due to budget pause", + }).stopReason, + ).toBe("budget_paused"); + + expect( + buildHeartbeatRunStopMetadata({ + adapterType: "codex_local", + adapterConfig: {}, + outcome: "cancelled", + errorCode: "cancelled", + errorMessage: "Cancelled by control plane", + }).stopReason, + ).toBe("cancelled"); + }); + + it("preserves existing result fields when merging stop metadata", () => { + const result = mergeHeartbeatRunStopMetadata( + { summary: "done" }, + buildHeartbeatRunStopMetadata({ + adapterType: "openclaw_gateway", + adapterConfig: {}, + outcome: "succeeded", + }), + ); + + expect(result).toMatchObject({ + summary: "done", + stopReason: "completed", + effectiveTimeoutSec: 120, + timeoutConfigured: true, + timeoutSource: "default", + timeoutFired: false, + }); + }); +}); diff --git a/server/src/services/heartbeat-stop-metadata.ts b/server/src/services/heartbeat-stop-metadata.ts new file mode 100644 index 0000000..e04c3b9 --- /dev/null +++ b/server/src/services/heartbeat-stop-metadata.ts @@ -0,0 +1,119 @@ +export type HeartbeatRunOutcome = "succeeded" | "failed" | "cancelled" | "timed_out"; + +export type HeartbeatRunStopReason = + | "completed" + | "timeout" + | "cancelled" + | "budget_paused" + | "paused" + | "process_lost" + | "adapter_failed"; + +export interface HeartbeatRunTimeoutPolicy { + effectiveTimeoutSec: number | null; + effectiveTimeoutMs?: number | null; + timeoutConfigured: boolean; + timeoutSource: "config" | "default" | "unknown"; +} + +export interface HeartbeatRunStopMetadata extends HeartbeatRunTimeoutPolicy { + stopReason: HeartbeatRunStopReason; + timeoutFired: boolean; +} + +function readFiniteNumber(value: unknown): number | null { + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + if (typeof value === "string") { + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function hasOwn(record: Record, key: string) { + return Object.prototype.hasOwnProperty.call(record, key); +} + +function defaultTimeoutSecForAdapter(adapterType: string) { + return adapterType === "openclaw_gateway" ? 120 : 0; +} + +export function resolveHeartbeatRunTimeoutPolicy( + adapterType: string, + adapterConfig: Record | null | undefined, +): HeartbeatRunTimeoutPolicy { + const config = adapterConfig ?? {}; + + if (adapterType === "http") { + const hasTimeoutMs = hasOwn(config, "timeoutMs"); + const rawTimeoutMs = hasTimeoutMs ? readFiniteNumber(config.timeoutMs) : 0; + const timeoutMs = Math.max(0, Math.floor(rawTimeoutMs ?? 0)); + return { + effectiveTimeoutSec: timeoutMs / 1000, + effectiveTimeoutMs: timeoutMs, + timeoutConfigured: timeoutMs > 0, + timeoutSource: hasTimeoutMs ? "config" : "default", + }; + } + + const hasTimeoutSec = hasOwn(config, "timeoutSec"); + const defaultTimeoutSec = defaultTimeoutSecForAdapter(adapterType); + const rawTimeoutSec = hasTimeoutSec ? readFiniteNumber(config.timeoutSec) : defaultTimeoutSec; + const timeoutSec = Math.max(0, Math.floor(rawTimeoutSec ?? defaultTimeoutSec)); + + return { + effectiveTimeoutSec: timeoutSec, + timeoutConfigured: timeoutSec > 0, + timeoutSource: hasTimeoutSec ? "config" : "default", + }; +} + +export function inferHeartbeatRunStopReason(input: { + outcome: HeartbeatRunOutcome; + errorCode?: string | null; + errorMessage?: string | null; +}): HeartbeatRunStopReason { + if (input.outcome === "succeeded") return "completed"; + if (input.outcome === "timed_out") return "timeout"; + if (input.outcome === "failed" && input.errorCode === "process_lost") return "process_lost"; + if (input.outcome === "cancelled") { + const message = (input.errorMessage ?? "").toLowerCase(); + if (message.includes("budget")) return "budget_paused"; + if (message.includes("pause") || message.includes("paused")) return "paused"; + return "cancelled"; + } + return "adapter_failed"; +} + +export function buildHeartbeatRunStopMetadata(input: { + adapterType: string; + adapterConfig: Record | null | undefined; + outcome: HeartbeatRunOutcome; + errorCode?: string | null; + errorMessage?: string | null; +}): HeartbeatRunStopMetadata { + const timeoutPolicy = resolveHeartbeatRunTimeoutPolicy(input.adapterType, input.adapterConfig); + const stopReason = inferHeartbeatRunStopReason(input); + return { + ...timeoutPolicy, + stopReason, + timeoutFired: stopReason === "timeout", + }; +} + +export function mergeHeartbeatRunStopMetadata( + resultJson: Record | null | undefined, + metadata: HeartbeatRunStopMetadata, +): Record { + return { + ...(resultJson ?? {}), + stopReason: metadata.stopReason, + effectiveTimeoutSec: metadata.effectiveTimeoutSec, + timeoutConfigured: metadata.timeoutConfigured, + timeoutSource: metadata.timeoutSource, + timeoutFired: metadata.timeoutFired, + ...(metadata.effectiveTimeoutMs != null ? { effectiveTimeoutMs: metadata.effectiveTimeoutMs } : {}), + }; +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 24ecd6d..fbf35f7 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2,21 +2,35 @@ import fs from "node:fs/promises"; import path from "node:path"; import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; -import { and, asc, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { and, asc, desc, eq, getTableColumns, gt, inArray, isNull, lte, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "@taskcore/db"; -import type { BillingType, ExecutionWorkspace, ExecutionWorkspaceConfig, WakeReason } from "@taskcore/shared"; +import { + AGENT_DEFAULT_MAX_CONCURRENT_RUNS, + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + type BillingType, + type ExecutionWorkspace, + type ExecutionWorkspaceConfig, + type RunLivenessState, +} from "@taskcore/shared"; import { agents, agentRuntimeState, agentTaskSessions, agentWakeupRequests, + activityLog, companySkills as companySkillsTable, + documentRevisions, + issueDocuments, heartbeatRunEvents, heartbeatRuns, issueComments, + issueRelations, issues, + issueWorkProducts, projects, projectWorkspaces, + workspaceOperations, } from "@taskcore/db"; import { conflict, HttpError, notFound } from "../errors.js"; import { logger } from "../middleware/logger.js"; @@ -25,21 +39,34 @@ import { getRunLogStore, type RunLogHandle } from "./run-log-store.js"; import { getServerAdapter, runningProcesses } from "../adapters/index.js"; import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec, UsageSummary } from "../adapters/index.js"; import { createLocalAgentJwt } from "../agent-auth-jwt.js"; -import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; +import { parseObject, asBoolean, asNumber, appendWithByteCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; import { costService } from "./costs.js"; import { trackAgentFirstHeartbeat } from "@taskcore/shared/telemetry"; import { getTelemetryClient } from "../telemetry.js"; import { companySkillService } from "./company-skills.js"; import { budgetService, type BudgetEnforcementScope } from "./budgets.js"; -import { circuitBreakerService } from "./circuitBreakers.js"; import { secretService } from "./secrets.js"; import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js"; import { buildHeartbeatRunIssueComment, + HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS, + HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS, + HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES, mergeHeartbeatRunResultJson, - summarizeHeartbeatRunResultJson, } from "./heartbeat-run-summary.js"; -import { logActivity, type LogActivityInput } from "./activity-log.js"; +import { + buildHeartbeatRunStopMetadata, + mergeHeartbeatRunStopMetadata, +} from "./heartbeat-stop-metadata.js"; +import { + classifyRunLiveness, + type RunLivenessClassificationInput, +} from "./run-liveness.js"; +import { + classifyIssueGraphLiveness, + type IssueLivenessFinding, +} from "./issue-liveness.js"; +import { logActivity, publishPluginDomainEvent, type LogActivityInput } from "./activity-log.js"; import { buildWorkspaceReadyComment, cleanupExecutionWorkspaceArtifacts, @@ -52,6 +79,10 @@ import { sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; import { issueService } from "./issues.js"; +import { + getIssueContinuationSummaryDocument, + refreshIssueContinuationSummary, +} from "./issue-continuation-summary.js"; import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js"; import { workspaceOperationService } from "./workspace-operations.js"; import { isProcessGroupAlive, terminateLocalService } from "./local-service-supervisor.js"; @@ -64,6 +95,13 @@ import { resolveExecutionWorkspaceMode, } from "./execution-workspace-policy.js"; import { instanceSettingsService } from "./instance-settings.js"; +import { + RUN_LIVENESS_CONTINUATION_REASON, + buildRunLivenessContinuationIdempotencyKey, + decideRunLivenessContinuation, + findExistingRunLivenessContinuationWake, + readContinuationAttempt, +} from "./run-continuations.js"; import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js"; import { hasSessionCompactionThresholds, @@ -78,7 +116,11 @@ import { extractSkillMentionIds } from "@taskcore/shared"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024; -const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; +const MAX_RUN_EVENT_PAYLOAD_STRING_CHARS = 16 * 1024; +const MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS = 50; +const MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS = 100; +const MAX_RUN_EVENT_PAYLOAD_DEPTH = 6; +const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10; const DEFERRED_WAKE_CONTEXT_KEY = "_taskcoreWakeContext"; const WAKE_COMMENT_IDS_KEY = "wakeCommentIds"; @@ -92,12 +134,39 @@ const MAX_INLINE_WAKE_COMMENTS = 8; const MAX_INLINE_WAKE_COMMENT_BODY_CHARS = 4_000; const MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS = 12_000; const execFile = promisify(execFileCallback); -const ACTIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running"] as const; +const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const; +const CANCELLABLE_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const; +const HEARTBEAT_RUN_TERMINAL_STATUSES = ["succeeded", "failed", "cancelled", "timed_out"] as const; +const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = ["failed", "cancelled", "timed_out"] as const; +export const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS = [ + 2 * 60 * 1000, + 10 * 60 * 1000, + 30 * 60 * 1000, + 2 * 60 * 60 * 1000, +] as const; +const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_JITTER_RATIO = 0.25; +const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON = "transient_failure"; +const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_WAKE_REASON = "transient_failure_retry"; +const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS = BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length; +type CodexTransientFallbackMode = + | "same_session" + | "safer_invocation" + | "fresh_session" + | "fresh_session_safer_invocation"; + +function resolveCodexTransientFallbackMode(attempt: number): CodexTransientFallbackMode { + if (attempt <= 1) return "same_session"; + if (attempt === 2) return "safer_invocation"; + if (attempt === 3) return "fresh_session"; + return "fresh_session_safer_invocation"; +} +const RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP = new Set(["approval_approved"]); const SESSIONED_LOCAL_ADAPTERS = new Set([ "claude_local", "codex_local", "cursor", "gemini_local", + "hermes_local", "opencode_local", "pi_local", ]); @@ -166,6 +235,26 @@ export function applyRunScopedMentionedSkillKeys( ]); } +export function computeBoundedTransientHeartbeatRetrySchedule( + attempt: number, + now = new Date(), + random: () => number = Math.random, +) { + if (!Number.isInteger(attempt) || attempt <= 0) return null; + const baseDelayMs = BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS[attempt - 1]; + if (typeof baseDelayMs !== "number") return null; + const sample = Math.min(1, Math.max(0, random())); + const jitterMultiplier = 1 + (((sample * 2) - 1) * BOUNDED_TRANSIENT_HEARTBEAT_RETRY_JITTER_RATIO); + const delayMs = Math.max(1_000, Math.round(baseDelayMs * jitterMultiplier)); + return { + attempt, + baseDelayMs, + delayMs, + dueAt: new Date(now.getTime() + delayMs), + maxAttempts: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS, + }; +} + async function resolveRunScopedMentionedSkillKeys(input: { db: Db; companyId: string; @@ -230,6 +319,16 @@ export function applyPersistedExecutionWorkspaceConfig(input: { } else if (input.workspaceConfig?.workspaceRuntime) { nextConfig.workspaceRuntime = { ...input.workspaceConfig.workspaceRuntime }; } + if (input.workspaceConfig?.desiredState === null) { + delete nextConfig.desiredState; + } else if (input.workspaceConfig?.desiredState) { + nextConfig.desiredState = input.workspaceConfig.desiredState; + } + if (input.workspaceConfig?.serviceStates === null) { + delete nextConfig.serviceStates; + } else if (input.workspaceConfig?.serviceStates) { + nextConfig.serviceStates = { ...input.workspaceConfig.serviceStates }; + } } if (input.workspaceConfig && input.mode === "isolated_workspace") { @@ -289,6 +388,22 @@ function buildExecutionWorkspaceConfigSnapshot(config: Record): const workspaceRuntime = parseObject(config.workspaceRuntime); snapshot.workspaceRuntime = Object.keys(workspaceRuntime).length > 0 ? workspaceRuntime : null; } + if ("desiredState" in config) { + snapshot.desiredState = + config.desiredState === "running" || config.desiredState === "stopped" || config.desiredState === "manual" + ? config.desiredState + : null; + } + if ("serviceStates" in config) { + const serviceStates = parseObject(config.serviceStates); + snapshot.serviceStates = Object.keys(serviceStates).length > 0 + ? Object.fromEntries( + Object.entries(serviceStates).filter(([, state]) => + state === "running" || state === "stopped" || state === "manual" + ), + ) as ExecutionWorkspaceConfig["serviceStates"] + : null; + } const hasSnapshot = Object.values(snapshot).some((value) => { if (value === null) return false; @@ -379,7 +494,6 @@ const heartbeatRunListColumns = { exitCode: heartbeatRuns.exitCode, signal: heartbeatRuns.signal, usageJson: heartbeatRuns.usageJson, - resultJson: heartbeatRuns.resultJson, sessionIdBefore: heartbeatRuns.sessionIdBefore, sessionIdAfter: heartbeatRuns.sessionIdAfter, logStore: heartbeatRuns.logStore, @@ -396,11 +510,107 @@ const heartbeatRunListColumns = { processStartedAt: heartbeatRuns.processStartedAt, retryOfRunId: heartbeatRuns.retryOfRunId, processLossRetryCount: heartbeatRuns.processLossRetryCount, - contextSnapshot: heartbeatRuns.contextSnapshot, + scheduledRetryAt: heartbeatRuns.scheduledRetryAt, + scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt, + scheduledRetryReason: heartbeatRuns.scheduledRetryReason, + livenessState: heartbeatRuns.livenessState, + livenessReason: heartbeatRuns.livenessReason, + continuationAttempt: heartbeatRuns.continuationAttempt, + lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, + nextAction: heartbeatRuns.nextAction, createdAt: heartbeatRuns.createdAt, updatedAt: heartbeatRuns.updatedAt, } as const; +const heartbeatRunListContextColumns = { + contextIssueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("contextIssueId"), + contextTaskId: sql`${heartbeatRuns.contextSnapshot} ->> 'taskId'`.as("contextTaskId"), + contextTaskKey: sql`${heartbeatRuns.contextSnapshot} ->> 'taskKey'`.as("contextTaskKey"), + contextCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"), + contextWakeCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as("contextWakeCommentId"), + contextWakeReason: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeReason'`.as("contextWakeReason"), + contextWakeSource: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeSource'`.as("contextWakeSource"), + contextWakeTriggerDetail: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeTriggerDetail'`.as("contextWakeTriggerDetail"), +} as const; + +const heartbeatRunListResultColumns = { + resultSummary: sql`left(${heartbeatRuns.resultJson} ->> 'summary', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultSummary"), + resultResult: sql`left(${heartbeatRuns.resultJson} ->> 'result', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultResult"), + resultMessage: sql`left(${heartbeatRuns.resultJson} ->> 'message', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultMessage"), + resultError: sql`left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultError"), + resultTotalCostUsd: sql`${heartbeatRuns.resultJson} ->> 'total_cost_usd'`.as("resultTotalCostUsd"), + resultCostUsd: sql`${heartbeatRuns.resultJson} ->> 'cost_usd'`.as("resultCostUsd"), + resultCostUsdCamel: sql`${heartbeatRuns.resultJson} ->> 'costUsd'`.as("resultCostUsdCamel"), +} as const; + +const heartbeatRunSafeResultJsonColumn = sql | null>` + case + when ${heartbeatRuns.resultJson} is null then null + when pg_column_size(${heartbeatRuns.resultJson}) <= ${HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES} + then ${heartbeatRuns.resultJson} + else jsonb_strip_nulls( + jsonb_build_object( + 'summary', left(${heartbeatRuns.resultJson} ->> 'summary', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'result', left(${heartbeatRuns.resultJson} ->> 'result', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'message', left(${heartbeatRuns.resultJson} ->> 'message', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'error', left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'stdout', left(${heartbeatRuns.resultJson} ->> 'stdout', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}), + 'stderr', left(${heartbeatRuns.resultJson} ->> 'stderr', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}), + 'stdoutTruncated', case + when length(${heartbeatRuns.resultJson} ->> 'stdout') > ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS} + then to_jsonb(true) + else null + end, + 'stderrTruncated', case + when length(${heartbeatRuns.resultJson} ->> 'stderr') > ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS} + then to_jsonb(true) + else null + end, + 'costUsd', coalesce( + ${heartbeatRuns.resultJson} -> 'costUsd', + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'total_cost_usd' + ), + 'cost_usd', coalesce( + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'costUsd', + ${heartbeatRuns.resultJson} -> 'total_cost_usd' + ), + 'total_cost_usd', coalesce( + ${heartbeatRuns.resultJson} -> 'total_cost_usd', + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'costUsd' + ), + 'truncated', true, + 'truncationReason', 'oversized_result_json', + 'originalSizeBytes', pg_column_size(${heartbeatRuns.resultJson}) + ) + ) + end +`.as("resultJson"); + +const heartbeatRunSafeColumns = { + ...getTableColumns(heartbeatRuns), + processGroupId: heartbeatRunProcessGroupIdColumn, + resultJson: heartbeatRunSafeResultJsonColumn, +} as const; + +const heartbeatRunSqlAsciiSafeColumns = { + ...getTableColumns(heartbeatRuns), + processGroupId: heartbeatRunProcessGroupIdColumn, + error: sql`NULL`.as("error"), + resultJson: sql | null>`NULL`.as("resultJson"), + stdoutExcerpt: sql`NULL`.as("stdoutExcerpt"), + stderrExcerpt: sql`NULL`.as("stderrExcerpt"), +} as const; + +const heartbeatRunLogAccessColumns = { + id: heartbeatRuns.id, + companyId: heartbeatRuns.companyId, + logStore: heartbeatRuns.logStore, + logRef: heartbeatRuns.logRef, +} as const; + const heartbeatRunIssueSummaryColumns = { id: heartbeatRuns.id, status: heartbeatRuns.status, @@ -410,11 +620,90 @@ const heartbeatRunIssueSummaryColumns = { finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, agentId: heartbeatRuns.agentId, + livenessState: heartbeatRuns.livenessState, + livenessReason: heartbeatRuns.livenessReason, + continuationAttempt: heartbeatRuns.continuationAttempt, + lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, + nextAction: heartbeatRuns.nextAction, issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"), } as const; function appendExcerpt(prev: string, chunk: string) { - return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES); + return appendWithByteCap(prev, chunk, MAX_EXCERPT_BYTES); +} + +function truncateRunEventString(value: string) { + if (value.length <= MAX_RUN_EVENT_PAYLOAD_STRING_CHARS) return value; + const omittedChars = value.length - MAX_RUN_EVENT_PAYLOAD_STRING_CHARS; + return `${value.slice(0, MAX_RUN_EVENT_PAYLOAD_STRING_CHARS)}\n[truncated ${omittedChars} chars]`; +} + +function boundRunEventValue(value: unknown, depth: number, seen: WeakSet): unknown { + if (typeof value === "string") { + return truncateRunEventString(value); + } + if ( + value === null + || typeof value === "number" + || typeof value === "boolean" + ) { + return value; + } + if (value instanceof Date) { + return value.toISOString(); + } + if (Array.isArray(value)) { + if (depth >= MAX_RUN_EVENT_PAYLOAD_DEPTH) { + return { + _truncated: true, + type: "array", + originalLength: value.length, + }; + } + const bounded = value + .slice(0, MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS) + .map((entry) => boundRunEventValue(entry, depth + 1, seen)); + if (value.length > MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS) { + bounded.push({ + _truncated: true, + omittedItems: value.length - MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS, + }); + } + return bounded; + } + if (typeof value !== "object" || value === undefined) { + return null; + } + if (seen.has(value)) { + return "[Circular]"; + } + seen.add(value); + const entries = Object.entries(value as Record); + if (depth >= MAX_RUN_EVENT_PAYLOAD_DEPTH) { + const bounded = { + _truncated: true, + type: "object", + keys: entries.map(([key]) => key).slice(0, 20), + }; + seen.delete(value); + return bounded; + } + + const out: Record = {}; + for (const [key, entryValue] of entries.slice(0, MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS)) { + out[key] = boundRunEventValue(entryValue, depth + 1, seen); + } + if (entries.length > MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS) { + out._truncated = true; + out._omittedKeys = entries.length - MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS; + } + seen.delete(value); + return out; +} + +export function boundHeartbeatRunEventPayloadForStorage(payload: Record): Record { + const bounded = boundRunEventValue(payload, 0, new WeakSet()); + return parseObject(bounded) ?? { _truncated: true }; } function redactInlineBase64ImageData(chunk: string) { @@ -460,7 +749,6 @@ async function withAgentStartLock(agentId: string, fn: () => Promise) { interface WakeupOptions { source?: "timer" | "assignment" | "on_demand" | "automation"; triggerDetail?: "manual" | "ping" | "callback" | "system"; - wakeReason?: WakeReason; reason?: string | null; payload?: Record | null; idempotencyKey?: string | null; @@ -521,6 +809,103 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } +export function summarizeHeartbeatRunContextSnapshot( + contextSnapshot: Record | null | undefined, +): Record | null { + const summary: Record = {}; + const allowedKeys = [ + "issueId", + "taskId", + "taskKey", + "commentId", + "wakeCommentId", + "wakeReason", + "wakeSource", + "wakeTriggerDetail", + ] as const; + + for (const key of allowedKeys) { + const value = readNonEmptyString(contextSnapshot?.[key]); + if (value) summary[key] = value; + } + + return Object.keys(summary).length > 0 ? summary : null; +} + +export function summarizeHeartbeatRunListResultJson(input: { + summary?: string | null; + result?: string | null; + message?: string | null; + error?: string | null; + totalCostUsd?: string | null; + costUsd?: string | null; + costUsdCamel?: string | null; +}): Record | null { + const summary: Record = {}; + for (const [key, value] of [ + ["summary", input.summary], + ["result", input.result], + ["message", input.message], + ["error", input.error], + ] as const) { + const normalized = readNonEmptyString(value); + if (normalized) summary[key] = normalized; + } + + for (const [key, value] of [ + ["total_cost_usd", input.totalCostUsd], + ["cost_usd", input.costUsd], + ["costUsd", input.costUsdCamel], + ] as const) { + const normalized = readNonEmptyString(value); + if (!normalized) continue; + const parsed = Number(normalized); + if (Number.isFinite(parsed)) summary[key] = parsed; + } + + return Object.keys(summary).length > 0 ? summary : null; +} + +function summarizeRunFailureForIssueComment( + run: Pick | null | undefined, +) { + if (!run) return null; + + const errorCode = readNonEmptyString(run.errorCode)?.trim() ?? null; + const rawError = readNonEmptyString(run.error)?.trim() ?? null; + const apiMessageMatch = rawError?.match(/"message"\s*:\s*"([^"]+)"/); + const firstLine = rawError + ?.split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? null; + const summarySource = apiMessageMatch?.[1] ?? firstLine; + const summary = + summarySource && summarySource.length > 240 + ? `${summarySource.slice(0, 237)}...` + : summarySource; + + if (errorCode && summary) return ` Latest retry failure: \`${errorCode}\` - ${summary}.`; + if (errorCode) return ` Latest retry failure: \`${errorCode}\`.`; + if (summary) return ` Latest retry failure: ${summary}.`; + return null; +} + +function didAutomaticRecoveryFail( + latestRun: Pick | null, + expectedRetryReason: "assignment_recovery" | "issue_continuation_needed", +) { + if (!latestRun) return false; + + const latestContext = parseObject(latestRun.contextSnapshot); + const latestRetryReason = readNonEmptyString(latestContext.retryReason); + return ( + latestRetryReason === expectedRetryReason && + UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES.includes( + latestRun.status as (typeof UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES)[number], + ) + ); +} + function normalizeLedgerBillingType(value: unknown): BillingType { const raw = readNonEmptyString(value); switch (raw) { @@ -603,8 +988,8 @@ export function buildExplicitResumeSessionOverride(input: { ); const taskSessionDisplayId = truncateDisplayId( input.taskSession?.sessionDisplayId ?? - (input.sessionCodec.getDisplayId ? input.sessionCodec.getDisplayId(taskSessionParams) : null) ?? - readNonEmptyString(taskSessionParams?.sessionId), + (input.sessionCodec.getDisplayId ? input.sessionCodec.getDisplayId(taskSessionParams) : null) ?? + readNonEmptyString(taskSessionParams?.sessionId), ); const canReuseTaskSessionParams = input.taskSession != null && @@ -854,6 +1239,51 @@ function shouldRequireIssueCommentForWake( ); } +const BLOCKED_INTERACTION_WAKE_REASONS = new Set([ + "issue_commented", + "issue_reopened_via_comment", + "issue_comment_mentioned", +]); + +function allowsBlockedIssueInteractionWake( + contextSnapshot: Record | null | undefined, +) { + const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); + if (!wakeReason || !BLOCKED_INTERACTION_WAKE_REASONS.has(wakeReason)) return false; + return Boolean(deriveCommentId(contextSnapshot, null)); +} + +async function listUnresolvedBlockerSummaries( + dbOrTx: Pick, + companyId: string, + issueId: string, + unresolvedBlockerIssueIds: string[], +) { + const ids = [...new Set(unresolvedBlockerIssueIds.filter(Boolean))]; + if (ids.length === 0) return []; + return dbOrTx + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + }) + .from(issueRelations) + .innerJoin(issues, eq(issueRelations.issueId, issues.id)) + .where( + and( + eq(issueRelations.companyId, companyId), + eq(issueRelations.type, "blocks"), + eq(issueRelations.relatedIssueId, issueId), + inArray(issues.id, ids), + ), + ) + .orderBy(asc(issues.title)); +} + export function formatRuntimeWorkspaceWarningLog(warning: string) { return { stream: "stdout" as const, @@ -878,9 +1308,11 @@ function shouldAutoCheckoutIssueForWake(input: { contextSnapshot: Record | null | undefined; issueStatus: string | null; issueAssigneeAgentId: string | null; + isDependencyReady: boolean; agentId: string; }) { if (input.issueAssigneeAgentId !== input.agentId) return false; + if (!input.isDependencyReady) return false; const issueStatus = readNonEmptyString(input.issueStatus); if ( @@ -900,6 +1332,15 @@ function shouldAutoCheckoutIssueForWake(input: { return true; } +function shouldQueueFollowupForRunningIssueWake(input: { + contextSnapshot: Record | null | undefined; + wakeCommentId: string | null; +}) { + if (input.wakeCommentId) return true; + const wakeReason = readNonEmptyString(input.contextSnapshot?.wakeReason); + return Boolean(wakeReason && RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP.has(wakeReason)); +} + function isCheckoutConflictError(error: unknown): boolean { return error instanceof HttpError && error.status === 409 && error.message === "Issue checkout conflict"; } @@ -1044,33 +1485,42 @@ async function buildTaskcoreWakePayload(input: { db: Db; companyId: string; contextSnapshot: Record; + continuationSummary?: + | { + key: string; + title: string | null; + body: string; + updatedAt: Date; + } + | null; issueSummary?: - | { - id: string; - identifier: string | null; - title: string; - status: string; - priority: string; - } - | null; + | { + id: string; + identifier: string | null; + title: string; + status: string; + priority: string; + } + | null; }) { const executionStage = parseObject(input.contextSnapshot.executionStage); const commentIds = extractWakeCommentIds(input.contextSnapshot); const issueId = readNonEmptyString(input.contextSnapshot.issueId); + const continuationSummary = input.continuationSummary ?? null; const issueSummary = input.issueSummary ?? (issueId ? await input.db - .select({ - id: issues.id, - identifier: issues.identifier, - title: issues.title, - status: issues.status, - priority: issues.priority, - }) - .from(issues) - .where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId))) - .then((rows) => rows[0] ?? null) + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId))) + .then((rows) => rows[0] ?? null) : null); if (commentIds.length === 0 && Object.keys(executionStage).length === 0 && !issueSummary) return null; @@ -1078,21 +1528,21 @@ async function buildTaskcoreWakePayload(input: { commentIds.length === 0 ? [] : await input.db - .select({ - id: issueComments.id, - issueId: issueComments.issueId, - body: issueComments.body, - authorAgentId: issueComments.authorAgentId, - authorUserId: issueComments.authorUserId, - createdAt: issueComments.createdAt, - }) - .from(issueComments) - .where( - and( - eq(issueComments.companyId, input.companyId), - inArray(issueComments.id, commentIds), - ), - ); + .select({ + id: issueComments.id, + issueId: issueComments.issueId, + body: issueComments.body, + authorAgentId: issueComments.authorAgentId, + authorUserId: issueComments.authorUserId, + createdAt: issueComments.createdAt, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, input.companyId), + inArray(issueComments.id, commentIds), + ), + ); const commentsById = new Map(commentRows.map((comment) => [comment.id, comment])); const comments: Array> = []; @@ -1142,15 +1592,51 @@ async function buildTaskcoreWakePayload(input: { reason: readNonEmptyString(input.contextSnapshot.wakeReason), issue: issueSummary ? { - id: issueSummary.id, - identifier: issueSummary.identifier, - title: issueSummary.title, - status: issueSummary.status, - priority: issueSummary.priority, - } + id: issueSummary.id, + identifier: issueSummary.identifier, + title: issueSummary.title, + status: issueSummary.status, + priority: issueSummary.priority, + } + : null, + childIssueSummaries: Array.isArray(input.contextSnapshot.childIssueSummaries) + ? input.contextSnapshot.childIssueSummaries + : [], + childIssueSummaryTruncated: input.contextSnapshot.childIssueSummaryTruncated === true, + livenessContinuation: readNonEmptyString(input.contextSnapshot.livenessContinuationState) || + readNonEmptyString(input.contextSnapshot.livenessContinuationInstruction) || + readNonEmptyString(input.contextSnapshot.livenessContinuationSourceRunId) || + typeof input.contextSnapshot.livenessContinuationAttempt === "number" + ? { + attempt: input.contextSnapshot.livenessContinuationAttempt, + maxAttempts: input.contextSnapshot.livenessContinuationMaxAttempts, + sourceRunId: readNonEmptyString(input.contextSnapshot.livenessContinuationSourceRunId), + state: readNonEmptyString(input.contextSnapshot.livenessContinuationState), + reason: readNonEmptyString(input.contextSnapshot.livenessContinuationReason), + instruction: readNonEmptyString(input.contextSnapshot.livenessContinuationInstruction), + } : null, checkedOutByHarness: input.contextSnapshot[TASKCORE_HARNESS_CHECKOUT_KEY] === true, + dependencyBlockedInteraction: input.contextSnapshot.dependencyBlockedInteraction === true, + unresolvedBlockerIssueIds: Array.isArray(input.contextSnapshot.unresolvedBlockerIssueIds) + ? input.contextSnapshot.unresolvedBlockerIssueIds.filter((value): value is string => typeof value === "string" && value.length > 0) + : [], + unresolvedBlockerSummaries: Array.isArray(input.contextSnapshot.unresolvedBlockerSummaries) + ? input.contextSnapshot.unresolvedBlockerSummaries + : [], executionStage: Object.keys(executionStage).length > 0 ? executionStage : null, + continuationSummary: continuationSummary + ? { + key: continuationSummary.key, + title: continuationSummary.title, + body: + continuationSummary.body.length > 4_000 + ? continuationSummary.body.slice(0, 4_000) + : continuationSummary.body, + bodyTruncated: continuationSummary.body.length > 4_000, + updatedAt: continuationSummary.updatedAt.toISOString(), + } + : null, commentIds, latestCommentId: commentIds[commentIds.length - 1] ?? null, comments, @@ -1176,6 +1662,14 @@ function isTrackedLocalChildProcessAdapter(adapterType: string) { return SESSIONED_LOCAL_ADAPTERS.has(adapterType); } +function isHeartbeatRunTerminalStatus( + status: string | null | undefined, +): status is (typeof HEARTBEAT_RUN_TERMINAL_STATUSES)[number] { + return HEARTBEAT_RUN_TERMINAL_STATUSES.includes( + status as (typeof HEARTBEAT_RUN_TERMINAL_STATUSES)[number], + ); +} + // A positive liveness check means some process currently owns the PID. // On Linux, PIDs can be recycled, so this is a best-effort signal rather // than proof that the original child is still alive. @@ -1307,11 +1801,11 @@ function resolveNextSessionState(input: { const displayId = truncateDisplayId( explicitDisplayId ?? - (codec.getDisplayId ? codec.getDisplayId(deserialized) : null) ?? - readNonEmptyString(deserialized?.sessionId) ?? - (shouldUsePrevious ? previousDisplayId : null) ?? - explicitSessionId ?? - (shouldUsePrevious ? previousLegacySessionId : null), + (codec.getDisplayId ? codec.getDisplayId(deserialized) : null) ?? + readNonEmptyString(deserialized?.sessionId) ?? + (shouldUsePrevious ? previousDisplayId : null) ?? + explicitSessionId ?? + (shouldUsePrevious ? previousLegacySessionId : null), ); const legacySessionId = @@ -1344,6 +1838,26 @@ export function heartbeatService(db: Db) { cancelWorkForScope: cancelBudgetScopeWork, }; const budgets = budgetService(db, budgetHooks); + let unsafeTextProjectionPromise: Promise | null = null; + + async function hasUnsafeTextProjectionDatabase() { + if (!unsafeTextProjectionPromise) { + unsafeTextProjectionPromise = db + .execute(sql`select current_setting('server_encoding') as server_encoding`) + .then((rows) => { + const first = Array.isArray(rows) ? rows[0] : null; + const serverEncoding = typeof first === "object" && first !== null + ? (first as Record).server_encoding + : null; + return typeof serverEncoding === "string" && serverEncoding.toUpperCase() === "SQL_ASCII"; + }) + .catch((err) => { + logger.warn({ err }, "failed to inspect database server encoding; using conservative heartbeat result projection"); + return true; + }); + } + return unsafeTextProjectionPromise; + } async function getAgent(agentId: string) { return db @@ -1353,9 +1867,24 @@ export function heartbeatService(db: Db) { .then((rows) => rows[0] ?? null); } - async function getRun(runId: string) { + async function getRun(runId: string, opts?: { unsafeFullResultJson?: boolean }) { + const safeForLegacyEncoding = !opts?.unsafeFullResultJson && await hasUnsafeTextProjectionDatabase(); return db - .select() + .select( + opts?.unsafeFullResultJson + ? getTableColumns(heartbeatRuns) + : safeForLegacyEncoding + ? heartbeatRunSqlAsciiSafeColumns + : heartbeatRunSafeColumns, + ) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null); + } + + async function getRunLogAccess(runId: string) { + return db + .select(heartbeatRunLogAccessColumns) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, runId)) .then((rows) => rows[0] ?? null); @@ -1423,7 +1952,10 @@ export function heartbeatService(db: Db) { conditions.push(sql`${heartbeatRuns.id} <> ${opts.excludeRunId}`); } return db - .select() + .select({ + id: heartbeatRuns.id, + usageJson: heartbeatRuns.usageJson, + }) .from(heartbeatRuns) .where(and(...conditions)) .orderBy(desc(heartbeatRuns.createdAt)) @@ -1472,6 +2004,7 @@ export function heartbeatService(db: Db) { agent: typeof agents.$inferSelect; sessionId: string | null; issueId: string | null; + continuationSummaryBody?: string | null; }): Promise { const { agent, sessionId, issueId } = input; if (!sessionId) { @@ -1499,8 +2032,8 @@ export function heartbeatService(db: Db) { id: heartbeatRuns.id, createdAt: heartbeatRuns.createdAt, usageJson: heartbeatRuns.usageJson, - resultJson: heartbeatRuns.resultJson, error: heartbeatRuns.error, + ...heartbeatRunListResultColumns, }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.agentId, agent.id), eq(heartbeatRuns.sessionIdAfter, sessionId))) @@ -1525,9 +2058,9 @@ export function heartbeatService(db: Db) { const sessionAgeHours = latestRun && oldestRun ? Math.max( - 0, - (new Date(latestRun.createdAt).getTime() - new Date(oldestRun.createdAt).getTime()) / (1000 * 60 * 60), - ) + 0, + (new Date(latestRun.createdAt).getTime() - new Date(oldestRun.createdAt).getTime()) / (1000 * 60 * 60), + ) : 0; let reason: string | null = null; @@ -1554,7 +2087,15 @@ export function heartbeatService(db: Db) { }; } - const latestSummary = summarizeHeartbeatRunResultJson(latestRun.resultJson); + const latestSummary = summarizeHeartbeatRunListResultJson({ + summary: latestRun?.resultSummary, + result: latestRun?.resultResult, + message: latestRun?.resultMessage, + error: latestRun?.resultError, + totalCostUsd: latestRun?.resultTotalCostUsd, + costUsd: latestRun?.resultCostUsd, + costUsdCamel: latestRun?.resultCostUsdCamel, + }); const latestTextSummary = readNonEmptyString(latestSummary?.summary) ?? readNonEmptyString(latestSummary?.result) ?? @@ -1567,6 +2108,9 @@ export function heartbeatService(db: Db) { issueId ? `- Issue: ${issueId}` : "", `- Rotation reason: ${reason}`, latestTextSummary ? `- Last run summary: ${latestTextSummary}` : "", + input.continuationSummaryBody + ? `- Issue continuation summary: ${input.continuationSummaryBody.slice(0, 1_500)}` + : "", "Continue from the current task state. Rebuild only the minimum context you need.", ] .filter(Boolean) @@ -1597,8 +2141,8 @@ export function heartbeatService(db: Db) { ); return truncateDisplayId( existingTaskSession?.sessionDisplayId ?? - (codec.getDisplayId ? codec.getDisplayId(parsedParams) : null) ?? - readNonEmptyString(parsedParams?.sessionId), + (codec.getDisplayId ? codec.getDisplayId(parsedParams) : null) ?? + readNonEmptyString(parsedParams?.sessionId), ); } @@ -1668,13 +2212,13 @@ export function heartbeatService(db: Db) { const contextProjectWorkspaceId = readNonEmptyString(context.projectWorkspaceId); const issueProjectRef = issueId ? await db - .select({ - projectId: issues.projectId, - projectWorkspaceId: issues.projectWorkspaceId, - }) - .from(issues) - .where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) - .then((rows) => rows[0] ?? null) + .select({ + projectId: issues.projectId, + projectWorkspaceId: issues.projectWorkspaceId, + }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) + .then((rows) => rows[0] ?? null) : null; const issueProjectId = issueProjectRef?.projectId ?? null; const preferredProjectWorkspaceId = @@ -1685,15 +2229,15 @@ export function heartbeatService(db: Db) { const unorderedProjectWorkspaceRows = workspaceProjectId ? await db - .select() - .from(projectWorkspaces) - .where( - and( - eq(projectWorkspaces.companyId, agent.companyId), - eq(projectWorkspaces.projectId, workspaceProjectId), - ), - ) - .orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)) + .select() + .from(projectWorkspaces) + .where( + and( + eq(projectWorkspaces.companyId, agent.companyId), + eq(projectWorkspaces.projectId, workspaceProjectId), + ), + ) + .orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)) : []; const projectWorkspaceRows = prioritizeProjectWorkspaceCandidatesForRun( unorderedProjectWorkspaceRows, @@ -1974,11 +2518,50 @@ export function heartbeatService(db: Db) { finishedAt: updated.finishedAt ? new Date(updated.finishedAt).toISOString() : null, }, }); + publishRunLifecyclePluginEvent(updated); } return updated; } + function publishRunLifecyclePluginEvent(run: typeof heartbeatRuns.$inferSelect) { + const eventType = + run.status === "running" + ? "agent.run.started" + : run.status === "succeeded" + ? "agent.run.finished" + : run.status === "failed" || run.status === "timed_out" + ? "agent.run.failed" + : run.status === "cancelled" + ? "agent.run.cancelled" + : null; + if (!eventType) return; + publishPluginDomainEvent({ + eventId: randomUUID(), + eventType, + occurredAt: new Date().toISOString(), + actorId: run.agentId, + actorType: "agent", + entityId: run.id, + entityType: "heartbeat_run", + companyId: run.companyId, + payload: { + runId: run.id, + agentId: run.agentId, + status: run.status, + invocationSource: run.invocationSource, + triggerDetail: run.triggerDetail, + error: run.error ?? null, + errorCode: run.errorCode ?? null, + issueId: typeof run.contextSnapshot === "object" && run.contextSnapshot !== null + ? (run.contextSnapshot as Record).issueId ?? null + : null, + startedAt: run.startedAt ? new Date(run.startedAt).toISOString() : null, + finishedAt: run.finishedAt ? new Date(run.finishedAt).toISOString() : null, + }, + }); + } + async function setWakeupStatus( wakeupRequestId: string | null | undefined, status: string, @@ -1991,47 +2574,180 @@ export function heartbeatService(db: Db) { .where(eq(agentWakeupRequests.id, wakeupRequestId)); } - async function appendRunEvent( - run: typeof heartbeatRuns.$inferSelect, - seq: number, - event: { - eventType: string; - stream?: "system" | "stdout" | "stderr"; - level?: "info" | "warn" | "error"; - color?: string; - message?: string; - payload?: Record; - }, - ) { - const currentUserRedactionOptions = await getCurrentUserRedactionOptions(); - const sanitizedMessage = event.message - ? redactCurrentUserText(event.message, currentUserRedactionOptions) - : event.message; - const sanitizedPayload = event.payload - ? redactCurrentUserValue(event.payload, currentUserRedactionOptions) - : event.payload; - - await db.insert(heartbeatRunEvents).values({ - companyId: run.companyId, - runId: run.id, - agentId: run.agentId, - seq, - eventType: event.eventType, - stream: event.stream, - level: event.level, - color: event.color, - message: sanitizedMessage, - payload: sanitizedPayload, + async function addContinuationExhaustedCommentOnce(input: { + run: typeof heartbeatRuns.$inferSelect; + issueId: string; + comment: string; + }) { + const existing = await db + .select({ id: issueComments.id }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, input.run.companyId), + eq(issueComments.issueId, input.issueId), + eq(issueComments.createdByRunId, input.run.id), + sql`${issueComments.body} like 'Bounded liveness continuation exhausted%'`, + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + if (existing) return; + await issuesSvc.addComment(input.issueId, input.comment, { + agentId: input.run.agentId, + runId: input.run.id, }); + } - publishLiveEvent({ - companyId: run.companyId, - type: "heartbeat.run.event", - payload: { - runId: run.id, - agentId: run.agentId, - seq, - eventType: event.eventType, + async function handleRunLivenessContinuation(run: typeof heartbeatRuns.$inferSelect) { + const livenessState = run.livenessState as RunLivenessState | null; + if (livenessState !== "plan_only" && livenessState !== "empty_response") return; + + const context = parseObject(run.contextSnapshot); + const issueId = readNonEmptyString(context.issueId); + if (!issueId) return; + + const [issue, agent] = await Promise.all([ + db + .select({ + id: issues.id, + companyId: issues.companyId, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + executionState: issues.executionState, + projectId: issues.projectId, + }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))) + .then((rows) => rows[0] ?? null), + db + .select({ + id: agents.id, + companyId: agents.companyId, + status: agents.status, + }) + .from(agents) + .where(eq(agents.id, run.agentId)) + .then((rows) => rows[0] ?? null), + ]); + + const budgetBlock = + issue && agent + ? await budgets.getInvocationBlock(issue.companyId, agent.id, { + issueId: issue.id, + projectId: issue.projectId, + }) + : null; + + const nextAttempt = readContinuationAttempt(run.continuationAttempt) + 1; + const idempotencyKey = issue + ? buildRunLivenessContinuationIdempotencyKey({ + issueId: issue.id, + sourceRunId: run.id, + livenessState, + nextAttempt, + }) + : null; + const existingWake = idempotencyKey + ? await findExistingRunLivenessContinuationWake(db, { + companyId: run.companyId, + idempotencyKey, + }) + : null; + + const decision = decideRunLivenessContinuation({ + run, + issue, + agent, + livenessState, + livenessReason: run.livenessReason, + nextAction: run.nextAction, + budgetBlocked: Boolean(budgetBlock), + idempotentWakeExists: Boolean(existingWake), + }); + + if (decision.kind === "exhausted") { + await setRunStatus(run.id, run.status, { + livenessReason: `${run.livenessReason ?? "Run ended without concrete progress"}; continuation attempts exhausted`, + }); + await addContinuationExhaustedCommentOnce({ + run, + issueId, + comment: decision.comment, + }); + return; + } + + if (decision.kind !== "enqueue") return; + + const continuationRun = await enqueueWakeup(run.agentId, { + source: "automation", + triggerDetail: "system", + reason: RUN_LIVENESS_CONTINUATION_REASON, + payload: decision.payload, + contextSnapshot: decision.contextSnapshot, + idempotencyKey: decision.idempotencyKey, + requestedByActorType: "system", + requestedByActorId: "heartbeat", + }); + + if (continuationRun) { + await db + .update(heartbeatRuns) + .set({ + continuationAttempt: decision.nextAttempt, + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, continuationRun.id)); + } + } + + async function appendRunEvent( + run: typeof heartbeatRuns.$inferSelect, + seq: number, + event: { + eventType: string; + stream?: "system" | "stdout" | "stderr"; + level?: "info" | "warn" | "error"; + color?: string; + message?: string; + payload?: Record; + }, + ) { + const currentUserRedactionOptions = await getCurrentUserRedactionOptions(); + const sanitizedMessage = event.message + ? redactCurrentUserText(event.message, currentUserRedactionOptions) + : event.message; + const boundedPayload = event.payload + ? boundHeartbeatRunEventPayloadForStorage(event.payload) + : event.payload; + const sanitizedPayload = boundedPayload + ? redactCurrentUserValue(boundedPayload, currentUserRedactionOptions) + : boundedPayload; + + await db.insert(heartbeatRunEvents).values({ + companyId: run.companyId, + runId: run.id, + agentId: run.agentId, + seq, + eventType: event.eventType, + stream: event.stream, + level: event.level, + color: event.color, + message: sanitizedMessage, + payload: sanitizedPayload, + }); + + publishLiveEvent({ + companyId: run.companyId, + type: "heartbeat.run.event", + payload: { + runId: run.id, + agentId: run.agentId, + seq, + eventType: event.eventType, stream: event.stream ?? null, level: event.level ?? null, color: event.color ?? null, @@ -2119,6 +2835,47 @@ export function heartbeatService(db: Db) { .then((rows) => rows[0] ?? null); } + async function refreshContinuationSummaryForRun( + run: typeof heartbeatRuns.$inferSelect, + agent: typeof agents.$inferSelect, + ) { + const contextSnapshot = parseObject(run.contextSnapshot); + const issueId = readNonEmptyString(contextSnapshot.issueId); + if (!issueId) return null; + try { + return await refreshIssueContinuationSummary({ + db, + issueId, + run: { + id: run.id, + status: run.status, + error: run.error, + errorCode: run.errorCode, + resultJson: run.resultJson as Record | null, + stdoutExcerpt: run.stdoutExcerpt, + stderrExcerpt: run.stderrExcerpt, + finishedAt: run.finishedAt, + }, + agent: { + id: agent.id, + name: agent.name, + adapterType: agent.adapterType, + }, + }); + } catch (err) { + logger.warn( + { + err, + runId: run.id, + issueId, + agentId: agent.id, + }, + "failed to refresh issue continuation summary", + ); + return null; + } + } + async function enqueueMissingIssueCommentRetry( run: typeof heartbeatRuns.$inferSelect, agent: typeof agents.$inferSelect, @@ -2407,6 +3164,219 @@ export function heartbeatService(db: Db) { return queued; } + async function scheduleBoundedRetryForRun( + run: typeof heartbeatRuns.$inferSelect, + agent: typeof agents.$inferSelect, + opts?: { + now?: Date; + random?: () => number; + retryReason?: string; + wakeReason?: string; + }, + ) { + const now = opts?.now ?? new Date(); + const retryReason = opts?.retryReason ?? BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON; + const wakeReason = opts?.wakeReason ?? BOUNDED_TRANSIENT_HEARTBEAT_RETRY_WAKE_REASON; + const nextAttempt = (run.scheduledRetryAttempt ?? 0) + 1; + const schedule = computeBoundedTransientHeartbeatRetrySchedule(nextAttempt, now, opts?.random); + const codexTransientFallbackMode = + agent.adapterType === "codex_local" && retryReason === BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON && run.errorCode === "codex_transient_upstream" + ? resolveCodexTransientFallbackMode(nextAttempt) + : null; + + if (!schedule) { + await appendRunEvent(run, await nextRunEventSeq(run.id), { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: `Bounded retry exhausted after ${run.scheduledRetryAttempt ?? 0} scheduled attempts; no further automatic retry will be queued`, + payload: { + retryReason, + scheduledRetryAttempt: run.scheduledRetryAttempt ?? 0, + maxAttempts: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS, + }, + }); + return { + outcome: "retry_exhausted" as const, + attempt: nextAttempt, + maxAttempts: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS, + }; + } + + const contextSnapshot = parseObject(run.contextSnapshot); + const issueId = readNonEmptyString(contextSnapshot.issueId); + const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null); + const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey); + const retryContextSnapshot: Record = { + ...contextSnapshot, + retryOfRunId: run.id, + wakeReason, + retryReason, + scheduledRetryAttempt: schedule.attempt, + scheduledRetryAt: schedule.dueAt.toISOString(), + ...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}), + }; + + const retryRun = await db.transaction(async (tx) => { + const wakeupRequest = await tx + .insert(agentWakeupRequests) + .values({ + companyId: run.companyId, + agentId: run.agentId, + source: "automation", + triggerDetail: "system", + reason: wakeReason, + payload: { + ...(issueId ? { issueId } : {}), + retryOfRunId: run.id, + retryReason, + scheduledRetryAttempt: schedule.attempt, + scheduledRetryAt: schedule.dueAt.toISOString(), + ...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}), + }, + status: "queued", + requestedByActorType: "system", + requestedByActorId: null, + updatedAt: now, + }) + .returning() + .then((rows) => rows[0]); + + const scheduledRun = await tx + .insert(heartbeatRuns) + .values({ + companyId: run.companyId, + agentId: run.agentId, + invocationSource: "automation", + triggerDetail: "system", + status: "scheduled_retry", + wakeupRequestId: wakeupRequest.id, + contextSnapshot: retryContextSnapshot, + sessionIdBefore: sessionBefore, + retryOfRunId: run.id, + scheduledRetryAt: schedule.dueAt, + scheduledRetryAttempt: schedule.attempt, + scheduledRetryReason: retryReason, + continuationAttempt: readContinuationAttempt(retryContextSnapshot.livenessContinuationAttempt), + updatedAt: now, + }) + .returning() + .then((rows) => rows[0]); + + await tx + .update(agentWakeupRequests) + .set({ + runId: scheduledRun.id, + updatedAt: now, + }) + .where(eq(agentWakeupRequests.id, wakeupRequest.id)); + + if (issueId) { + await tx + .update(issues) + .set({ + executionRunId: scheduledRun.id, + executionAgentNameKey: normalizeAgentNameKey(agent.name), + executionLockedAt: now, + updatedAt: now, + }) + .where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId), eq(issues.executionRunId, run.id))); + } + + return scheduledRun; + }); + + await appendRunEvent(run, await nextRunEventSeq(run.id), { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: `Scheduled bounded retry ${schedule.attempt}/${schedule.maxAttempts} for ${schedule.dueAt.toISOString()}`, + payload: { + retryRunId: retryRun.id, + retryReason, + scheduledRetryAttempt: schedule.attempt, + scheduledRetryAt: schedule.dueAt.toISOString(), + baseDelayMs: schedule.baseDelayMs, + delayMs: schedule.delayMs, + ...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}), + }, + }); + + return { + outcome: "scheduled" as const, + run: retryRun, + dueAt: schedule.dueAt, + attempt: schedule.attempt, + maxAttempts: schedule.maxAttempts, + }; + } + + async function promoteDueScheduledRetries(now = new Date()) { + const dueRuns = await db + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.status, "scheduled_retry"), + lte(heartbeatRuns.scheduledRetryAt, now), + ), + ) + .orderBy(asc(heartbeatRuns.scheduledRetryAt), asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id)) + .limit(50); + + const promotedRunIds: string[] = []; + + for (const dueRun of dueRuns) { + const promoted = await db + .update(heartbeatRuns) + .set({ + status: "queued", + updatedAt: now, + }) + .where( + and( + eq(heartbeatRuns.id, dueRun.id), + eq(heartbeatRuns.status, "scheduled_retry"), + lte(heartbeatRuns.scheduledRetryAt, now), + ), + ) + .returning() + .then((rows) => rows[0] ?? null); + if (!promoted) continue; + + promotedRunIds.push(promoted.id); + + await appendRunEvent(promoted, await nextRunEventSeq(promoted.id), { + eventType: "lifecycle", + stream: "system", + level: "info", + message: "Scheduled retry became due and was promoted to the queued run pool", + payload: { + scheduledRetryAttempt: promoted.scheduledRetryAttempt, + scheduledRetryAt: promoted.scheduledRetryAt ? new Date(promoted.scheduledRetryAt).toISOString() : null, + scheduledRetryReason: promoted.scheduledRetryReason, + }, + }); + + publishLiveEvent({ + companyId: promoted.companyId, + type: "heartbeat.run.queued", + payload: { + runId: promoted.id, + agentId: promoted.agentId, + invocationSource: promoted.invocationSource, + triggerDetail: promoted.triggerDetail, + wakeupRequestId: promoted.wakeupRequestId, + }, + }); + } + + return { + promoted: promotedRunIds.length, + runIds: promotedRunIds, + }; + } + function parseHeartbeatPolicy(agent: typeof agents.$inferSelect) { const runtimeConfig = parseObject(agent.runtimeConfig); const heartbeat = parseObject(runtimeConfig.heartbeat); @@ -2419,6 +3389,36 @@ export function heartbeatService(db: Db) { }; } + function issueRunPriorityRank(priority: string | null | undefined) { + switch (priority) { + case "critical": + return 0; + case "high": + return 1; + case "medium": + return 2; + case "low": + return 3; + default: + return 4; + } + } + + async function listQueuedRunDependencyReadiness( + companyId: string, + queuedRuns: Array, + ) { + const issueIds = [...new Set( + queuedRuns + .map((run) => readNonEmptyString(parseObject(run.contextSnapshot).issueId)) + .filter((issueId): issueId is string => Boolean(issueId)), + )]; + if (issueIds.length === 0) { + return new Map>>(); + } + return issuesSvc.listDependencyReadiness(companyId, issueIds); + } + async function countRunningRunsForAgent(agentId: string) { const [{ count }] = await db .select({ count: sql`count(*)` }) @@ -2449,10 +3449,20 @@ export function heartbeatService(db: Db) { return null; } - const claimedAt = new Date(); - const claimed = await db - .update(heartbeatRuns) - .set({ + const issueId = readNonEmptyString(context.issueId); + if (issueId) { + const dependencyReadiness = await issuesSvc.listDependencyReadiness(run.companyId, [issueId]); + const unresolvedBlockerCount = dependencyReadiness.get(issueId)?.unresolvedBlockerCount ?? 0; + if (unresolvedBlockerCount > 0 && !allowsBlockedIssueInteractionWake(context)) { + logger.debug({ runId: run.id, issueId, unresolvedBlockerCount }, "claimQueuedRun: skipping blocked run"); + return null; + } + } + + const claimedAt = new Date(); + const claimed = await db + .update(heartbeatRuns) + .set({ status: "running", startedAt: run.startedAt ?? claimedAt, updatedAt: claimedAt, @@ -2477,6 +3487,7 @@ export function heartbeatService(db: Db) { finishedAt: claimed.finishedAt ? new Date(claimed.finishedAt).toISOString() : null, }, }); + publishRunLifecyclePluginEvent(claimed); await setWakeupStatus(claimed.wakeupRequestId, "claimed", { claimedAt }); @@ -2558,6 +3569,194 @@ export function heartbeatService(db: Db) { } } + function mergeRunStopMetadataForAgent( + agent: Pick, + outcome: "succeeded" | "failed" | "cancelled" | "timed_out", + options?: { + resultJson?: Record | null; + errorCode?: string | null; + errorMessage?: string | null; + }, + ) { + const stopMetadata = buildHeartbeatRunStopMetadata({ + adapterType: agent.adapterType, + adapterConfig: parseObject(agent.adapterConfig), + outcome, + errorCode: options?.errorCode ?? null, + errorMessage: options?.errorMessage ?? null, + }); + return mergeHeartbeatRunStopMetadata(options?.resultJson ?? null, stopMetadata); + } + + function countValue(value: unknown) { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : 0; + } + + function dateValue(value: unknown) { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; + if (typeof value === "string" || typeof value === "number") { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + return null; + } + + function latestDate(...values: unknown[]) { + let latest: Date | null = null; + for (const value of values) { + const parsed = dateValue(value); + if (!parsed) continue; + if (!latest || parsed.getTime() > latest.getTime()) latest = parsed; + } + return latest; + } + + async function buildRunLivenessInput( + run: typeof heartbeatRuns.$inferSelect, + resultJson: Record | null | undefined, + ): Promise { + const context = parseObject(run.contextSnapshot); + const contextIssueId = readNonEmptyString(context.issueId); + const continuationAttempt = asNumber(context.continuationAttempt, run.continuationAttempt ?? 0); + + const issue = contextIssueId + ? await db + .select({ + status: issues.status, + title: issues.title, + description: issues.description, + }) + .from(issues) + .where(and(eq(issues.companyId, run.companyId), eq(issues.id, contextIssueId))) + .then((rows) => rows[0] ?? null) + : null; + + const [commentStats] = contextIssueId + ? await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${issueComments.createdAt})`, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, run.companyId), + eq(issueComments.issueId, contextIssueId), + eq(issueComments.createdByRunId, run.id), + ), + ) + : [{ count: 0, latestAt: null }]; + + const [documentStats] = contextIssueId + ? await db + .select({ + count: sql`count(*)::int`, + planCount: sql`count(*) filter (where ${issueDocuments.key} = 'plan')::int`, + latestAt: sql`max(${documentRevisions.createdAt})`, + }) + .from(documentRevisions) + .innerJoin(issueDocuments, eq(documentRevisions.documentId, issueDocuments.documentId)) + .where( + and( + eq(documentRevisions.companyId, run.companyId), + eq(documentRevisions.createdByRunId, run.id), + eq(issueDocuments.companyId, run.companyId), + eq(issueDocuments.issueId, contextIssueId), + sql`${issueDocuments.key} != ${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`, + ), + ) + : [{ count: 0, planCount: 0, latestAt: null }]; + + const [workProductStats] = contextIssueId + ? await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${issueWorkProducts.createdAt})`, + }) + .from(issueWorkProducts) + .where( + and( + eq(issueWorkProducts.companyId, run.companyId), + eq(issueWorkProducts.issueId, contextIssueId), + eq(issueWorkProducts.createdByRunId, run.id), + ), + ) + : [{ count: 0, latestAt: null }]; + + const [workspaceOperationStats] = await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${workspaceOperations.startedAt})`, + }) + .from(workspaceOperations) + .where(and(eq(workspaceOperations.companyId, run.companyId), eq(workspaceOperations.heartbeatRunId, run.id))); + + const [activityStats] = await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${activityLog.createdAt})`, + }) + .from(activityLog) + .where(and(eq(activityLog.companyId, run.companyId), eq(activityLog.runId, run.id))); + + const [eventStats] = await db + .select({ + count: sql`count(*) filter (where ${heartbeatRunEvents.eventType} not in ('lifecycle', 'adapter.invoke', 'error'))::int`, + latestAt: sql`max(${heartbeatRunEvents.createdAt}) filter (where ${heartbeatRunEvents.eventType} not in ('lifecycle', 'adapter.invoke', 'error'))`, + }) + .from(heartbeatRunEvents) + .where(and(eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id))); + + return { + runStatus: run.status, + issue, + resultJson: resultJson ?? run.resultJson ?? null, + stdoutExcerpt: run.stdoutExcerpt ?? null, + stderrExcerpt: run.stderrExcerpt ?? null, + error: run.error ?? null, + errorCode: run.errorCode ?? null, + continuationAttempt, + evidence: { + issueCommentsCreated: countValue(commentStats?.count), + documentRevisionsCreated: countValue(documentStats?.count), + planDocumentRevisionsCreated: countValue(documentStats?.planCount), + workProductsCreated: countValue(workProductStats?.count), + workspaceOperationsCreated: countValue(workspaceOperationStats?.count), + activityEventsCreated: countValue(activityStats?.count), + toolOrActionEventsCreated: countValue(eventStats?.count), + latestEvidenceAt: latestDate( + commentStats?.latestAt, + documentStats?.latestAt, + workProductStats?.latestAt, + workspaceOperationStats?.latestAt, + activityStats?.latestAt, + eventStats?.latestAt, + ), + }, + }; + } + + async function classifyAndPersistRunLiveness( + run: typeof heartbeatRuns.$inferSelect, + resultJson?: Record | null, + ) { + const classification = classifyRunLiveness(await buildRunLivenessInput(run, resultJson)); + return db + .update(heartbeatRuns) + .set({ + livenessState: classification.livenessState, + livenessReason: classification.livenessReason, + continuationAttempt: classification.continuationAttempt, + lastUsefulActionAt: classification.lastUsefulActionAt, + nextAction: classification.nextAction, + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, run.id)) + .returning() + .then((rows) => rows[0] ?? null); + } + async function reapOrphanedRuns(opts?: { staleThresholdMs?: number }) { const staleThresholdMs = opts?.staleThresholdMs ?? 0; const now = new Date(); @@ -2567,6 +3766,7 @@ export function heartbeatService(db: Db) { .select({ run: heartbeatRuns, adapterType: agents.adapterType, + adapterConfig: agents.adapterConfig, }) .from(heartbeatRuns) .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) @@ -2574,7 +3774,7 @@ export function heartbeatService(db: Db) { const reaped: string[] = []; - for (const { run, adapterType } of activeRuns) { + for (const { run, adapterType, adapterConfig } of activeRuns) { if (runningProcesses.has(run.id) || activeRunExecutions.has(run.id)) continue; // Apply staleness threshold to avoid false positives @@ -2624,6 +3824,15 @@ export function heartbeatService(db: Db) { error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage, errorCode: "process_lost", finishedAt: now, + resultJson: mergeRunStopMetadataForAgent( + { adapterType, adapterConfig }, + "failed", + { + resultJson: parseObject(run.resultJson), + errorCode: "process_lost", + errorMessage: shouldRetry ? `${baseMessage}; retrying once` : baseMessage, + }, + ), }); await setWakeupStatus(run.wakeupRequestId, "failed", { finishedAt: now, @@ -2631,6 +3840,7 @@ export function heartbeatService(db: Db) { }); if (!finalizedRun) finalizedRun = await getRun(run.id); if (!finalizedRun) continue; + finalizedRun = await classifyAndPersistRunLiveness(finalizedRun, parseObject(finalizedRun.resultJson)) ?? finalizedRun; let retriedRun: typeof heartbeatRuns.$inferSelect | null = null; if (shouldRetry) { @@ -2683,7 +3893,13 @@ export function heartbeatService(db: Db) { async function getLatestIssueRun(companyId: string, issueId: string) { return db - .select() + .select({ + id: heartbeatRuns.id, + status: heartbeatRuns.status, + error: heartbeatRuns.error, + errorCode: heartbeatRuns.errorCode, + contextSnapshot: heartbeatRuns.contextSnapshot, + }) .from(heartbeatRuns) .where( and( @@ -2704,7 +3920,7 @@ export function heartbeatService(db: Db) { .where( and( eq(heartbeatRuns.companyId, companyId), - inArray(heartbeatRuns.status, [...ACTIVE_HEARTBEAT_RUN_STATUSES]), + inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]), sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, ), ) @@ -2738,7 +3954,6 @@ export function heartbeatService(db: Db) { const queued = await enqueueWakeup(input.agentId, { source: "automation", triggerDetail: "system", - wakeReason: "task_update", reason: input.reason, payload: { issueId: input.issueId, @@ -2771,10 +3986,154 @@ export function heartbeatService(db: Db) { return queued; } + function formatIssueLinksForComment(relations: Array<{ identifier?: string | null }>) { + const identifiers = [ + ...new Set( + relations + .map((relation) => relation.identifier) + .filter((identifier): identifier is string => Boolean(identifier)), + ), + ]; + if (identifiers.length === 0) return "another open issue"; + return identifiers + .slice(0, 5) + .map((identifier) => { + const prefix = identifier.split("-")[0] || "PAP"; + return `[${identifier}](/${prefix}/issues/${identifier})`; + }) + .join(", "); + } + + async function reconcileUnassignedBlockingIssues() { + const candidates = await db + .select({ + id: issues.id, + companyId: issues.companyId, + identifier: issues.identifier, + status: issues.status, + createdByAgentId: issues.createdByAgentId, + }) + .from(issueRelations) + .innerJoin(issues, eq(issueRelations.issueId, issues.id)) + .where( + and( + eq(issueRelations.type, "blocks"), + inArray(issues.status, ["todo", "blocked"]), + isNull(issues.assigneeAgentId), + isNull(issues.assigneeUserId), + sql`${issues.createdByAgentId} is not null`, + sql`exists ( + select 1 + from issues blocked_issue + where blocked_issue.id = ${issueRelations.relatedIssueId} + and blocked_issue.company_id = ${issues.companyId} + and blocked_issue.status not in ('done', 'cancelled') + )`, + ), + ); + + let assigned = 0; + let skipped = 0; + const issueIds: string[] = []; + const seen = new Set(); + + for (const candidate of candidates) { + if (seen.has(candidate.id)) continue; + seen.add(candidate.id); + + const creatorAgentId = candidate.createdByAgentId; + if (!creatorAgentId) { + skipped += 1; + continue; + } + const creatorAgent = await getAgent(creatorAgentId); + if ( + !creatorAgent || + creatorAgent.companyId !== candidate.companyId || + creatorAgent.status === "paused" || + creatorAgent.status === "terminated" || + creatorAgent.status === "pending_approval" + ) { + skipped += 1; + continue; + } + + const relations = await issuesSvc.getRelationSummaries(candidate.id); + const blockingLinks = formatIssueLinksForComment(relations.blocks); + const updated = await issuesSvc.update(candidate.id, { + assigneeAgentId: creatorAgent.id, + assigneeUserId: null, + }); + if (!updated) { + skipped += 1; + continue; + } + + await issuesSvc.addComment( + candidate.id, + [ + "## Assigned Orphan Blocker", + "", + `Taskcore found this issue is blocking ${blockingLinks} but had no assignee, so no heartbeat could pick it up.`, + "", + "- Assigned it back to the agent that created the blocker.", + "- Next action: resolve this blocker or reassign it to the right owner.", + ].join("\n"), + {}, + ); + + await logActivity(db, { + companyId: candidate.companyId, + actorType: "system", + actorId: "system", + agentId: null, + runId: null, + action: "issue.updated", + entityType: "issue", + entityId: candidate.id, + details: { + identifier: candidate.identifier, + assigneeAgentId: creatorAgent.id, + source: "heartbeat.reconcile_unassigned_blocking_issue", + }, + }); + + const queued = await enqueueWakeup(creatorAgent.id, { + source: "automation", + triggerDetail: "system", + reason: "issue_assigned", + payload: { + issueId: candidate.id, + mutation: "unassigned_blocker_recovery", + }, + requestedByActorType: "system", + requestedByActorId: null, + contextSnapshot: { + issueId: candidate.id, + taskId: candidate.id, + wakeReason: "issue_assigned", + source: "issue.unassigned_blocker_recovery", + }, + }); + + if (queued) { + assigned += 1; + issueIds.push(candidate.id); + } else { + skipped += 1; + } + } + + return { assigned, skipped, issueIds }; + } + async function escalateStrandedAssignedIssue(input: { issue: typeof issues.$inferSelect; previousStatus: "todo" | "in_progress"; - latestRun: typeof heartbeatRuns.$inferSelect | null; + latestRun: Pick< + typeof heartbeatRuns.$inferSelect, + "id" | "status" | "error" | "errorCode" | "contextSnapshot" + > | null; comment: string; }) { const updated = await issuesSvc.update(input.issue.id, { @@ -2822,6 +4181,7 @@ export function heartbeatService(db: Db) { const result = { dispatchRequeued: 0, continuationRequeued: 0, + orphanBlockersAssigned: 0, escalated: 0, skipped: 0, issueIds: [] as string[], @@ -2850,23 +4210,22 @@ export function heartbeatService(db: Db) { } const latestRun = await getLatestIssueRun(issue.companyId, issue.id); - const latestContext = parseObject(latestRun?.contextSnapshot); - const latestRetryReason = readNonEmptyString(latestContext.retryReason); - if (issue.status === "todo") { if (!latestRun || latestRun.status === "succeeded") { result.skipped += 1; continue; } - if (latestRetryReason === "assignment_recovery") { + if (didAutomaticRecoveryFail(latestRun, "assignment_recovery")) { + const failureSummary = summarizeRunFailureForIssueComment(latestRun); const updated = await escalateStrandedAssignedIssue({ issue, previousStatus: "todo", latestRun, comment: "Taskcore automatically retried dispatch for this assigned `todo` issue after a lost wake/run, " + - "but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.", + `but it still has no live execution path.${failureSummary ?? ""} ` + + "Moving it to `blocked` so it is visible for intervention.", }); if (updated) { result.escalated += 1; @@ -2894,15 +4253,20 @@ export function heartbeatService(db: Db) { continue; } - if (latestRetryReason === "issue_continuation_needed") { + if (!latestRun && !issue.checkoutRunId && !issue.executionRunId) { + result.skipped += 1; + continue; + } + if (didAutomaticRecoveryFail(latestRun, "issue_continuation_needed")) { + const failureSummary = summarizeRunFailureForIssueComment(latestRun); const updated = await escalateStrandedAssignedIssue({ issue, previousStatus: "in_progress", latestRun, comment: "Taskcore automatically retried continuation for this assigned `in_progress` issue after its live " + - "execution disappeared, but it still has no live execution path. Moving it to `blocked` so it is " + - "visible for intervention.", + `execution disappeared, but it still has no live execution path.${failureSummary ?? ""} ` + + "Moving it to `blocked` so it is visible for intervention.", }); if (updated) { result.escalated += 1; @@ -2929,108 +4293,496 @@ export function heartbeatService(db: Db) { } } + const orphanBlockerRecovery = await reconcileUnassignedBlockingIssues(); + result.orphanBlockersAssigned = orphanBlockerRecovery.assigned; + result.skipped += orphanBlockerRecovery.skipped; + result.issueIds.push(...orphanBlockerRecovery.issueIds); + return result; } - async function updateRuntimeState( - agent: typeof agents.$inferSelect, - run: typeof heartbeatRuns.$inferSelect, - result: AdapterExecutionResult, - session: { legacySessionId: string | null }, - normalizedUsage?: UsageTotals | null, - ) { - await ensureRuntimeState(agent); - const usage = normalizedUsage ?? normalizeUsageTotals(result.usage); - const inputTokens = usage?.inputTokens ?? 0; - const outputTokens = usage?.outputTokens ?? 0; - const cachedInputTokens = usage?.cachedInputTokens ?? 0; - const billingType = normalizeLedgerBillingType(result.billingType); - const additionalCostCents = normalizeBilledCostCents(result.costUsd, billingType); - const hasTokenUsage = inputTokens > 0 || outputTokens > 0 || cachedInputTokens > 0; - const provider = result.provider ?? "unknown"; - const biller = resolveLedgerBiller(result); - const ledgerScope = await resolveLedgerScopeForRun(db, agent.companyId, run); - - await db - .update(agentRuntimeState) - .set({ - adapterType: agent.adapterType, - sessionId: session.legacySessionId, - lastRunId: run.id, - lastRunStatus: run.status, - lastError: result.errorMessage ?? null, - consecutiveFailures: - run.status === "succeeded" - ? 0 - : run.status === "cancelled" - ? agentRuntimeState.consecutiveFailures - : sql`${agentRuntimeState.consecutiveFailures} + 1`, - totalInputTokens: sql`${agentRuntimeState.totalInputTokens} + ${inputTokens}`, - totalOutputTokens: sql`${agentRuntimeState.totalOutputTokens} + ${outputTokens}`, - totalCachedInputTokens: sql`${agentRuntimeState.totalCachedInputTokens} + ${cachedInputTokens}`, - totalCostCents: sql`${agentRuntimeState.totalCostCents} + ${additionalCostCents}`, - updatedAt: new Date(), - }) - .where(eq(agentRuntimeState.agentId, agent.id)); - - if (run.status !== "succeeded" && run.status !== "cancelled") { - const cb = circuitBreakerService(db, budgetService(db, budgetHooks)); - await cb.checkAndEnforce(agent.id); - } - - if (additionalCostCents > 0 || hasTokenUsage) { - const costs = costService(db, budgetHooks); - await costs.createEvent(agent.companyId, { - heartbeatRunId: run.id, - agentId: agent.id, - issueId: ledgerScope.issueId, - projectId: ledgerScope.projectId, - provider, - biller, - billingType, - model: result.model ?? "unknown", - inputTokens, - cachedInputTokens, - outputTokens, - costCents: additionalCostCents, - occurredAt: new Date(), - }); - } + function issueIdFromRunContext(contextSnapshot: unknown) { + const context = parseObject(contextSnapshot); + return readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId); } - async function startNextQueuedRunForAgent(agentId: string) { - return withAgentStartLock(agentId, async () => { - const agent = await getAgent(agentId); - if (!agent) return []; - if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") { - return []; - } - const policy = parseHeartbeatPolicy(agent); - const runningCount = await countRunningRunsForAgent(agentId); - const availableSlots = Math.max(0, policy.maxConcurrentRuns - runningCount); - if (availableSlots <= 0) return []; + function issueIdFromWakePayload(payload: unknown) { + const parsed = parseObject(payload); + const nestedContext = parseObject(parsed[DEFERRED_WAKE_CONTEXT_KEY]); + return readNonEmptyString(parsed.issueId) ?? + readNonEmptyString(nestedContext.issueId) ?? + readNonEmptyString(nestedContext.taskId); + } - const queuedRuns = await db - .select() + async function collectIssueGraphLivenessFindings() { + const [issueRows, relationRows, agentRows, activeRunRows, wakeRows] = await Promise.all([ + db + .select({ + id: issues.id, + companyId: issues.companyId, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + projectId: issues.projectId, + goalId: issues.goalId, + parentId: issues.parentId, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + createdByAgentId: issues.createdByAgentId, + createdByUserId: issues.createdByUserId, + executionState: issues.executionState, + }) + .from(issues) + .where(isNull(issues.hiddenAt)), + db + .select({ + companyId: issueRelations.companyId, + blockerIssueId: issueRelations.issueId, + blockedIssueId: issueRelations.relatedIssueId, + }) + .from(issueRelations) + .where(eq(issueRelations.type, "blocks")), + db + .select({ + id: agents.id, + companyId: agents.companyId, + name: agents.name, + role: agents.role, + title: agents.title, + status: agents.status, + reportsTo: agents.reportsTo, + }) + .from(agents), + db + .select({ + companyId: heartbeatRuns.companyId, + agentId: heartbeatRuns.agentId, + status: heartbeatRuns.status, + contextSnapshot: heartbeatRuns.contextSnapshot, + }) .from(heartbeatRuns) - .where(and(eq(heartbeatRuns.agentId, agentId), eq(heartbeatRuns.status, "queued"))) - .orderBy(asc(heartbeatRuns.createdAt)) - .limit(availableSlots); - if (queuedRuns.length === 0) return []; - - const claimedRuns: Array = []; - for (const queuedRun of queuedRuns) { - const claimed = await claimQueuedRun(queuedRun); - if (claimed) claimedRuns.push(claimed); - } - if (claimedRuns.length === 0) return []; + .where(inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES])), + db + .select({ + companyId: agentWakeupRequests.companyId, + agentId: agentWakeupRequests.agentId, + status: agentWakeupRequests.status, + payload: agentWakeupRequests.payload, + }) + .from(agentWakeupRequests) + .where(inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"])), + ]); - for (const claimedRun of claimedRuns) { - void executeRun(claimedRun.id).catch((err) => { - logger.error({ err, runId: claimedRun.id }, "queued heartbeat execution failed"); - }); - } - return claimedRuns; + return classifyIssueGraphLiveness({ + issues: issueRows, + relations: relationRows, + agents: agentRows, + activeRuns: activeRunRows.map((row) => ({ + companyId: row.companyId, + agentId: row.agentId, + status: row.status, + issueId: issueIdFromRunContext(row.contextSnapshot), + })), + queuedWakeRequests: wakeRows.map((row) => ({ + companyId: row.companyId, + agentId: row.agentId, + status: row.status, + issueId: issueIdFromWakePayload(row.payload), + })), + }); + } + + async function findOpenLivenessEscalation(companyId: string, incidentKey: string) { + return db + .select() + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + eq(issues.originKind, "harness_liveness_escalation"), + eq(issues.originId, incidentKey), + isNull(issues.hiddenAt), + notInArray(issues.status, ["done", "cancelled"]), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + } + + async function existingBlockerIssueIds(companyId: string, issueId: string) { + return db + .select({ blockerIssueId: issueRelations.issueId }) + .from(issueRelations) + .where( + and( + eq(issueRelations.companyId, companyId), + eq(issueRelations.relatedIssueId, issueId), + eq(issueRelations.type, "blocks"), + ), + ) + .then((rows) => rows.map((row) => row.blockerIssueId)); + } + + function formatDependencyPath(finding: IssueLivenessFinding) { + return finding.dependencyPath + .map((entry) => entry.identifier ?? entry.issueId) + .join(" -> "); + } + + function buildLivenessEscalationDescription(finding: IssueLivenessFinding) { + return [ + "Taskcore detected a harness-level issue graph liveness incident.", + "", + `- Incident key: \`${finding.incidentKey}\``, + `- Finding: \`${finding.state}\``, + `- Dependency path: ${formatDependencyPath(finding)}`, + `- Reason: ${finding.reason}`, + `- Requested action: ${finding.recommendedAction}`, + "", + "Resolve the blocked chain, then mark this escalation issue done so the original issue can resume when all blockers are cleared.", + ].join("\n"); + } + + function buildLivenessOriginalIssueComment(finding: IssueLivenessFinding, escalation: typeof issues.$inferSelect) { + return [ + "Taskcore detected a harness-level liveness incident in this issue's dependency graph.", + "", + `- Escalation issue: ${escalation.identifier ?? escalation.id}`, + `- Incident key: \`${finding.incidentKey}\``, + `- Finding: \`${finding.state}\``, + `- Dependency path: ${formatDependencyPath(finding)}`, + `- Reason: ${finding.reason}`, + `- Manager action requested: ${finding.recommendedAction}`, + "", + "This issue now keeps its existing blockers and is also blocked by the escalation issue so dependency wakeups remain explicit.", + ].join("\n"); + } + + async function resolveEscalationOwnerAgentId( + finding: IssueLivenessFinding, + issue: typeof issues.$inferSelect, + ) { + const candidates = [ + finding.recommendedOwnerAgentId, + ...finding.recommendedOwnerCandidateAgentIds, + ].filter((candidate): candidate is string => Boolean(candidate)); + + for (const candidate of [...new Set(candidates)]) { + const budgetBlock = await budgets.getInvocationBlock(issue.companyId, candidate, { + issueId: issue.id, + projectId: issue.projectId, + }); + if (!budgetBlock) return candidate; + } + + return null; + } + + async function ensureIssueBlockedByEscalation(input: { + issue: typeof issues.$inferSelect; + escalationIssueId: string; + finding: IssueLivenessFinding; + runId?: string | null; + }) { + const blockerIds = await existingBlockerIssueIds(input.issue.companyId, input.issue.id); + const nextBlockerIds = [...new Set([...blockerIds, input.escalationIssueId])]; + const update: Partial & { blockedByIssueIds: string[] } = { + blockedByIssueIds: nextBlockerIds, + }; + if (input.issue.status !== "blocked") { + update.status = "blocked"; + } + + const updated = await issuesSvc.update(input.issue.id, update); + if (!updated) return null; + + await logActivity(db, { + companyId: input.issue.companyId, + actorType: "system", + actorId: "system", + agentId: null, + runId: input.runId ?? null, + action: "issue.blockers.updated", + entityType: "issue", + entityId: input.issue.id, + details: { + source: "heartbeat.reconcile_issue_graph_liveness", + incidentKey: input.finding.incidentKey, + findingState: input.finding.state, + blockerIssueIds: nextBlockerIds, + escalationIssueId: input.escalationIssueId, + status: update.status ?? input.issue.status, + previousStatus: input.issue.status, + }, + }); + + return updated; + } + + async function createIssueGraphLivenessEscalation(input: { + finding: IssueLivenessFinding; + runId?: string | null; + }) { + const issue = await db + .select() + .from(issues) + .where(eq(issues.id, input.finding.issueId)) + .then((rows) => rows[0] ?? null); + if (!issue || issue.companyId !== input.finding.companyId) return { kind: "skipped" as const }; + + const existing = await findOpenLivenessEscalation(issue.companyId, input.finding.incidentKey); + if (existing) { + await ensureIssueBlockedByEscalation({ + issue, + escalationIssueId: existing.id, + finding: input.finding, + runId: input.runId ?? null, + }); + return { kind: "existing" as const, escalationIssueId: existing.id }; + } + + const ownerAgentId = await resolveEscalationOwnerAgentId(input.finding, issue); + if (!ownerAgentId) return { kind: "skipped" as const }; + + const escalation = await issuesSvc.create(issue.companyId, { + title: `Unblock liveness incident for ${issue.identifier ?? issue.title}`, + description: buildLivenessEscalationDescription(input.finding), + status: "todo", + priority: "high", + parentId: issue.id, + projectId: issue.projectId, + goalId: issue.goalId, + assigneeAgentId: ownerAgentId, + originKind: "harness_liveness_escalation", + originId: input.finding.incidentKey, + billingCode: issue.billingCode, + inheritExecutionWorkspaceFromIssueId: issue.id, + }); + + await ensureIssueBlockedByEscalation({ + issue, + escalationIssueId: escalation.id, + finding: input.finding, + runId: input.runId ?? null, + }); + + await issuesSvc.addComment( + issue.id, + buildLivenessOriginalIssueComment(input.finding, escalation), + { runId: input.runId ?? null }, + ); + + await logActivity(db, { + companyId: issue.companyId, + actorType: "system", + actorId: "system", + agentId: ownerAgentId, + runId: input.runId ?? null, + action: "issue.harness_liveness_escalation_created", + entityType: "issue", + entityId: escalation.id, + details: { + source: "heartbeat.reconcile_issue_graph_liveness", + incidentKey: input.finding.incidentKey, + findingState: input.finding.state, + sourceIssueId: issue.id, + sourceIdentifier: issue.identifier, + escalationIssueId: escalation.id, + escalationIdentifier: escalation.identifier, + dependencyPath: input.finding.dependencyPath, + }, + }); + + const wake = await enqueueWakeup(ownerAgentId, { + source: "assignment", + triggerDetail: "system", + reason: "issue_assigned", + payload: { + issueId: escalation.id, + sourceIssueId: issue.id, + incidentKey: input.finding.incidentKey, + }, + requestedByActorType: "system", + requestedByActorId: null, + contextSnapshot: { + issueId: escalation.id, + taskId: escalation.id, + wakeReason: "issue_assigned", + source: "harness_liveness_escalation", + sourceIssueId: issue.id, + incidentKey: input.finding.incidentKey, + }, + }); + + logger.warn({ + incidentKey: input.finding.incidentKey, + findingState: input.finding.state, + sourceIssueId: issue.id, + escalationIssueId: escalation.id, + ownerAgentId, + wakeupRunId: wake?.id ?? null, + }, "created issue graph liveness escalation"); + + return { kind: "created" as const, escalationIssueId: escalation.id }; + } + + async function reconcileIssueGraphLiveness(opts?: { runId?: string | null }) { + const findings = await collectIssueGraphLivenessFindings(); + const result = { + findings: findings.length, + escalationsCreated: 0, + existingEscalations: 0, + skipped: 0, + issueIds: [] as string[], + escalationIssueIds: [] as string[], + }; + + for (const finding of findings) { + const escalation = await createIssueGraphLivenessEscalation({ + finding, + runId: opts?.runId ?? null, + }); + if (escalation.kind === "created") { + result.escalationsCreated += 1; + result.issueIds.push(finding.issueId); + result.escalationIssueIds.push(escalation.escalationIssueId); + } else if (escalation.kind === "existing") { + result.existingEscalations += 1; + result.issueIds.push(finding.issueId); + result.escalationIssueIds.push(escalation.escalationIssueId); + } else { + result.skipped += 1; + } + } + + return result; + } + + async function updateRuntimeState( + agent: typeof agents.$inferSelect, + run: typeof heartbeatRuns.$inferSelect, + result: AdapterExecutionResult, + session: { legacySessionId: string | null }, + normalizedUsage?: UsageTotals | null, + ) { + await ensureRuntimeState(agent); + const usage = normalizedUsage ?? normalizeUsageTotals(result.usage); + const inputTokens = usage?.inputTokens ?? 0; + const outputTokens = usage?.outputTokens ?? 0; + const cachedInputTokens = usage?.cachedInputTokens ?? 0; + const billingType = normalizeLedgerBillingType(result.billingType); + const additionalCostCents = normalizeBilledCostCents(result.costUsd, billingType); + const hasTokenUsage = inputTokens > 0 || outputTokens > 0 || cachedInputTokens > 0; + const provider = result.provider ?? "unknown"; + const biller = resolveLedgerBiller(result); + const ledgerScope = await resolveLedgerScopeForRun(db, agent.companyId, run); + + await db + .update(agentRuntimeState) + .set({ + adapterType: agent.adapterType, + sessionId: session.legacySessionId, + lastRunId: run.id, + lastRunStatus: run.status, + lastError: result.errorMessage ?? null, + totalInputTokens: sql`${agentRuntimeState.totalInputTokens} + ${inputTokens}`, + totalOutputTokens: sql`${agentRuntimeState.totalOutputTokens} + ${outputTokens}`, + totalCachedInputTokens: sql`${agentRuntimeState.totalCachedInputTokens} + ${cachedInputTokens}`, + totalCostCents: sql`${agentRuntimeState.totalCostCents} + ${additionalCostCents}`, + updatedAt: new Date(), + }) + .where(eq(agentRuntimeState.agentId, agent.id)); + + if (additionalCostCents > 0 || hasTokenUsage) { + const costs = costService(db, budgetHooks); + await costs.createEvent(agent.companyId, { + heartbeatRunId: run.id, + agentId: agent.id, + issueId: ledgerScope.issueId, + projectId: ledgerScope.projectId, + provider, + biller, + billingType, + model: result.model ?? "unknown", + inputTokens, + cachedInputTokens, + outputTokens, + costCents: additionalCostCents, + occurredAt: new Date(), + }); + } + } + + async function startNextQueuedRunForAgent(agentId: string) { + return withAgentStartLock(agentId, async () => { + const agent = await getAgent(agentId); + if (!agent) return []; + if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") { + return []; + } + const policy = parseHeartbeatPolicy(agent); + const runningCount = await countRunningRunsForAgent(agentId); + const availableSlots = Math.max(0, policy.maxConcurrentRuns - runningCount); + if (availableSlots <= 0) return []; + + const queuedRuns = await db + .select() + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.agentId, agentId), eq(heartbeatRuns.status, "queued"))) + .orderBy(asc(heartbeatRuns.createdAt)); + if (queuedRuns.length === 0) return []; + + const dependencyReadiness = await listQueuedRunDependencyReadiness(agent.companyId, queuedRuns); + const queuedIssueIds = [...new Set( + queuedRuns + .map((run) => readNonEmptyString(parseObject(run.contextSnapshot).issueId)) + .filter((issueId): issueId is string => Boolean(issueId)), + )]; + const issueRows = await db + .select({ + id: issues.id, + status: issues.status, + priority: issues.priority, + }) + .from(issues) + .where( + queuedIssueIds.length > 0 + ? and(eq(issues.companyId, agent.companyId), inArray(issues.id, queuedIssueIds)) + : sql`false`, + ); + const issueById = new Map(issueRows.map((row) => [row.id, row])); + const prioritizedRuns = [...queuedRuns].sort((left, right) => { + const leftIssueId = readNonEmptyString(parseObject(left.contextSnapshot).issueId); + const rightIssueId = readNonEmptyString(parseObject(right.contextSnapshot).issueId); + const leftReadiness = leftIssueId ? dependencyReadiness.get(leftIssueId) : null; + const rightReadiness = rightIssueId ? dependencyReadiness.get(rightIssueId) : null; + const leftReady = leftIssueId ? (leftReadiness?.isDependencyReady ?? true) : true; + const rightReady = rightIssueId ? (rightReadiness?.isDependencyReady ?? true) : true; + const leftIssue = leftIssueId ? issueById.get(leftIssueId) : null; + const rightIssue = rightIssueId ? issueById.get(rightIssueId) : null; + const leftRank = leftIssueId ? (leftReady ? (leftIssue?.status === "in_progress" ? 0 : 1) : 3) : 2; + const rightRank = rightIssueId ? (rightReady ? (rightIssue?.status === "in_progress" ? 0 : 1) : 3) : 2; + if (leftRank !== rightRank) return leftRank - rightRank; + const leftPriorityRank = issueRunPriorityRank(leftIssue?.priority); + const rightPriorityRank = issueRunPriorityRank(rightIssue?.priority); + if (leftPriorityRank !== rightPriorityRank) return leftPriorityRank - rightPriorityRank; + return left.createdAt.getTime() - right.createdAt.getTime(); + }); + + const claimedRuns: Array = []; + for (const queuedRun of prioritizedRuns) { + if (claimedRuns.length >= availableSlots) break; + const claimed = await claimQueuedRun(queuedRun); + if (claimed) claimedRuns.push(claimed); + } + if (claimedRuns.length === 0) return []; + + for (const claimedRun of claimedRuns) { + void executeRun(claimedRun.id).catch((err) => { + logger.error({ err, runId: claimedRun.id }, "queued heartbeat execution failed"); + }); + } + return claimedRuns; }); } @@ -3042,7 +4794,7 @@ export function heartbeatService(db: Db) { if (run.status === "queued") { const claimed = await claimQueuedRun(run); if (!claimed) { - // Another worker has already claimed or finalized this run. + // claimQueuedRun can also leave the run queued when dependencies are unresolved. return; } run = claimed; @@ -3051,61 +4803,65 @@ export function heartbeatService(db: Db) { activeRunExecutions.add(run.id); try { - const agent = await getAgent(run.agentId); - if (!agent) { - await setRunStatus(runId, "failed", { - error: "Agent not found", - errorCode: "agent_not_found", - finishedAt: new Date(), - }); - await setWakeupStatus(run.wakeupRequestId, "failed", { - finishedAt: new Date(), - error: "Agent not found", - }); - const failedRun = await getRun(runId); - if (failedRun) await releaseIssueExecutionAndPromote(failedRun); - return; - } + const agent = await getAgent(run.agentId); + if (!agent) { + await setRunStatus(runId, "failed", { + error: "Agent not found", + errorCode: "agent_not_found", + finishedAt: new Date(), + }); + await setWakeupStatus(run.wakeupRequestId, "failed", { + finishedAt: new Date(), + error: "Agent not found", + }); + const failedRun = await getRun(runId); + if (failedRun) await releaseIssueExecutionAndPromote(failedRun); + return; + } - const runtime = await ensureRuntimeState(agent); - const context = parseObject(run.contextSnapshot); - const taskKey = deriveTaskKeyWithHeartbeatFallback(context, null); - const sessionCodec = getAdapterSessionCodec(agent.adapterType); - const issueId = readNonEmptyString(context.issueId); - let issueContext = issueId ? await getIssueExecutionContext(agent.companyId, issueId) : null; - if ( - issueId && - issueContext && - shouldAutoCheckoutIssueForWake({ - contextSnapshot: context, - issueStatus: issueContext.status, - issueAssigneeAgentId: issueContext.assigneeAgentId, - agentId: agent.id, - }) - ) { - try { - await issuesSvc.checkout(issueId, agent.id, ["todo", "backlog", "blocked"], run.id); - context[TASKCORE_HARNESS_CHECKOUT_KEY] = true; - } catch (error) { - if (!isCheckoutConflictError(error)) throw error; - context[TASKCORE_HARNESS_CHECKOUT_KEY] = false; - } - issueContext = await getIssueExecutionContext(agent.companyId, issueId); + const runtime = await ensureRuntimeState(agent); + const context = parseObject(run.contextSnapshot); + const taskKey = deriveTaskKeyWithHeartbeatFallback(context, null); + const sessionCodec = getAdapterSessionCodec(agent.adapterType); + const issueId = readNonEmptyString(context.issueId); + let issueContext = issueId ? await getIssueExecutionContext(agent.companyId, issueId) : null; + const issueDependencyReadiness = issueId + ? await issuesSvc.listDependencyReadiness(agent.companyId, [issueId]).then((rows) => rows.get(issueId) ?? null) + : null; + if ( + issueId && + issueContext && + shouldAutoCheckoutIssueForWake({ + contextSnapshot: context, + issueStatus: issueContext.status, + issueAssigneeAgentId: issueContext.assigneeAgentId, + isDependencyReady: issueDependencyReadiness?.isDependencyReady ?? true, + agentId: agent.id, + }) + ) { + try { + await issuesSvc.checkout(issueId, agent.id, ["todo", "backlog", "blocked"], run.id); + context[TASKCORE_HARNESS_CHECKOUT_KEY] = true; + } catch (error) { + if (!isCheckoutConflictError(error)) throw error; + context[TASKCORE_HARNESS_CHECKOUT_KEY] = false; } - const issueAssigneeOverrides = - issueContext && issueContext.assigneeAgentId === agent.id - ? parseIssueAssigneeAdapterOverrides( + issueContext = await getIssueExecutionContext(agent.companyId, issueId); + } + const issueAssigneeOverrides = + issueContext && issueContext.assigneeAgentId === agent.id + ? parseIssueAssigneeAdapterOverrides( issueContext.assigneeAdapterOverrides, ) - : null; - const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces; - const issueExecutionWorkspaceSettings = isolatedWorkspacesEnabled - ? parseIssueExecutionWorkspaceSettings(issueContext?.executionWorkspaceSettings) : null; - const contextProjectId = readNonEmptyString(context.projectId); - const executionProjectId = issueContext?.projectId ?? contextProjectId; - const projectContext = executionProjectId - ? await db + const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces; + const issueExecutionWorkspaceSettings = isolatedWorkspacesEnabled + ? parseIssueExecutionWorkspaceSettings(issueContext?.executionWorkspaceSettings) + : null; + const contextProjectId = readNonEmptyString(context.projectId); + const executionProjectId = issueContext?.projectId ?? contextProjectId; + const projectContext = executionProjectId + ? await db .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy, env: projects.env, @@ -3113,43 +4869,43 @@ export function heartbeatService(db: Db) { .from(projects) .where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId))) .then((rows) => rows[0] ?? null) - : null; - const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy( - parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy), - isolatedWorkspacesEnabled, - ); - const taskSession = taskKey - ? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey) - : null; - const resetTaskSession = shouldResetTaskSessionForWake(context); - const sessionResetReason = describeSessionResetReason(context); - const taskSessionForRun = resetTaskSession ? null : taskSession; - const explicitResumeSessionParams = normalizeSessionParams( - sessionCodec.deserialize(parseObject(context.resumeSessionParams)), - ); - const explicitResumeSessionDisplayId = truncateDisplayId( - readNonEmptyString(context.resumeSessionDisplayId) ?? + : null; + const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy( + parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy), + isolatedWorkspacesEnabled, + ); + const taskSession = taskKey + ? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey) + : null; + const resetTaskSession = shouldResetTaskSessionForWake(context); + const sessionResetReason = describeSessionResetReason(context); + const taskSessionForRun = resetTaskSession ? null : taskSession; + const explicitResumeSessionParams = normalizeSessionParams( + sessionCodec.deserialize(parseObject(context.resumeSessionParams)), + ); + const explicitResumeSessionDisplayId = truncateDisplayId( + readNonEmptyString(context.resumeSessionDisplayId) ?? (sessionCodec.getDisplayId ? sessionCodec.getDisplayId(explicitResumeSessionParams) : null) ?? readNonEmptyString(explicitResumeSessionParams?.sessionId), - ); - const previousSessionParams = - explicitResumeSessionParams ?? - (explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ?? - normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null)); - const config = parseObject(agent.adapterConfig); - const requestedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({ - projectPolicy: projectExecutionWorkspacePolicy, - issueSettings: issueExecutionWorkspaceSettings, - legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, - }); - const resolvedWorkspace = await resolveWorkspaceForRun( - agent, - context, - previousSessionParams, - { useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default" }, - ); - const issueRef = issueContext - ? { + ); + const previousSessionParams = + explicitResumeSessionParams ?? + (explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ?? + normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null)); + const config = parseObject(agent.adapterConfig); + const requestedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({ + projectPolicy: projectExecutionWorkspacePolicy, + issueSettings: issueExecutionWorkspaceSettings, + legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, + }); + const resolvedWorkspace = await resolveWorkspaceForRun( + agent, + context, + previousSessionParams, + { useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default" }, + ); + const issueRef = issueContext + ? { id: issueContext.id, identifier: issueContext.identifier, title: issueContext.title, @@ -3160,126 +4916,140 @@ export function heartbeatService(db: Db) { executionWorkspaceId: issueContext.executionWorkspaceId, executionWorkspacePreference: issueContext.executionWorkspacePreference, } - : null; - const taskcoreWakePayload = await buildTaskcoreWakePayload({ - db, - companyId: agent.companyId, - contextSnapshot: context, - issueSummary: issueRef - ? { + : null; + const continuationSummary = issueRef + ? await getIssueContinuationSummaryDocument(db, issueRef.id) + : null; + if (continuationSummary) { + context.taskcoreContinuationSummary = { + key: continuationSummary.key, + title: continuationSummary.title, + body: continuationSummary.body, + updatedAt: continuationSummary.updatedAt.toISOString(), + }; + } else { + delete context.taskcoreContinuationSummary; + } + const taskcoreWakePayload = await buildTaskcoreWakePayload({ + db, + companyId: agent.companyId, + contextSnapshot: context, + continuationSummary, + issueSummary: issueRef + ? { id: issueRef.id, identifier: issueRef.identifier, title: issueRef.title, status: issueRef.status, priority: issueRef.priority, } - : null, - }); - if (taskcoreWakePayload) { - context[TASKCORE_WAKE_PAYLOAD_KEY] = taskcoreWakePayload; - } else { - delete context[TASKCORE_WAKE_PAYLOAD_KEY]; - } - const existingExecutionWorkspace = - issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null; - const shouldReuseExisting = - issueRef?.executionWorkspacePreference === "reuse_existing" && - existingExecutionWorkspace && - existingExecutionWorkspace.status !== "archived"; - const persistedExecutionWorkspaceMode = shouldReuseExisting && existingExecutionWorkspace - ? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode) - : null; - const effectiveExecutionWorkspaceMode: ReturnType = - persistedExecutionWorkspaceMode === "isolated_workspace" || - persistedExecutionWorkspaceMode === "operator_branch" || - persistedExecutionWorkspaceMode === "agent_default" - ? persistedExecutionWorkspaceMode - : requestedExecutionWorkspaceMode; - const workspaceManagedConfig = shouldReuseExisting - ? { ...config } - : buildExecutionWorkspaceAdapterConfig({ + : null, + }); + if (taskcoreWakePayload) { + context[TASKCORE_WAKE_PAYLOAD_KEY] = taskcoreWakePayload; + } else { + delete context[TASKCORE_WAKE_PAYLOAD_KEY]; + } + const existingExecutionWorkspace = + issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null; + const shouldReuseExisting = + issueRef?.executionWorkspacePreference === "reuse_existing" && + existingExecutionWorkspace && + existingExecutionWorkspace.status !== "archived"; + const persistedExecutionWorkspaceMode = shouldReuseExisting && existingExecutionWorkspace + ? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode) + : null; + const effectiveExecutionWorkspaceMode: ReturnType = + persistedExecutionWorkspaceMode === "isolated_workspace" || + persistedExecutionWorkspaceMode === "operator_branch" || + persistedExecutionWorkspaceMode === "agent_default" + ? persistedExecutionWorkspaceMode + : requestedExecutionWorkspaceMode; + const workspaceManagedConfig = shouldReuseExisting + ? { ...config } + : buildExecutionWorkspaceAdapterConfig({ agentConfig: config, projectPolicy: projectExecutionWorkspacePolicy, issueSettings: issueExecutionWorkspaceSettings, mode: requestedExecutionWorkspaceMode, legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, }); - const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({ - config: workspaceManagedConfig, - workspaceConfig: existingExecutionWorkspace?.config ?? null, - mode: effectiveExecutionWorkspaceMode, - }); - const mergedConfig = issueAssigneeOverrides?.adapterConfig - ? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig } - : persistedWorkspaceManagedConfig; - const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig); - const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig); - const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({ - companyId: agent.companyId, - executionRunConfig, - projectEnv: projectContext?.env ?? null, - secretsSvc, - }); - const runScopedMentionedSkillKeys = await resolveRunScopedMentionedSkillKeys({ - db, - companyId: agent.companyId, - issueId, - }); - const effectiveResolvedConfig = applyRunScopedMentionedSkillKeys( - resolvedConfig, - runScopedMentionedSkillKeys, - ); - const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); - const runtimeConfig = { - ...effectiveResolvedConfig, - taskcoreRuntimeSkills: runtimeSkillEntries, - }; - const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({ - companyId: agent.companyId, - heartbeatRunId: run.id, - executionWorkspaceId: existingExecutionWorkspace?.id ?? null, - }); - const executionWorkspaceBase = { - baseCwd: resolvedWorkspace.cwd, - source: resolvedWorkspace.source, - projectId: resolvedWorkspace.projectId, - workspaceId: resolvedWorkspace.workspaceId, - repoUrl: resolvedWorkspace.repoUrl, - repoRef: resolvedWorkspace.repoRef, - } satisfies ExecutionWorkspaceInput; - const reusedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace - ? buildRealizedExecutionWorkspaceFromPersisted({ + const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({ + config: workspaceManagedConfig, + workspaceConfig: existingExecutionWorkspace?.config ?? null, + mode: effectiveExecutionWorkspaceMode, + }); + const mergedConfig = issueAssigneeOverrides?.adapterConfig + ? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig } + : persistedWorkspaceManagedConfig; + const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig); + const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig); + const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({ + companyId: agent.companyId, + executionRunConfig, + projectEnv: projectContext?.env ?? null, + secretsSvc, + }); + const runScopedMentionedSkillKeys = await resolveRunScopedMentionedSkillKeys({ + db, + companyId: agent.companyId, + issueId, + }); + const effectiveResolvedConfig = applyRunScopedMentionedSkillKeys( + resolvedConfig, + runScopedMentionedSkillKeys, + ); + const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); + const runtimeConfig = { + ...effectiveResolvedConfig, + taskcoreRuntimeSkills: runtimeSkillEntries, + }; + const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({ + companyId: agent.companyId, + heartbeatRunId: run.id, + executionWorkspaceId: existingExecutionWorkspace?.id ?? null, + }); + const executionWorkspaceBase = { + baseCwd: resolvedWorkspace.cwd, + source: resolvedWorkspace.source, + projectId: resolvedWorkspace.projectId, + workspaceId: resolvedWorkspace.workspaceId, + repoUrl: resolvedWorkspace.repoUrl, + repoRef: resolvedWorkspace.repoRef, + } satisfies ExecutionWorkspaceInput; + const reusedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace + ? buildRealizedExecutionWorkspaceFromPersisted({ base: executionWorkspaceBase, workspace: existingExecutionWorkspace, }) - : null; - const executionWorkspace = reusedExecutionWorkspace ?? await realizeExecutionWorkspace({ - base: executionWorkspaceBase, - config: runtimeConfig, - issue: issueRef, - agent: { - id: agent.id, - name: agent.name, - companyId: agent.companyId, - }, - recorder: workspaceOperationRecorder, - }); - const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null; - const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null; - let persistedExecutionWorkspace = null; - const nextExecutionWorkspaceMetadataBase = { - ...(existingExecutionWorkspace?.metadata ?? {}), - source: executionWorkspace.source, - createdByRuntime: executionWorkspace.created, - } as Record; - const nextExecutionWorkspaceMetadata = shouldReuseExisting - ? nextExecutionWorkspaceMetadataBase - : configSnapshot - ? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot) - : nextExecutionWorkspaceMetadataBase; - try { - persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace - ? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, { + : null; + const executionWorkspace = reusedExecutionWorkspace ?? await realizeExecutionWorkspace({ + base: executionWorkspaceBase, + config: runtimeConfig, + issue: issueRef, + agent: { + id: agent.id, + name: agent.name, + companyId: agent.companyId, + }, + recorder: workspaceOperationRecorder, + }); + const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null; + const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null; + let persistedExecutionWorkspace = null; + const nextExecutionWorkspaceMetadataBase = { + ...(existingExecutionWorkspace?.metadata ?? {}), + source: executionWorkspace.source, + createdByRuntime: executionWorkspace.created, + } as Record; + const nextExecutionWorkspaceMetadata = shouldReuseExisting + ? nextExecutionWorkspaceMetadataBase + : configSnapshot + ? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot) + : nextExecutionWorkspaceMetadataBase; + try { + persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace + ? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, { cwd: executionWorkspace.cwd, repoUrl: executionWorkspace.repoUrl, baseRef: executionWorkspace.repoRef, @@ -3290,8 +5060,8 @@ export function heartbeatService(db: Db) { lastUsedAt: new Date(), metadata: nextExecutionWorkspaceMetadata, }) - : resolvedProjectId - ? await executionWorkspacesSvc.create({ + : resolvedProjectId + ? await executionWorkspacesSvc.create({ companyId: agent.companyId, projectId: resolvedProjectId, projectWorkspaceId: resolvedProjectWorkspaceId, @@ -3317,399 +5087,400 @@ export function heartbeatService(db: Db) { openedAt: new Date(), metadata: nextExecutionWorkspaceMetadata, }) - : null; - } catch (error) { - if (executionWorkspace.created) { - try { - await cleanupExecutionWorkspaceArtifacts({ - workspace: { - id: existingExecutionWorkspace?.id ?? `transient-${run.id}`, - cwd: executionWorkspace.cwd, - providerType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "local_fs", - providerRef: executionWorkspace.worktreePath, - branchName: executionWorkspace.branchName, - repoUrl: executionWorkspace.repoUrl, - baseRef: executionWorkspace.repoRef, - projectId: resolvedProjectId, - projectWorkspaceId: resolvedProjectWorkspaceId, - sourceIssueId: issueRef?.id ?? null, - metadata: { - createdByRuntime: true, - source: executionWorkspace.source, - }, - }, - projectWorkspace: { - cwd: resolvedWorkspace.cwd, - cleanupCommand: null, - }, - cleanupCommand: configSnapshot?.cleanupCommand ?? null, - teardownCommand: configSnapshot?.teardownCommand ?? projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null, - recorder: workspaceOperationRecorder, - }); - } catch (cleanupError) { - logger.warn( - { - runId: run.id, - issueId, - executionWorkspaceCwd: executionWorkspace.cwd, - cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + : null; + } catch (error) { + if (executionWorkspace.created) { + try { + await cleanupExecutionWorkspaceArtifacts({ + workspace: { + id: existingExecutionWorkspace?.id ?? `transient-${run.id}`, + cwd: executionWorkspace.cwd, + providerType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "local_fs", + providerRef: executionWorkspace.worktreePath, + branchName: executionWorkspace.branchName, + repoUrl: executionWorkspace.repoUrl, + baseRef: executionWorkspace.repoRef, + projectId: resolvedProjectId, + projectWorkspaceId: resolvedProjectWorkspaceId, + sourceIssueId: issueRef?.id ?? null, + metadata: { + createdByRuntime: true, + source: executionWorkspace.source, }, - "Failed to cleanup realized execution workspace after persistence failure", - ); - } + }, + projectWorkspace: { + cwd: resolvedWorkspace.cwd, + cleanupCommand: null, + }, + cleanupCommand: configSnapshot?.cleanupCommand ?? null, + teardownCommand: configSnapshot?.teardownCommand ?? projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null, + recorder: workspaceOperationRecorder, + }); + } catch (cleanupError) { + logger.warn( + { + runId: run.id, + issueId, + executionWorkspaceCwd: executionWorkspace.cwd, + cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }, + "Failed to cleanup realized execution workspace after persistence failure", + ); } - throw error; } - await workspaceOperationRecorder.attachExecutionWorkspaceId(persistedExecutionWorkspace?.id ?? null); - if ( - existingExecutionWorkspace && - persistedExecutionWorkspace && - existingExecutionWorkspace.id !== persistedExecutionWorkspace.id && - existingExecutionWorkspace.status === "active" - ) { - await executionWorkspacesSvc.update(existingExecutionWorkspace.id, { - status: "idle", - cleanupReason: null, - }); + throw error; + } + await workspaceOperationRecorder.attachExecutionWorkspaceId(persistedExecutionWorkspace?.id ?? null); + if ( + existingExecutionWorkspace && + persistedExecutionWorkspace && + existingExecutionWorkspace.id !== persistedExecutionWorkspace.id && + existingExecutionWorkspace.status === "active" + ) { + await executionWorkspacesSvc.update(existingExecutionWorkspace.id, { + status: "idle", + cleanupReason: null, + }); + } + if (issueId && persistedExecutionWorkspace) { + const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode); + const shouldSwitchIssueToExistingWorkspace = + issueRef?.executionWorkspacePreference === "reuse_existing" || + requestedExecutionWorkspaceMode === "isolated_workspace" || + requestedExecutionWorkspaceMode === "operator_branch"; + const nextIssuePatch: Record = {}; + if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) { + nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id; } - if (issueId && persistedExecutionWorkspace) { - const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode); - const shouldSwitchIssueToExistingWorkspace = - issueRef?.executionWorkspacePreference === "reuse_existing" || - requestedExecutionWorkspaceMode === "isolated_workspace" || - requestedExecutionWorkspaceMode === "operator_branch"; - const nextIssuePatch: Record = {}; - if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) { - nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id; - } - if (resolvedProjectWorkspaceId && issueRef?.projectWorkspaceId !== resolvedProjectWorkspaceId) { - nextIssuePatch.projectWorkspaceId = resolvedProjectWorkspaceId; - } - if (shouldSwitchIssueToExistingWorkspace) { - nextIssuePatch.executionWorkspacePreference = "reuse_existing"; - nextIssuePatch.executionWorkspaceSettings = { - ...(issueExecutionWorkspaceSettings ?? {}), - mode: nextIssueWorkspaceMode, - }; - } - if (Object.keys(nextIssuePatch).length > 0) { - await issuesSvc.update(issueId, nextIssuePatch); - } + if (resolvedProjectWorkspaceId && issueRef?.projectWorkspaceId !== resolvedProjectWorkspaceId) { + nextIssuePatch.projectWorkspaceId = resolvedProjectWorkspaceId; } - if (persistedExecutionWorkspace) { - context.executionWorkspaceId = persistedExecutionWorkspace.id; - await db - .update(heartbeatRuns) - .set({ - contextSnapshot: context, - updatedAt: new Date(), - }) - .where(eq(heartbeatRuns.id, run.id)); + if (shouldSwitchIssueToExistingWorkspace) { + nextIssuePatch.executionWorkspacePreference = "reuse_existing"; + nextIssuePatch.executionWorkspaceSettings = { + ...(issueExecutionWorkspaceSettings ?? {}), + mode: nextIssueWorkspaceMode, + }; } - const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({ - agentId: agent.id, - previousSessionParams, - resolvedWorkspace: { - ...resolvedWorkspace, - cwd: executionWorkspace.cwd, - }, - }); - const runtimeSessionParams = runtimeSessionResolution.sessionParams; - const runtimeWorkspaceWarnings = [ - ...resolvedWorkspace.warnings, - ...executionWorkspace.warnings, - ...(runtimeSessionResolution.warning ? [runtimeSessionResolution.warning] : []), - ...(resetTaskSession && sessionResetReason - ? [ + if (Object.keys(nextIssuePatch).length > 0) { + await issuesSvc.update(issueId, nextIssuePatch); + } + } + if (persistedExecutionWorkspace) { + context.executionWorkspaceId = persistedExecutionWorkspace.id; + await db + .update(heartbeatRuns) + .set({ + contextSnapshot: context, + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, run.id)); + } + const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({ + agentId: agent.id, + previousSessionParams, + resolvedWorkspace: { + ...resolvedWorkspace, + cwd: executionWorkspace.cwd, + }, + }); + const runtimeSessionParams = runtimeSessionResolution.sessionParams; + const runtimeWorkspaceWarnings = [ + ...resolvedWorkspace.warnings, + ...executionWorkspace.warnings, + ...(runtimeSessionResolution.warning ? [runtimeSessionResolution.warning] : []), + ...(resetTaskSession && sessionResetReason + ? [ taskKey ? `Skipping saved session resume for task "${taskKey}" because ${sessionResetReason}.` : `Skipping saved session resume because ${sessionResetReason}.`, ] - : []), - ]; - context.taskcoreWorkspace = { - cwd: executionWorkspace.cwd, - source: executionWorkspace.source, - mode: effectiveExecutionWorkspaceMode, - strategy: executionWorkspace.strategy, - projectId: executionWorkspace.projectId, - workspaceId: executionWorkspace.workspaceId, - repoUrl: executionWorkspace.repoUrl, - repoRef: executionWorkspace.repoRef, - branchName: executionWorkspace.branchName, - worktreePath: executionWorkspace.worktreePath, - agentHome: await (async () => { - const home = resolveDefaultAgentWorkspaceDir(agent.id); - await fs.mkdir(home, { recursive: true }); - return home; - })(), - }; - context.taskcoreWorkspaces = resolvedWorkspace.workspaceHints; - const runtimeServiceIntents = (() => { - const runtimeConfig = parseObject(resolvedConfig.workspaceRuntime); - return Array.isArray(runtimeConfig.services) - ? runtimeConfig.services.filter( + : []), + ]; + context.taskcoreWorkspace = { + cwd: executionWorkspace.cwd, + source: executionWorkspace.source, + mode: effectiveExecutionWorkspaceMode, + strategy: executionWorkspace.strategy, + projectId: executionWorkspace.projectId, + workspaceId: executionWorkspace.workspaceId, + repoUrl: executionWorkspace.repoUrl, + repoRef: executionWorkspace.repoRef, + branchName: executionWorkspace.branchName, + worktreePath: executionWorkspace.worktreePath, + agentHome: await (async () => { + const home = resolveDefaultAgentWorkspaceDir(agent.id); + await fs.mkdir(home, { recursive: true }); + return home; + })(), + }; + context.taskcoreWorkspaces = resolvedWorkspace.workspaceHints; + const runtimeServiceIntents = (() => { + const runtimeConfig = parseObject(resolvedConfig.workspaceRuntime); + return Array.isArray(runtimeConfig.services) + ? runtimeConfig.services.filter( (value): value is Record => typeof value === "object" && value !== null, ) - : []; - })(); - if (runtimeServiceIntents.length > 0) { - context.taskcoreRuntimeServiceIntents = runtimeServiceIntents; - } else { - delete context.taskcoreRuntimeServiceIntents; - } - if (executionWorkspace.projectId && !readNonEmptyString(context.projectId)) { - context.projectId = executionWorkspace.projectId; - } - const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId; - let previousSessionDisplayId = truncateDisplayId( - explicitResumeSessionDisplayId ?? + : []; + })(); + if (runtimeServiceIntents.length > 0) { + context.taskcoreRuntimeServiceIntents = runtimeServiceIntents; + } else { + delete context.taskcoreRuntimeServiceIntents; + } + if (executionWorkspace.projectId && !readNonEmptyString(context.projectId)) { + context.projectId = executionWorkspace.projectId; + } + const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId; + let previousSessionDisplayId = truncateDisplayId( + explicitResumeSessionDisplayId ?? taskSessionForRun?.sessionDisplayId ?? (sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ?? readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback, - ); - let runtimeSessionIdForAdapter = - readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback; - let runtimeSessionParamsForAdapter = runtimeSessionParams; + ); + let runtimeSessionIdForAdapter = + readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback; + let runtimeSessionParamsForAdapter = runtimeSessionParams; - const sessionCompaction = await evaluateSessionCompaction({ - agent, - sessionId: previousSessionDisplayId ?? runtimeSessionIdForAdapter, - issueId, - }); - if (sessionCompaction.rotate) { - context.taskcoreSessionHandoffMarkdown = sessionCompaction.handoffMarkdown; - context.taskcoreSessionRotationReason = sessionCompaction.reason; - context.taskcorePreviousSessionId = previousSessionDisplayId ?? runtimeSessionIdForAdapter; - runtimeSessionIdForAdapter = null; - runtimeSessionParamsForAdapter = null; - previousSessionDisplayId = null; - if (sessionCompaction.reason) { - runtimeWorkspaceWarnings.push( - `Starting a fresh session because ${sessionCompaction.reason}.`, - ); - } - } else { - delete context.taskcoreSessionHandoffMarkdown; - delete context.taskcoreSessionRotationReason; - delete context.taskcorePreviousSessionId; + const sessionCompaction = await evaluateSessionCompaction({ + agent, + sessionId: previousSessionDisplayId ?? runtimeSessionIdForAdapter, + issueId, + continuationSummaryBody: continuationSummary?.body ?? null, + }); + if (sessionCompaction.rotate) { + context.taskcoreSessionHandoffMarkdown = sessionCompaction.handoffMarkdown; + context.taskcoreSessionRotationReason = sessionCompaction.reason; + context.taskcorePreviousSessionId = previousSessionDisplayId ?? runtimeSessionIdForAdapter; + runtimeSessionIdForAdapter = null; + runtimeSessionParamsForAdapter = null; + previousSessionDisplayId = null; + if (sessionCompaction.reason) { + runtimeWorkspaceWarnings.push( + `Starting a fresh session because ${sessionCompaction.reason}.`, + ); + } + } else { + delete context.taskcoreSessionHandoffMarkdown; + delete context.taskcoreSessionRotationReason; + delete context.taskcorePreviousSessionId; + } + + const runtimeForAdapter = { + sessionId: runtimeSessionIdForAdapter, + sessionParams: runtimeSessionParamsForAdapter, + sessionDisplayId: previousSessionDisplayId, + taskKey, + }; + + let seq = 1; + let handle: RunLogHandle | null = null; + let stdoutExcerpt = ""; + let stderrExcerpt = ""; + try { + const startedAt = run.startedAt ?? new Date(); + const runningWithSession = await db + .update(heartbeatRuns) + .set({ + startedAt, + sessionIdBefore: runtimeForAdapter.sessionDisplayId ?? runtimeForAdapter.sessionId, + contextSnapshot: context, + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, run.id)) + .returning() + .then((rows) => rows[0] ?? null); + if (runningWithSession) run = runningWithSession; + + const runningAgent = await db + .update(agents) + .set({ status: "running", updatedAt: new Date() }) + .where(eq(agents.id, agent.id)) + .returning() + .then((rows) => rows[0] ?? null); + + if (runningAgent) { + publishLiveEvent({ + companyId: runningAgent.companyId, + type: "agent.status", + payload: { + agentId: runningAgent.id, + status: runningAgent.status, + outcome: "running", + }, + }); } - const runtimeForAdapter = { - sessionId: runtimeSessionIdForAdapter, - sessionParams: runtimeSessionParamsForAdapter, - sessionDisplayId: previousSessionDisplayId, - taskKey, - }; + const currentRun = run; + await appendRunEvent(currentRun, seq++, { + eventType: "lifecycle", + stream: "system", + level: "info", + message: "run started", + }); - let seq = 1; - let handle: RunLogHandle | null = null; - let stdoutExcerpt = ""; - let stderrExcerpt = ""; - try { - const startedAt = run.startedAt ?? new Date(); - const runningWithSession = await db - .update(heartbeatRuns) - .set({ - startedAt, - sessionIdBefore: runtimeForAdapter.sessionDisplayId ?? runtimeForAdapter.sessionId, - contextSnapshot: context, - updatedAt: new Date(), - }) - .where(eq(heartbeatRuns.id, run.id)) - .returning() - .then((rows) => rows[0] ?? null); - if (runningWithSession) run = runningWithSession; + handle = await runLogStore.begin({ + companyId: run.companyId, + agentId: run.agentId, + runId, + }); - const runningAgent = await db - .update(agents) - .set({ status: "running", updatedAt: new Date() }) - .where(eq(agents.id, agent.id)) - .returning() - .then((rows) => rows[0] ?? null); + await db + .update(heartbeatRuns) + .set({ + logStore: handle.store, + logRef: handle.logRef, + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, runId)); - if (runningAgent) { - publishLiveEvent({ - companyId: runningAgent.companyId, - type: "agent.status", - payload: { - agentId: runningAgent.id, - status: runningAgent.status, - outcome: "running", - }, + const currentUserRedactionOptions = await getCurrentUserRedactionOptions(); + const onLog = async (stream: "stdout" | "stderr", chunk: string) => { + const sanitizedChunk = compactRunLogChunk( + redactCurrentUserText(chunk, currentUserRedactionOptions), + ); + if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk); + if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk); + const ts = new Date().toISOString(); + + if (handle) { + await runLogStore.append(handle, { + stream, + chunk: sanitizedChunk, + ts, }); } - const currentRun = run; - await appendRunEvent(currentRun, seq++, { - eventType: "lifecycle", - stream: "system", - level: "info", - message: "run started", - }); + const payloadChunk = + sanitizedChunk.length > MAX_LIVE_LOG_CHUNK_BYTES + ? sanitizedChunk.slice(sanitizedChunk.length - MAX_LIVE_LOG_CHUNK_BYTES) + : sanitizedChunk; - handle = await runLogStore.begin({ + publishLiveEvent({ companyId: run.companyId, - agentId: run.agentId, - runId, + type: "heartbeat.run.log", + payload: { + runId: run.id, + agentId: run.agentId, + ts, + stream, + chunk: payloadChunk, + truncated: payloadChunk.length !== sanitizedChunk.length, + }, }); - + }; + if (runScopedMentionedSkillKeys.length > 0) { + await onLog( + "stdout", + `[taskcore] Enabled run-scoped skills from issue mentions: ${runScopedMentionedSkillKeys.join(", ")}\n`, + ); + } + for (const warning of runtimeWorkspaceWarnings) { + const logEntry = formatRuntimeWorkspaceWarningLog(warning); + await onLog(logEntry.stream, logEntry.chunk); + } + const adapterEnv = Object.fromEntries( + Object.entries(parseObject(resolvedConfig.env)).filter( + (entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string", + ), + ); + const runtimeServices = await ensureRuntimeServicesForRun({ + db, + runId: run.id, + agent: { + id: agent.id, + name: agent.name, + companyId: agent.companyId, + }, + issue: issueRef, + workspace: executionWorkspace, + executionWorkspaceId: persistedExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null, + config: effectiveResolvedConfig, + adapterEnv, + onLog, + }); + if (runtimeServices.length > 0) { + context.taskcoreRuntimeServices = runtimeServices; + context.taskcoreRuntimePrimaryUrl = + runtimeServices.find((service) => readNonEmptyString(service.url))?.url ?? null; await db .update(heartbeatRuns) .set({ - logStore: handle.store, - logRef: handle.logRef, + contextSnapshot: context, updatedAt: new Date(), }) - .where(eq(heartbeatRuns.id, runId)); - - const currentUserRedactionOptions = await getCurrentUserRedactionOptions(); - const onLog = async (stream: "stdout" | "stderr", chunk: string) => { - const sanitizedChunk = compactRunLogChunk( - redactCurrentUserText(chunk, currentUserRedactionOptions), + .where(eq(heartbeatRuns.id, run.id)); + } + if (issueId && (executionWorkspace.created || runtimeServices.some((service) => !service.reused))) { + try { + await issuesSvc.addComment( + issueId, + buildWorkspaceReadyComment({ + workspace: executionWorkspace, + runtimeServices, + }), + { agentId: agent.id, runId: run.id }, ); - if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk); - if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk); - const ts = new Date().toISOString(); - - if (handle) { - await runLogStore.append(handle, { - stream, - chunk: sanitizedChunk, - ts, - }); - } - - const payloadChunk = - sanitizedChunk.length > MAX_LIVE_LOG_CHUNK_BYTES - ? sanitizedChunk.slice(sanitizedChunk.length - MAX_LIVE_LOG_CHUNK_BYTES) - : sanitizedChunk; - - publishLiveEvent({ - companyId: run.companyId, - type: "heartbeat.run.log", - payload: { - runId: run.id, - agentId: run.agentId, - ts, - stream, - chunk: payloadChunk, - truncated: payloadChunk.length !== sanitizedChunk.length, - }, - }); - }; - if (runScopedMentionedSkillKeys.length > 0) { + } catch (err) { await onLog( - "stdout", - `[taskcore] Enabled run-scoped skills from issue mentions: ${runScopedMentionedSkillKeys.join(", ")}\n`, + "stderr", + `[taskcore] Failed to post workspace-ready comment: ${err instanceof Error ? err.message : String(err)}\n`, ); } - for (const warning of runtimeWorkspaceWarnings) { - const logEntry = formatRuntimeWorkspaceWarningLog(warning); - await onLog(logEntry.stream, logEntry.chunk); - } - const adapterEnv = Object.fromEntries( - Object.entries(parseObject(resolvedConfig.env)).filter( - (entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string", - ), - ); - const runtimeServices = await ensureRuntimeServicesForRun({ - db, - runId: run.id, - agent: { - id: agent.id, - name: agent.name, - companyId: agent.companyId, - }, - issue: issueRef, - workspace: executionWorkspace, - executionWorkspaceId: persistedExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null, - config: effectiveResolvedConfig, - adapterEnv, - onLog, - }); - if (runtimeServices.length > 0) { - context.taskcoreRuntimeServices = runtimeServices; - context.taskcoreRuntimePrimaryUrl = - runtimeServices.find((service) => readNonEmptyString(service.url))?.url ?? null; - await db - .update(heartbeatRuns) - .set({ - contextSnapshot: context, - updatedAt: new Date(), - }) - .where(eq(heartbeatRuns.id, run.id)); - } - if (issueId && (executionWorkspace.created || runtimeServices.some((service) => !service.reused))) { - try { - await issuesSvc.addComment( - issueId, - buildWorkspaceReadyComment({ - workspace: executionWorkspace, - runtimeServices, - }), - { agentId: agent.id, runId: run.id }, - ); - } catch (err) { - await onLog( - "stderr", - `[taskcore] Failed to post workspace-ready comment: ${err instanceof Error ? err.message : String(err)}\n`, - ); + } + const onAdapterMeta = async (meta: AdapterInvocationMeta) => { + if (meta.env && secretKeys.size > 0) { + for (const key of secretKeys) { + if (key in meta.env) meta.env[key] = "***REDACTED***"; } } - const onAdapterMeta = async (meta: AdapterInvocationMeta) => { - if (meta.env && secretKeys.size > 0) { - for (const key of secretKeys) { - if (key in meta.env) meta.env[key] = "***REDACTED***"; - } - } - await appendRunEvent(currentRun, seq++, { - eventType: "adapter.invoke", - stream: "system", - level: "info", - message: "adapter invocation", - payload: meta as unknown as Record, - }); - }; + await appendRunEvent(currentRun, seq++, { + eventType: "adapter.invoke", + stream: "system", + level: "info", + message: "adapter invocation", + payload: meta as unknown as Record, + }); + }; - const adapter = getServerAdapter(agent.adapterType); - const authToken = adapter.supportsLocalAgentJwt - ? createLocalAgentJwt(agent.id, agent.companyId, agent.adapterType, run.id) - : null; - if (adapter.supportsLocalAgentJwt && !authToken) { - logger.warn( - { - companyId: agent.companyId, - agentId: agent.id, - runId: run.id, - adapterType: agent.adapterType, - }, - "local agent jwt secret missing or invalid; running without injected TASKCORE_API_KEY", - ); - } - const adapterResult = await adapter.execute({ - runId: run.id, - agent, - runtime: runtimeForAdapter, - config: runtimeConfig, - context, - onLog, - onMeta: onAdapterMeta, - onSpawn: async (meta) => { - await persistRunProcessMetadata(run.id, { - pid: meta.pid, - processGroupId: - "processGroupId" in meta && typeof meta.processGroupId === "number" - ? meta.processGroupId - : null, - startedAt: meta.startedAt, - }); + const adapter = getServerAdapter(agent.adapterType); + const authToken = adapter.supportsLocalAgentJwt + ? createLocalAgentJwt(agent.id, agent.companyId, agent.adapterType, run.id) + : null; + if (adapter.supportsLocalAgentJwt && !authToken) { + logger.warn( + { + companyId: agent.companyId, + agentId: agent.id, + runId: run.id, + adapterType: agent.adapterType, }, - authToken: authToken ?? undefined, - }); - const adapterManagedRuntimeServices = adapterResult.runtimeServices - ? await persistAdapterManagedRuntimeServices({ + "local agent jwt secret missing or invalid; running without injected TASKCORE_API_KEY", + ); + } + const adapterResult = await adapter.execute({ + runId: run.id, + agent, + runtime: runtimeForAdapter, + config: runtimeConfig, + context, + onLog, + onMeta: onAdapterMeta, + onSpawn: async (meta) => { + await persistRunProcessMetadata(run.id, { + pid: meta.pid, + processGroupId: + "processGroupId" in meta && typeof meta.processGroupId === "number" + ? meta.processGroupId + : null, + startedAt: meta.startedAt, + }); + }, + authToken: authToken ?? undefined, + }); + const adapterManagedRuntimeServices = adapterResult.runtimeServices + ? await persistAdapterManagedRuntimeServices({ db, adapterType: agent.adapterType, runId: run.id, @@ -3722,85 +5493,102 @@ export function heartbeatService(db: Db) { workspace: executionWorkspace, reports: adapterResult.runtimeServices, }) - : []; - if (adapterManagedRuntimeServices.length > 0) { - const combinedRuntimeServices = [ - ...runtimeServices, - ...adapterManagedRuntimeServices, - ]; - context.taskcoreRuntimeServices = combinedRuntimeServices; - context.taskcoreRuntimePrimaryUrl = - combinedRuntimeServices.find((service) => readNonEmptyString(service.url))?.url ?? null; - await db - .update(heartbeatRuns) - .set({ - contextSnapshot: context, - updatedAt: new Date(), - }) - .where(eq(heartbeatRuns.id, run.id)); - if (issueId) { - try { - await issuesSvc.addComment( - issueId, - buildWorkspaceReadyComment({ - workspace: executionWorkspace, - runtimeServices: adapterManagedRuntimeServices, - }), - { agentId: agent.id, runId: run.id }, - ); - } catch (err) { - await onLog( - "stderr", - `[taskcore] Failed to post adapter-managed runtime comment: ${err instanceof Error ? err.message : String(err)}\n`, - ); - } + : []; + if (adapterManagedRuntimeServices.length > 0) { + const combinedRuntimeServices = [ + ...runtimeServices, + ...adapterManagedRuntimeServices, + ]; + context.taskcoreRuntimeServices = combinedRuntimeServices; + context.taskcoreRuntimePrimaryUrl = + combinedRuntimeServices.find((service) => readNonEmptyString(service.url))?.url ?? null; + await db + .update(heartbeatRuns) + .set({ + contextSnapshot: context, + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, run.id)); + if (issueId) { + try { + await issuesSvc.addComment( + issueId, + buildWorkspaceReadyComment({ + workspace: executionWorkspace, + runtimeServices: adapterManagedRuntimeServices, + }), + { agentId: agent.id, runId: run.id }, + ); + } catch (err) { + await onLog( + "stderr", + `[taskcore] Failed to post adapter-managed runtime comment: ${err instanceof Error ? err.message : String(err)}\n`, + ); } } - const nextSessionState = resolveNextSessionState({ - codec: sessionCodec, - adapterResult, - previousParams: previousSessionParams, - previousDisplayId: runtimeForAdapter.sessionDisplayId, - previousLegacySessionId: runtimeForAdapter.sessionId, - }); - const rawUsage = normalizeUsageTotals(adapterResult.usage); - const sessionUsageResolution = await resolveNormalizedUsageForSession({ - agentId: agent.id, - runId: run.id, - sessionId: nextSessionState.displayId ?? nextSessionState.legacySessionId, - rawUsage, - }); - const normalizedUsage = sessionUsageResolution.normalizedUsage; - - let outcome: "succeeded" | "failed" | "cancelled" | "timed_out"; - const latestRun = await getRun(run.id); - if (latestRun?.status === "cancelled") { - outcome = "cancelled"; - } else if (adapterResult.timedOut) { - outcome = "timed_out"; - } else if ((adapterResult.exitCode ?? 0) === 0 && !adapterResult.errorMessage) { - outcome = "succeeded"; - } else { - outcome = "failed"; - } - - let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null; - if (handle) { - logSummary = await runLogStore.finalize(handle); - } + } + const nextSessionState = resolveNextSessionState({ + codec: sessionCodec, + adapterResult, + previousParams: previousSessionParams, + previousDisplayId: runtimeForAdapter.sessionDisplayId, + previousLegacySessionId: runtimeForAdapter.sessionId, + }); + const rawUsage = normalizeUsageTotals(adapterResult.usage); + const sessionUsageResolution = await resolveNormalizedUsageForSession({ + agentId: agent.id, + runId: run.id, + sessionId: nextSessionState.displayId ?? nextSessionState.legacySessionId, + rawUsage, + }); + const normalizedUsage = sessionUsageResolution.normalizedUsage; + + let outcome: "succeeded" | "failed" | "cancelled" | "timed_out"; + const latestRun = await getRun(run.id); + if (isHeartbeatRunTerminalStatus(latestRun?.status)) { + outcome = latestRun.status; + } else if (adapterResult.timedOut) { + outcome = "timed_out"; + } else if ((adapterResult.exitCode ?? 0) === 0 && !adapterResult.errorMessage) { + outcome = "succeeded"; + } else { + outcome = "failed"; + } + const runErrorMessage = + outcome === "cancelled" + ? (latestRun?.error ?? adapterResult.errorMessage ?? "Cancelled") + : outcome === "succeeded" + ? null + : redactCurrentUserText( + adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"), + currentUserRedactionOptions, + ); + const runErrorCode = + outcome === "timed_out" + ? "timeout" + : outcome === "cancelled" + ? (latestRun?.errorCode ?? "cancelled") + : outcome === "failed" + ? (adapterResult.errorCode ?? "adapter_failed") + : null; + + let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null; + if (handle) { + logSummary = await runLogStore.finalize(handle); + } - const status = - outcome === "succeeded" - ? "succeeded" - : outcome === "cancelled" - ? "cancelled" - : outcome === "timed_out" - ? "timed_out" - : "failed"; - - const usageJson = - normalizedUsage || adapterResult.costUsd != null - ? ({ + const status = + outcome === "succeeded" + ? "succeeded" + : outcome === "cancelled" + ? "cancelled" + : outcome === "timed_out" + ? "timed_out" + : "failed"; + + const usageJson = + normalizedUsage || adapterResult.costUsd != null + ? ({ ...(normalizedUsage ?? {}), ...(rawUsage ? { rawInputTokens: rawUsage.inputTokens, @@ -3822,213 +5610,260 @@ export function heartbeatService(db: Db) { ...(adapterResult.costUsd != null ? { costUsd: adapterResult.costUsd } : {}), billingType: normalizeLedgerBillingType(adapterResult.billingType), } as Record) - : null; + : null; - const persistedResultJson = mergeHeartbeatRunResultJson( - adapterResult.resultJson ?? null, - adapterResult.summary ?? null, - ); + const persistedResultJson = mergeHeartbeatRunResultJson( + mergeRunStopMetadataForAgent(agent, outcome, { + resultJson: adapterResult.resultJson ?? null, + errorCode: runErrorCode, + errorMessage: runErrorMessage, + }), + adapterResult.summary ?? null, + ); - await setRunStatus(run.id, status, { - finishedAt: new Date(), - error: - outcome === "succeeded" - ? null - : redactCurrentUserText( - adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"), - currentUserRedactionOptions, - ), - errorCode: - outcome === "timed_out" - ? "timeout" - : outcome === "cancelled" - ? "cancelled" - : outcome === "failed" - ? (adapterResult.errorCode ?? "adapter_failed") - : null, - exitCode: adapterResult.exitCode, - signal: adapterResult.signal, - usageJson, - resultJson: persistedResultJson, - sessionIdAfter: nextSessionState.displayId ?? nextSessionState.legacySessionId, - stdoutExcerpt, - stderrExcerpt, - logBytes: logSummary?.bytes, - logSha256: logSummary?.sha256, - logCompressed: logSummary?.compressed ?? false, - }); + let persistedRun = await setRunStatus(run.id, status, { + finishedAt: new Date(), + error: runErrorMessage, + errorCode: runErrorCode, + exitCode: adapterResult.exitCode, + signal: adapterResult.signal, + usageJson, + resultJson: persistedResultJson, + sessionIdAfter: nextSessionState.displayId ?? nextSessionState.legacySessionId, + stdoutExcerpt, + stderrExcerpt, + logBytes: logSummary?.bytes, + logSha256: logSummary?.sha256, + logCompressed: logSummary?.compressed ?? false, + }); + if (persistedRun) { + persistedRun = await classifyAndPersistRunLiveness(persistedRun, persistedResultJson) ?? persistedRun; + } - await setWakeupStatus(run.wakeupRequestId, outcome === "succeeded" ? "completed" : status, { - finishedAt: new Date(), - error: adapterResult.errorMessage ?? null, - }); + await setWakeupStatus(run.wakeupRequestId, outcome === "succeeded" ? "completed" : status, { + finishedAt: new Date(), + error: runErrorMessage, + }); - const finalizedRun = await getRun(run.id); - if (finalizedRun) { - await appendRunEvent(finalizedRun, seq++, { - eventType: "lifecycle", - stream: "system", - level: outcome === "succeeded" ? "info" : "error", - message: `run ${outcome}`, - payload: { - status, - exitCode: adapterResult.exitCode, - }, - }); - if (issueId && outcome === "succeeded") { - try { - const existingRunComment = await findRunIssueComment(finalizedRun.id, finalizedRun.companyId, issueId); - if (!existingRunComment) { - const issueComment = buildHeartbeatRunIssueComment(persistedResultJson); - if (issueComment) { - await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id }); - } + const finalizedRun = persistedRun ?? (await getRun(run.id)); + if (finalizedRun) { + await appendRunEvent(finalizedRun, seq++, { + eventType: "lifecycle", + stream: "system", + level: outcome === "succeeded" ? "info" : "error", + message: `run ${outcome}`, + payload: { + status, + exitCode: adapterResult.exitCode, + }, + }); + const livenessRun = finalizedRun; + await refreshContinuationSummaryForRun(livenessRun, agent); + if (issueId && outcome === "succeeded") { + try { + const existingRunComment = await findRunIssueComment(livenessRun.id, livenessRun.companyId, issueId); + if (!existingRunComment) { + const issueComment = buildHeartbeatRunIssueComment(persistedResultJson); + if (issueComment) { + await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: livenessRun.id }); } - } catch (err) { - await onLog( - "stderr", - `[taskcore] Failed to post run summary comment: ${err instanceof Error ? err.message : String(err)}\n`, - ); - } - } - await finalizeIssueCommentPolicy(finalizedRun, agent); - await releaseIssueExecutionAndPromote(finalizedRun); - } - - if (finalizedRun) { - await updateRuntimeState(agent, finalizedRun, adapterResult, { - legacySessionId: nextSessionState.legacySessionId, - }, normalizedUsage); - if (taskKey) { - if (adapterResult.clearSession || (!nextSessionState.params && !nextSessionState.displayId)) { - await clearTaskSessions(agent.companyId, agent.id, { - taskKey, - adapterType: agent.adapterType, - }); - } else { - await upsertTaskSession({ - companyId: agent.companyId, - agentId: agent.id, - adapterType: agent.adapterType, - taskKey, - sessionParamsJson: nextSessionState.params, - sessionDisplayId: nextSessionState.displayId, - lastRunId: finalizedRun.id, - lastError: outcome === "succeeded" ? null : (adapterResult.errorMessage ?? "run_failed"), - }); } + } catch (err) { + await onLog( + "stderr", + `[taskcore] Failed to post run summary comment: ${err instanceof Error ? err.message : String(err)}\n`, + ); } } - await finalizeAgentStatus(agent.id, outcome); - } catch (err) { - const message = redactCurrentUserText( - err instanceof Error ? err.message : "Unknown adapter failure", - await getCurrentUserRedactionOptions(), - ); - logger.error({ err, runId }, "heartbeat execution failed"); - - let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null; - if (handle) { - try { - logSummary = await runLogStore.finalize(handle); - } catch (finalizeErr) { - logger.warn({ err: finalizeErr, runId }, "failed to finalize run log after error"); - } + if (outcome === "failed" && livenessRun.errorCode === "codex_transient_upstream") { + await scheduleBoundedRetryForRun(livenessRun, agent); } + await finalizeIssueCommentPolicy(livenessRun, agent); + await releaseIssueExecutionAndPromote(livenessRun); + await handleRunLivenessContinuation(livenessRun); + } - const failedRun = await setRunStatus(run.id, "failed", { - error: message, - errorCode: "adapter_failed", - finishedAt: new Date(), - stdoutExcerpt, - stderrExcerpt, - logBytes: logSummary?.bytes, - logSha256: logSummary?.sha256, - logCompressed: logSummary?.compressed ?? false, - }); - await setWakeupStatus(run.wakeupRequestId, "failed", { - finishedAt: new Date(), - error: message, - }); - - if (failedRun) { - await appendRunEvent(failedRun, seq++, { - eventType: "error", - stream: "system", - level: "error", - message, - }); - await finalizeIssueCommentPolicy(failedRun, agent); - await releaseIssueExecutionAndPromote(failedRun); - - await updateRuntimeState(agent, failedRun, { - exitCode: null, - signal: null, - timedOut: false, - errorMessage: message, - }, { - legacySessionId: runtimeForAdapter.sessionId, - }); - - if (taskKey && (previousSessionParams || previousSessionDisplayId || taskSession)) { + if (finalizedRun) { + await updateRuntimeState(agent, finalizedRun, adapterResult, { + legacySessionId: nextSessionState.legacySessionId, + }, normalizedUsage); + if (taskKey) { + if (adapterResult.clearSession || (!nextSessionState.params && !nextSessionState.displayId)) { + await clearTaskSessions(agent.companyId, agent.id, { + taskKey, + adapterType: agent.adapterType, + }); + } else { await upsertTaskSession({ companyId: agent.companyId, agentId: agent.id, adapterType: agent.adapterType, taskKey, - sessionParamsJson: previousSessionParams, - sessionDisplayId: previousSessionDisplayId, - lastRunId: failedRun.id, - lastError: message, + sessionParamsJson: nextSessionState.params, + sessionDisplayId: nextSessionState.displayId, + lastRunId: finalizedRun.id, + lastError: outcome === "succeeded" ? null : (adapterResult.errorMessage ?? "run_failed"), }); } } + } + await finalizeAgentStatus(agent.id, outcome); + } catch (err) { + const message = redactCurrentUserText( + err instanceof Error ? err.message : "Unknown adapter failure", + await getCurrentUserRedactionOptions(), + ); + logger.error({ err, runId }, "heartbeat execution failed"); - await finalizeAgentStatus(agent.id, "failed"); + let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null; + if (handle) { + try { + logSummary = await runLogStore.finalize(handle); + } catch (finalizeErr) { + logger.warn({ err: finalizeErr, runId }, "failed to finalize run log after error"); + } } - } catch (outerErr) { - // Setup code before adapter.execute threw (e.g. ensureRuntimeState, resolveWorkspaceForRun). - // The inner catch did not fire, so we must record the failure here. - const message = outerErr instanceof Error ? outerErr.message : "Unknown setup failure"; - logger.error({ err: outerErr, runId }, "heartbeat execution setup failed"); - await setRunStatus(runId, "failed", { + + const failedRun = await setRunStatus(run.id, "failed", { error: message, errorCode: "adapter_failed", finishedAt: new Date(), - }).catch(() => undefined); + resultJson: mergeRunStopMetadataForAgent(agent, "failed", { + errorCode: "adapter_failed", + errorMessage: message, + }), + stdoutExcerpt, + stderrExcerpt, + logBytes: logSummary?.bytes, + logSha256: logSummary?.sha256, + logCompressed: logSummary?.compressed ?? false, + }); await setWakeupStatus(run.wakeupRequestId, "failed", { finishedAt: new Date(), error: message, - }).catch(() => undefined); - const failedRun = await getRun(runId).catch(() => null); + }); + if (failedRun) { - // Emit a run-log event so the failure is visible in the run timeline, - // consistent with what the inner catch block does for adapter failures. - await appendRunEvent(failedRun, 1, { + await appendRunEvent(failedRun, seq++, { eventType: "error", stream: "system", level: "error", message, - }).catch(() => undefined); - const failedAgent = await getAgent(run.agentId).catch(() => null); - if (failedAgent) { - await finalizeIssueCommentPolicy(failedRun, failedAgent).catch(() => undefined); + }); + const livenessRun = await classifyAndPersistRunLiveness(failedRun) ?? failedRun; + await refreshContinuationSummaryForRun(livenessRun, agent); + await finalizeIssueCommentPolicy(livenessRun, agent); + await releaseIssueExecutionAndPromote(livenessRun); + + await updateRuntimeState(agent, livenessRun, { + exitCode: null, + signal: null, + timedOut: false, + errorMessage: message, + }, { + legacySessionId: runtimeForAdapter.sessionId, + }); + + if (taskKey && (previousSessionParams || previousSessionDisplayId || taskSession)) { + await upsertTaskSession({ + companyId: agent.companyId, + agentId: agent.id, + adapterType: agent.adapterType, + taskKey, + sessionParamsJson: previousSessionParams, + sessionDisplayId: previousSessionDisplayId, + lastRunId: failedRun.id, + lastError: message, + }); } - await releaseIssueExecutionAndPromote(failedRun).catch(() => undefined); } - // Ensure the agent is not left stuck in "running" if the inner catch handler's - // DB calls threw (e.g. a transient DB error in finalizeAgentStatus). - await finalizeAgentStatus(run.agentId, "failed").catch(() => undefined); - } finally { - await releaseRuntimeServicesForRun(run.id).catch(() => undefined); - activeRunExecutions.delete(run.id); - await startNextQueuedRunForAgent(run.agentId); + + await finalizeAgentStatus(agent.id, "failed"); + } + } catch (outerErr) { + // Setup code before adapter.execute threw (e.g. ensureRuntimeState, resolveWorkspaceForRun). + // The inner catch did not fire, so we must record the failure here. + const message = outerErr instanceof Error ? outerErr.message : "Unknown setup failure"; + logger.error({ err: outerErr, runId }, "heartbeat execution setup failed"); + const setupFailureAgent = await getAgent(run.agentId).catch(() => null); + await setRunStatus(runId, "failed", { + error: message, + errorCode: "adapter_failed", + finishedAt: new Date(), + ...(setupFailureAgent ? { + resultJson: mergeRunStopMetadataForAgent(setupFailureAgent, "failed", { + errorCode: "adapter_failed", + errorMessage: message, + }), + } : {}), + }).catch(() => undefined); + await setWakeupStatus(run.wakeupRequestId, "failed", { + finishedAt: new Date(), + error: message, + }).catch(() => undefined); + const failedRun = await getRun(runId).catch(() => null); + if (failedRun) { + // Emit a run-log event so the failure is visible in the run timeline, + // consistent with what the inner catch block does for adapter failures. + await appendRunEvent(failedRun, 1, { + eventType: "error", + stream: "system", + level: "error", + message, + }).catch(() => undefined); + const livenessRun = await classifyAndPersistRunLiveness(failedRun).catch(() => failedRun); + const failedAgent = setupFailureAgent ?? await getAgent(run.agentId).catch(() => null); + if (failedAgent) { + await refreshContinuationSummaryForRun(livenessRun, failedAgent).catch(() => undefined); + await finalizeIssueCommentPolicy(livenessRun, failedAgent).catch(() => undefined); + } + await releaseIssueExecutionAndPromote(livenessRun).catch(() => undefined); + } + // Ensure the agent is not left stuck in "running" if the inner catch handler's + // DB calls threw (e.g. a transient DB error in finalizeAgentStatus). + await finalizeAgentStatus(run.agentId, "failed").catch(() => undefined); + } finally { + await releaseRuntimeServicesForRun(run.id).catch(() => undefined); + activeRunExecutions.delete(run.id); + await startNextQueuedRunForAgent(run.agentId); + } + } + + function buildImmediateExecutionPathRecoveryComment(input: { + status: "todo" | "in_progress"; + latestRun: Pick | null | undefined; + }) { + const failureSummary = summarizeRunFailureForIssueComment(input.latestRun); + if (input.status === "todo") { + return ( + "Taskcore automatically retried dispatch for this assigned `todo` issue during terminal run recovery, " + + `but it still has no live execution path.${failureSummary ?? ""} ` + + "Moving it to `blocked` so it is visible for intervention." + ); } + + return ( + "Taskcore automatically retried continuation for this assigned `in_progress` issue during terminal run " + + `recovery, but it still has no live execution path.${failureSummary ?? ""} ` + + "Moving it to `blocked` so it is visible for intervention." + ); } async function releaseIssueExecutionAndPromote(run: typeof heartbeatRuns.$inferSelect) { const runContext = parseObject(run.contextSnapshot); const contextIssueId = readNonEmptyString(runContext.issueId); + const taskKey = deriveTaskKeyWithHeartbeatFallback(runContext, null); + const recoveryAgent = await getAgent(run.agentId); + const recoveryAgentInvokable = + recoveryAgent && + recoveryAgent.status !== "paused" && + recoveryAgent.status !== "terminated" && + recoveryAgent.status !== "pending_approval"; + const recoverySessionBefore = recoveryAgentInvokable + ? await resolveSessionBeforeForWakeup(recoveryAgent, taskKey) + : null; + const recoveryAgentNameKey = normalizeAgentNameKey(recoveryAgent?.name); + const promotionResult = await db.transaction(async (tx) => { if (contextIssueId) { await tx.execute( @@ -4046,6 +5881,8 @@ export function heartbeatService(db: Db) { companyId: issues.companyId, identifier: issues.identifier, status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, executionRunId: issues.executionRunId, }) .from(issues) @@ -4087,7 +5924,7 @@ export function heartbeatService(db: Db) { .limit(1) .then((rows) => rows[0] ?? null); - if (!deferred) return null; + if (!deferred) break; const deferredAgent = await tx .select() @@ -4184,6 +6021,9 @@ export function heartbeatService(db: Db) { const sessionBefore = readNonEmptyString(promotedContextSnapshot.resumeSessionDisplayId) ?? await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey); + const promotedContinuationAttempt = readContinuationAttempt( + promotedContextSnapshot.livenessContinuationAttempt, + ); const now = new Date(); const newRun = await tx .insert(heartbeatRuns) @@ -4196,6 +6036,7 @@ export function heartbeatService(db: Db) { wakeupRequestId: deferred.id, contextSnapshot: promotedContextSnapshot, sessionIdBefore: sessionBefore, + continuationAttempt: promotedContinuationAttempt, }) .returning() .then((rows) => rows[0]); @@ -4224,16 +6065,165 @@ export function heartbeatService(db: Db) { .where(eq(issues.id, issue.id)); return { + kind: "promoted" as const, run: newRun, reopenedActivity, }; } + + const issueNeedsImmediateRecovery = + (issue.status === "todo" || issue.status === "in_progress") && + !issue.assigneeUserId && + issue.assigneeAgentId === run.agentId && + (run.status === "failed" || run.status === "timed_out" || run.status === "cancelled"); + + if (!issueNeedsImmediateRecovery) { + return { kind: "released" as const }; + } + + const existingExecutionPath = await tx + .select({ id: heartbeatRuns.id }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, issue.companyId), + inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]), + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`, + sql`${heartbeatRuns.id} <> ${run.id}`, + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + if (existingExecutionPath) { + return { kind: "released" as const }; + } + + const shouldBlockImmediately = + !recoveryAgentInvokable || + !recoveryAgent || + didAutomaticRecoveryFail(run, issue.status === "todo" ? "assignment_recovery" : "issue_continuation_needed"); + if (shouldBlockImmediately) { + const comment = buildImmediateExecutionPathRecoveryComment({ + status: issue.status as "todo" | "in_progress", + latestRun: run, + }); + await tx + .update(issues) + .set({ + status: "blocked", + updatedAt: new Date(), + }) + .where(eq(issues.id, issue.id)); + return { + kind: "blocked" as const, + issueId: issue.id, + issueIdentifier: issue.identifier, + previousStatus: issue.status, + comment, + }; + } + + const retryReason = issue.status === "todo" ? "assignment_recovery" : "issue_continuation_needed"; + const recoveryReason = issue.status === "todo" ? "issue_assignment_recovery" : "issue_continuation_needed"; + const recoverySource = + issue.status === "todo" ? "issue.assignment_recovery" : "issue.continuation_recovery"; + const now = new Date(); + const wakeupRequest = await tx + .insert(agentWakeupRequests) + .values({ + companyId: issue.companyId, + agentId: recoveryAgent.id, + source: "automation", + triggerDetail: "system", + reason: recoveryReason, + payload: { + issueId: issue.id, + retryOfRunId: run.id, + }, + status: "queued", + requestedByActorType: "system", + requestedByActorId: null, + updatedAt: now, + }) + .returning() + .then((rows) => rows[0]); + + const queuedRun = await tx + .insert(heartbeatRuns) + .values({ + companyId: issue.companyId, + agentId: recoveryAgent.id, + invocationSource: "automation", + triggerDetail: "system", + status: "queued", + wakeupRequestId: wakeupRequest.id, + contextSnapshot: { + issueId: issue.id, + taskId: issue.id, + wakeReason: recoveryReason, + retryReason, + source: recoverySource, + retryOfRunId: run.id, + }, + sessionIdBefore: recoverySessionBefore, + retryOfRunId: run.id, + updatedAt: now, + }) + .returning() + .then((rows) => rows[0]); + + await tx + .update(agentWakeupRequests) + .set({ + runId: queuedRun.id, + updatedAt: now, + }) + .where(eq(agentWakeupRequests.id, wakeupRequest.id)); + + await tx + .update(issues) + .set({ + executionRunId: queuedRun.id, + executionAgentNameKey: recoveryAgentNameKey, + executionLockedAt: now, + updatedAt: now, + }) + .where(eq(issues.id, issue.id)); + + return { + kind: "queued_recovery" as const, + run: queuedRun, + }; }); + if (promotionResult?.kind === "blocked") { + await issuesSvc.addComment(promotionResult.issueId, promotionResult.comment, {}); + await logActivity(db, { + companyId: run.companyId, + actorType: "system", + actorId: "system", + agentId: null, + runId: run.id, + action: "issue.updated", + entityType: "issue", + entityId: promotionResult.issueId, + details: { + identifier: promotionResult.issueIdentifier, + status: "blocked", + previousStatus: promotionResult.previousStatus, + source: "heartbeat.release_issue_execution_and_promote", + latestRunId: run.id, + latestRunStatus: run.status, + latestRunErrorCode: run.errorCode ?? null, + }, + }); + return; + } + const promotedRun = promotionResult?.run ?? null; if (!promotedRun) return; - if (promotionResult?.reopenedActivity) { + if (promotionResult?.kind === "promoted" && promotionResult.reopenedActivity) { await logActivity(db, promotionResult.reopenedActivity); } @@ -4256,7 +6246,6 @@ export function heartbeatService(db: Db) { const source = opts.source ?? "on_demand"; const triggerDetail = opts.triggerDetail ?? null; const contextSnapshot: Record = { ...(opts.contextSnapshot ?? {}) }; - const wakeReason = opts.wakeReason ?? (source === "timer" ? "heartbeat_timer" : "force_wake"); const reason = opts.reason ?? null; const payload = opts.payload ?? null; const { @@ -4275,25 +6264,6 @@ export function heartbeatService(db: Db) { const agent = await getAgent(agentId); if (!agent) throw notFound("Agent not found"); - - // Log activity for the wake attempt - await logActivity(db, { - companyId: agent.companyId, - actorType: opts.requestedByActorType ?? "system", - actorId: opts.requestedByActorId ?? "system", - action: "agent.wakeup_requested", - entityType: "agent", - entityId: agentId, - agentId, - details: { - wakeReason, - source, - triggerDetail, - reason, - issueId, - }, - }); - const explicitResumeSession = await resolveExplicitResumeSessionOverride(agent, payload, taskKey); if (explicitResumeSession) { enrichedContextSnapshot.resumeFromRunId = explicitResumeSession.resumeFromRunId; @@ -4314,6 +6284,7 @@ export function heartbeatService(db: Db) { const sessionBefore = explicitResumeSession?.sessionDisplayId ?? await resolveSessionBeforeForWakeup(agent, effectiveTaskKey); + const continuationAttempt = readContinuationAttempt(enrichedContextSnapshot.livenessContinuationAttempt); const writeSkippedRequest = async (skipReason: string) => { await db.insert(agentWakeupRequests).values({ @@ -4321,7 +6292,6 @@ export function heartbeatService(db: Db) { agentId, source, triggerDetail, - wakeReason, reason: skipReason, payload, status: "skipped", @@ -4400,7 +6370,6 @@ export function heartbeatService(db: Db) { agentId, source, triggerDetail, - wakeReason, reason: "issue_execution_issue_not_found", payload, status: "skipped", @@ -4420,7 +6389,12 @@ export function heartbeatService(db: Db) { .then((rows) => rows[0] ?? null) : null; - if (activeExecutionRun && activeExecutionRun.status !== "queued" && activeExecutionRun.status !== "running") { + if ( + activeExecutionRun && + !EXECUTION_PATH_HEARTBEAT_RUN_STATUSES.includes( + activeExecutionRun.status as (typeof EXECUTION_PATH_HEARTBEAT_RUN_STATUSES)[number], + ) + ) { activeExecutionRun = null; } @@ -4443,7 +6417,7 @@ export function heartbeatService(db: Db) { .where( and( eq(heartbeatRuns.companyId, issue.companyId), - inArray(heartbeatRuns.status, ["queued", "running"]), + inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]), sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`, ), ) @@ -4473,6 +6447,53 @@ export function heartbeatService(db: Db) { } } + const dependencyReadiness = await issuesSvc.listDependencyReadiness( + issue.companyId, + [issue.id], + tx, + ).then((rows) => rows.get(issue.id) ?? null); + + // Blocked descendants should stay idle until the final blocker resolves. + // Human comment/mention wakes are the exception: they may run in a + // bounded interaction mode so the assignee can answer or triage. + const blockedInteractionWake = + dependencyReadiness && + !dependencyReadiness.isDependencyReady && + allowsBlockedIssueInteractionWake(enrichedContextSnapshot); + + if (blockedInteractionWake) { + enrichedContextSnapshot.dependencyBlockedInteraction = true; + enrichedContextSnapshot.unresolvedBlockerIssueIds = dependencyReadiness.unresolvedBlockerIssueIds; + enrichedContextSnapshot.unresolvedBlockerCount = dependencyReadiness.unresolvedBlockerCount; + enrichedContextSnapshot.unresolvedBlockerSummaries = await listUnresolvedBlockerSummaries( + tx, + issue.companyId, + issue.id, + dependencyReadiness.unresolvedBlockerIssueIds, + ); + } + + if (!activeExecutionRun && dependencyReadiness && !dependencyReadiness.isDependencyReady && !blockedInteractionWake) { + await tx.insert(agentWakeupRequests).values({ + companyId: agent.companyId, + agentId, + source, + triggerDetail, + reason: "issue_dependencies_blocked", + payload: { + ...(payload ?? {}), + issueId, + unresolvedBlockerIssueIds: dependencyReadiness.unresolvedBlockerIssueIds, + }, + status: "skipped", + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + idempotencyKey: opts.idempotencyKey ?? null, + finishedAt: new Date(), + }); + return { kind: "skipped" as const }; + } + if (activeExecutionRun) { const executionAgent = await tx .select({ name: agents.name }) @@ -4484,12 +6505,12 @@ export function heartbeatService(db: Db) { normalizeAgentNameKey(executionAgent?.name); const isSameExecutionAgent = Boolean(executionAgentNameKey) && executionAgentNameKey === agentNameKey; - const shouldQueueFollowupForCommentWake = - Boolean(wakeCommentId) && + const shouldQueueFollowupForRunningWake = + shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId }) && activeExecutionRun.status === "running" && isSameExecutionAgent; - if (isSameExecutionAgent && !shouldQueueFollowupForCommentWake) { + if (isSameExecutionAgent && !shouldQueueFollowupForRunningWake) { const mergedContextSnapshot = mergeCoalescedContextSnapshot( activeExecutionRun.contextSnapshot, enrichedContextSnapshot, @@ -4575,7 +6596,6 @@ export function heartbeatService(db: Db) { agentId, source, triggerDetail, - wakeReason, reason: "issue_execution_deferred", payload: deferredPayload, status: "deferred_issue_execution", @@ -4594,7 +6614,6 @@ export function heartbeatService(db: Db) { agentId, source, triggerDetail, - wakeReason, reason, payload, status: "queued", @@ -4612,11 +6631,11 @@ export function heartbeatService(db: Db) { agentId, invocationSource: source, triggerDetail, - wakeReason, status: "queued", wakeupRequestId: wakeupRequest.id, contextSnapshot: enrichedContextSnapshot, sessionIdBefore: sessionBefore, + continuationAttempt, }) .returning() .then((rows) => rows[0]); @@ -4637,7 +6656,10 @@ export function heartbeatService(db: Db) { }); if (outcome.kind === "deferred" || outcome.kind === "skipped") return null; - if (outcome.kind === "coalesced") return outcome.run; + if (outcome.kind === "coalesced") { + await startNextQueuedRunForAgent(agent.id); + return outcome.run; + } const newRun = outcome.run; publishLiveEvent({ @@ -4659,21 +6681,27 @@ export function heartbeatService(db: Db) { const activeRuns = await db .select() .from(heartbeatRuns) - .where(and(eq(heartbeatRuns.agentId, agentId), inArray(heartbeatRuns.status, ["queued", "running"]))) + .where(and(eq(heartbeatRuns.agentId, agentId), inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]))) .orderBy(desc(heartbeatRuns.createdAt)); const sameScopeQueuedRun = activeRuns.find( (candidate) => candidate.status === "queued" && isSameTaskScope(runTaskKey(candidate), taskKey), ); + const sameScopeScheduledRetryRun = activeRuns.find( + (candidate) => candidate.status === "scheduled_retry" && isSameTaskScope(runTaskKey(candidate), taskKey), + ); const sameScopeRunningRun = activeRuns.find( (candidate) => candidate.status === "running" && isSameTaskScope(runTaskKey(candidate), taskKey), ); - const shouldQueueFollowupForCommentWake = - Boolean(wakeCommentId) && Boolean(sameScopeRunningRun) && !sameScopeQueuedRun; + const shouldQueueFollowupForRunningWake = + Boolean(sameScopeRunningRun) && + !sameScopeQueuedRun && + shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId }); const coalescedTargetRun = sameScopeQueuedRun ?? - (shouldQueueFollowupForCommentWake ? null : sameScopeRunningRun ?? null); + sameScopeScheduledRetryRun ?? + (shouldQueueFollowupForRunningWake ? null : sameScopeRunningRun ?? null); if (coalescedTargetRun) { const mergedContextSnapshot = mergeCoalescedContextSnapshot( @@ -4736,6 +6764,7 @@ export function heartbeatService(db: Db) { wakeupRequestId: wakeupRequest.id, contextSnapshot: enrichedContextSnapshot, sessionIdBefore: sessionBefore, + continuationAttempt, }) .returning() .then((rows) => rows[0]); @@ -4782,7 +6811,7 @@ export function heartbeatService(db: Db) { .where( and( eq(heartbeatRuns.companyId, companyId), - inArray(heartbeatRuns.status, ["queued", "running"]), + inArray(heartbeatRuns.status, [...CANCELLABLE_HEARTBEAT_RUN_STATUSES]), sql`${effectiveProjectId} = ${projectId}`, ), ); @@ -4867,7 +6896,8 @@ export function heartbeatService(db: Db) { async function cancelRunInternal(runId: string, reason = "Cancelled by control plane") { const run = await getRun(runId); if (!run) throw notFound("Heartbeat run not found"); - if (run.status !== "running" && run.status !== "queued") return run; + if (!CANCELLABLE_HEARTBEAT_RUN_STATUSES.includes(run.status as (typeof CANCELLABLE_HEARTBEAT_RUN_STATUSES)[number])) return run; + const agent = await getAgent(run.agentId); const running = runningProcesses.get(run.id); if (running) { @@ -4887,6 +6917,13 @@ export function heartbeatService(db: Db) { finishedAt: new Date(), error: reason, errorCode: "cancelled", + ...(agent ? { + resultJson: mergeRunStopMetadataForAgent(agent, "cancelled", { + resultJson: parseObject(run.resultJson), + errorCode: "cancelled", + errorMessage: reason, + }), + } : {}), }); await setWakeupStatus(run.wakeupRequestId, "cancelled", { @@ -4911,16 +6948,24 @@ export function heartbeatService(db: Db) { } async function cancelActiveForAgentInternal(agentId: string, reason = "Cancelled due to agent pause") { + const agent = await getAgent(agentId); const runs = await db .select() .from(heartbeatRuns) - .where(and(eq(heartbeatRuns.agentId, agentId), inArray(heartbeatRuns.status, ["queued", "running"]))); + .where(and(eq(heartbeatRuns.agentId, agentId), inArray(heartbeatRuns.status, [...CANCELLABLE_HEARTBEAT_RUN_STATUSES]))); for (const run of runs) { await setRunStatus(run.id, "cancelled", { finishedAt: new Date(), error: reason, errorCode: "cancelled", + ...(agent ? { + resultJson: mergeRunStopMetadataForAgent(agent, "cancelled", { + resultJson: parseObject(run.resultJson), + errorCode: "cancelled", + errorMessage: reason, + }), + } : {}), }); await setWakeupStatus(run.wakeupRequestId, "cancelled", { @@ -4963,7 +7008,7 @@ export function heartbeatService(db: Db) { .where( and( eq(heartbeatRuns.companyId, scope.companyId), - inArray(heartbeatRuns.status, ["queued", "running"]), + inArray(heartbeatRuns.status, [...CANCELLABLE_HEARTBEAT_RUN_STATUSES]), ), ) .then((rows) => rows.map((row) => row.id)) @@ -4978,8 +7023,21 @@ export function heartbeatService(db: Db) { return { list: async (companyId: string, agentId?: string, limit?: number) => { + const safeForLegacyEncoding = await hasUnsafeTextProjectionDatabase(); const query = db - .select(heartbeatRunListColumns) + .select( + safeForLegacyEncoding + ? { + ...heartbeatRunListColumns, + error: sql`NULL`.as("error"), + ...heartbeatRunListContextColumns, + } + : { + ...heartbeatRunListColumns, + ...heartbeatRunListContextColumns, + ...heartbeatRunListResultColumns, + }, + ) .from(heartbeatRuns) .where( agentId @@ -4989,14 +7047,65 @@ export function heartbeatService(db: Db) { .orderBy(desc(heartbeatRuns.createdAt)); const rows = limit ? await query.limit(limit) : await query; - return rows.map((row) => ({ - ...row, - resultJson: summarizeHeartbeatRunResultJson(row.resultJson), - })); + return rows.map((row) => { + const { + contextIssueId, + contextTaskId, + contextTaskKey, + contextCommentId, + contextWakeCommentId, + contextWakeReason, + contextWakeSource, + contextWakeTriggerDetail, + resultSummary, + resultResult, + resultMessage, + resultError, + resultTotalCostUsd, + resultCostUsd, + resultCostUsdCamel, + ...rest + } = row as typeof row & { + resultSummary?: string | null; + resultResult?: string | null; + resultMessage?: string | null; + resultError?: string | null; + resultTotalCostUsd?: string | null; + resultCostUsd?: string | null; + resultCostUsdCamel?: string | null; + }; + + return { + ...rest, + contextSnapshot: summarizeHeartbeatRunContextSnapshot({ + issueId: contextIssueId, + taskId: contextTaskId, + taskKey: contextTaskKey, + commentId: contextCommentId, + wakeCommentId: contextWakeCommentId, + wakeReason: contextWakeReason, + wakeSource: contextWakeSource, + wakeTriggerDetail: contextWakeTriggerDetail, + }), + resultJson: safeForLegacyEncoding + ? null + : summarizeHeartbeatRunListResultJson({ + summary: resultSummary, + result: resultResult, + message: resultMessage, + error: resultError, + totalCostUsd: resultTotalCostUsd, + costUsd: resultCostUsd, + costUsdCamel: resultCostUsdCamel, + }), + }; + }); }, getRun, + getRunLogAccess, + getRuntimeState: async (agentId: string) => { const state = await getRuntimeState(agentId); const agent = await getAgent(agentId); @@ -5070,8 +7179,36 @@ export function heartbeatService(db: Db) { .orderBy(asc(heartbeatRunEvents.seq)) .limit(Math.max(1, Math.min(limit, 1000))), - readLog: async (runId: string, opts?: { offset?: number; limitBytes?: number }) => { - const run = await getRun(runId); + getRetryExhaustedReason: async (runId: string) => { + const row = await db + .select({ + message: heartbeatRunEvents.message, + }) + .from(heartbeatRunEvents) + .where( + and( + eq(heartbeatRunEvents.runId, runId), + eq(heartbeatRunEvents.eventType, "lifecycle"), + sql`${heartbeatRunEvents.message} like 'Bounded retry exhausted%'`, + ), + ) + .orderBy(desc(heartbeatRunEvents.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + return row?.message ?? null; + }, + + readLog: async ( + runOrLookup: string | { + id: string; + companyId: string; + logStore: string | null; + logRef: string | null; + }, + opts?: { offset?: number; limitBytes?: number }, + ) => { + const run = typeof runOrLookup === "string" ? await getRunLogAccess(runOrLookup) : runOrLookup; + const runId = typeof runOrLookup === "string" ? runOrLookup : runOrLookup.id; if (!run) throw notFound("Heartbeat run not found"); if (!run.logStore || !run.logRef) throw notFound("Run log not found"); @@ -5088,7 +7225,9 @@ export function heartbeatService(db: Db) { store: run.logStore, logRef: run.logRef, ...result, - content: redactCurrentUserText(result.content, await getCurrentUserRedactionOptions()), + // Run-log chunks are already redacted before they are appended to the store. + // Rewriting the full chunk again on every poll creates avoidable string copies. + content: result.content, }; }, @@ -5113,10 +7252,30 @@ export function heartbeatService(db: Db) { reapOrphanedRuns, + promoteDueScheduledRetries, + resumeQueuedRuns, + scheduleBoundedRetry: async ( + runId: string, + opts?: { + now?: Date; + random?: () => number; + retryReason?: string; + wakeReason?: string; + }, + ) => { + const run = await getRun(runId, { unsafeFullResultJson: true }); + if (!run) return { outcome: "missing_run" as const }; + const agent = await getAgent(run.agentId); + if (!agent) return { outcome: "missing_agent" as const }; + return scheduleBoundedRetryForRun(run, agent, opts); + }, + reconcileStrandedAssignedIssues, + reconcileIssueGraphLiveness, + tickTimers: async (now = new Date()) => { const allAgents = await db.select().from(agents); let checked = 0; @@ -5133,38 +7292,15 @@ export function heartbeatService(db: Db) { const elapsedMs = now.getTime() - baseline; if (elapsedMs < policy.intervalSec * 1000) continue; - // Deterministic Wake Gating: Only wake if agent has active work OR it's a forced interval - const activeTasks = await db - .select({ id: issues.id }) - .from(issues) - .where( - and( - eq(issues.assigneeAgentId, agent.id), - inArray(issues.status, ["todo", "in_progress", "in_review", "blocked"]), - ), - ) - .limit(1); - - if (activeTasks.length === 0) { - // If no active tasks, only wake every 10x interval (minimum once per hour) - // as a "safety" check for system configuration changes or missed events. - const safetyIntervalMs = Math.max(3600 * 1000, policy.intervalSec * 1000 * 10); - if (elapsedMs < safetyIntervalMs) { - continue; - } - } - const run = await enqueueWakeup(agent.id, { source: "timer", triggerDetail: "system", - wakeReason: "heartbeat_timer", - reason: activeTasks.length > 0 ? "active_tasks_detected" : "periodic_safety_check", + reason: "heartbeat_timer", requestedByActorType: "system", requestedByActorId: "heartbeat_scheduler", contextSnapshot: { source: "scheduler", reason: "interval_elapsed", - activeTaskCount: activeTasks.length, now: now.toISOString(), }, }); diff --git a/server/src/services/index.ts b/server/src/services/index.ts index c279dd1..f614c14 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -5,9 +5,23 @@ export { agentService, deduplicateAgentName } from "./agents.js"; export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js"; export { assetService } from "./assets.js"; export { documentService, extractLegacyPlanBody } from "./documents.js"; +export { + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + buildContinuationSummaryMarkdown, + getIssueContinuationSummaryDocument, + refreshIssueContinuationSummary, +} from "./issue-continuation-summary.js"; export { projectService } from "./projects.js"; -export { issueService, type IssueFilters } from "./issues.js"; +export { + clampIssueListLimit, + ISSUE_LIST_DEFAULT_LIMIT, + ISSUE_LIST_MAX_LIMIT, + issueService, + type IssueFilters, +} from "./issues.js"; +export { issueThreadInteractionService } from "./issue-thread-interactions.js"; export { issueApprovalService } from "./issue-approvals.js"; +export { issueReferenceService } from "./issue-references.js"; export { goalService } from "./goals.js"; export { activityService, type ActivityFilters } from "./activity.js"; export { approvalService } from "./approvals.js"; @@ -17,6 +31,7 @@ export { routineService } from "./routines.js"; export { costService } from "./costs.js"; export { financeService } from "./finance.js"; export { heartbeatService } from "./heartbeat.js"; +export { classifyIssueGraphLiveness, type IssueLivenessFinding } from "./issue-liveness.js"; export { dashboardService } from "./dashboard.js"; export { sidebarBadgeService } from "./sidebar-badges.js"; export { sidebarPreferenceService } from "./sidebar-preferences.js"; diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 3295b65..f54c36b 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -85,7 +85,16 @@ export function instanceSettingsService(db: Db) { }) .returning(); - return created; + if (created) return created; + + const raced = await db + .select() + .from(instanceSettings) + .where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY)) + .then((rows) => rows[0] ?? null); + if (raced) return raced; + + throw new Error("Failed to initialize instance settings row"); } return { diff --git a/server/src/services/invite-grants.ts b/server/src/services/invite-grants.ts new file mode 100644 index 0000000..c3271cb --- /dev/null +++ b/server/src/services/invite-grants.ts @@ -0,0 +1,68 @@ +import { PERMISSION_KEYS } from "@taskcore/shared"; +import type { HumanCompanyMembershipRole } from "@taskcore/shared"; +import { grantsForHumanRole } from "./company-member-roles.js"; + +export function grantsFromDefaults( + defaultsPayload: Record | null | undefined, + key: "human" | "agent" +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + if (!defaultsPayload || typeof defaultsPayload !== "object") return []; + const scoped = defaultsPayload[key]; + if (!scoped || typeof scoped !== "object") return []; + const grants = (scoped as Record).grants; + if (!Array.isArray(grants)) return []; + const validPermissionKeys = new Set(PERMISSION_KEYS); + const result: Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; + }> = []; + for (const item of grants) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + if (typeof record.permissionKey !== "string") continue; + if (!validPermissionKeys.has(record.permissionKey)) continue; + result.push({ + permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number], + scope: + record.scope && + typeof record.scope === "object" && + !Array.isArray(record.scope) + ? (record.scope as Record) + : null, + }); + } + return result; +} + +export function agentJoinGrantsFromDefaults( + defaultsPayload: Record | null | undefined +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + const grants = grantsFromDefaults(defaultsPayload, "agent"); + if (grants.some((grant) => grant.permissionKey === "tasks:assign")) { + return grants; + } + return [ + ...grants, + { + permissionKey: "tasks:assign", + scope: null, + }, + ]; +} + +export function humanJoinGrantsFromDefaults( + defaultsPayload: Record | null | undefined, + membershipRole: HumanCompanyMembershipRole +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + const grants = grantsFromDefaults(defaultsPayload, "human"); + return grants.length > 0 ? grants : grantsForHumanRole(membershipRole); +} diff --git a/server/src/services/issue-continuation-summary.ts b/server/src/services/issue-continuation-summary.ts new file mode 100644 index 0000000..138a371 --- /dev/null +++ b/server/src/services/issue-continuation-summary.ts @@ -0,0 +1,269 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@taskcore/db"; +import { documents, issueDocuments, issues } from "@taskcore/db"; +import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@taskcore/shared"; +import { documentService } from "./documents.js"; + +export { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY }; +export const ISSUE_CONTINUATION_SUMMARY_TITLE = "Continuation Summary"; +export const ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS = 8_000; +const SUMMARY_SECTION_MAX_CHARS = 1_200; +const PATH_CANDIDATE_RE = /(?:^|[\s`"'(])((?:server|ui|packages|doc|scripts|\.github)\/[A-Za-z0-9._/-]+)/g; + +type IssueSummaryInput = { + id: string; + identifier: string | null; + title: string; + description: string | null; + status: string; + priority: string; +}; + +type RunSummaryInput = { + id: string; + status: string; + error: string | null; + errorCode?: string | null; + resultJson?: Record | null; + stdoutExcerpt?: string | null; + stderrExcerpt?: string | null; + finishedAt?: Date | null; +}; + +type AgentSummaryInput = { + id: string; + name: string; + adapterType: string | null; +}; + +export type IssueContinuationSummaryDocument = { + key: typeof ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY; + title: string | null; + body: string; + latestRevisionId: string | null; + latestRevisionNumber: number; + updatedAt: Date; +}; + +function truncateText(value: string, maxChars: number) { + const trimmed = value.trim(); + if (trimmed.length <= maxChars) return trimmed; + return `${trimmed.slice(0, Math.max(0, maxChars - 20)).trimEnd()}\n[truncated]`; +} + +function asNonEmptyString(value: unknown) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readResultSummary(resultJson: Record | null | undefined) { + if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) return null; + return ( + asNonEmptyString(resultJson.summary) ?? + asNonEmptyString(resultJson.result) ?? + asNonEmptyString(resultJson.message) ?? + asNonEmptyString(resultJson.error) ?? + null + ); +} + +function extractMarkdownSection(markdown: string | null | undefined, heading: string) { + if (!markdown) return null; + const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`^##\\s+${escaped}\\s*$([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, "im"); + const match = re.exec(markdown); + const section = match?.[1]?.trim(); + return section ? truncateText(section, SUMMARY_SECTION_MAX_CHARS) : null; +} + +function extractPathCandidates(...texts: Array) { + const seen = new Set(); + for (const text of texts) { + if (!text) continue; + for (const match of text.matchAll(PATH_CANDIDATE_RE)) { + const path = match[1]?.replace(/[),.;:]+$/, ""); + if (path) seen.add(path); + if (seen.size >= 12) break; + } + if (seen.size >= 12) break; + } + return [...seen]; +} + +function inferMode(issue: IssueSummaryInput, run: RunSummaryInput) { + if (issue.status === "done" || issue.status === "in_review") return "review"; + if (run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") return "implementation"; + if (issue.status === "backlog" || issue.status === "todo") return "plan"; + return "implementation"; +} + +function inferNextAction(issue: IssueSummaryInput, run: RunSummaryInput, previousNextAction: string | null) { + if (issue.status === "done") return "Review the completed issue output and close any remaining follow-up comments."; + if (issue.status === "in_review") return "Wait for reviewer feedback or approval before continuing executor work."; + if (run.status === "failed" || run.status === "timed_out") { + return "Inspect the failed run, fix the cause, and resume from the most recent concrete action above."; + } + if (run.status === "cancelled") return "Confirm the cancellation reason before starting another run."; + return previousNextAction ?? "Resume implementation from the acceptance criteria, latest comments, and this summary."; +} + +function bulletList(items: string[], empty: string) { + if (items.length === 0) return `- ${empty}`; + return items.map((item) => `- ${item}`).join("\n"); +} + +function extractPreviousNextAction(previousBody: string | null | undefined) { + const section = extractMarkdownSection(previousBody, "Next Action"); + if (!section) return null; + return section + .split(/\r?\n/) + .map((line) => line.replace(/^[-*]\s+/, "").trim()) + .find(Boolean) ?? null; +} + +export function buildContinuationSummaryMarkdown(input: { + issue: IssueSummaryInput; + run: RunSummaryInput; + agent: AgentSummaryInput; + previousSummaryBody?: string | null; +}) { + const { issue, run, agent } = input; + const resultSummary = readResultSummary(run.resultJson); + const recentActions = [ + `Run \`${run.id}\` finished with status \`${run.status}\`${run.finishedAt ? ` at ${run.finishedAt.toISOString()}` : ""}.`, + resultSummary ? truncateText(resultSummary, SUMMARY_SECTION_MAX_CHARS) : "No adapter-provided result summary was captured for this run.", + ]; + if (run.error) { + recentActions.push(`Latest run error${run.errorCode ? ` (${run.errorCode})` : ""}: ${truncateText(run.error, 500)}`); + } + + const paths = extractPathCandidates(resultSummary, run.stdoutExcerpt, run.stderrExcerpt, input.previousSummaryBody); + const objective = extractMarkdownSection(issue.description, "Objective") ?? issue.description?.trim() ?? "No objective captured."; + const acceptanceCriteria = extractMarkdownSection(issue.description, "Acceptance Criteria") ?? "No explicit acceptance criteria captured."; + const mode = inferMode(issue, run); + const nextAction = inferNextAction(issue, run, extractPreviousNextAction(input.previousSummaryBody)); + + const body = [ + "# Continuation Summary", + "", + `- Issue: ${issue.identifier ?? issue.id} — ${issue.title}`, + `- Status: ${issue.status}`, + `- Priority: ${issue.priority}`, + `- Current mode: ${mode}`, + `- Last updated by run: ${run.id}`, + `- Agent: ${agent.name} (${agent.adapterType ?? "unknown"})`, + "", + "## Objective", + "", + truncateText(objective, SUMMARY_SECTION_MAX_CHARS), + "", + "## Acceptance Criteria", + "", + acceptanceCriteria, + "", + "## Recent Concrete Actions", + "", + bulletList(recentActions, "No recent actions captured."), + "", + "## Files / Routes Touched", + "", + bulletList(paths.map((path) => `\`${path}\``), "No file or route paths were detected in the captured run summary."), + "", + "## Commands Run", + "", + bulletList( + [ + `Heartbeat run \`${run.id}\` invoked adapter \`${agent.adapterType ?? "unknown"}\`.`, + "Detailed shell/tool commands remain in the run log and transcript.", + ], + "No command metadata captured.", + ), + "", + "## Blockers / Decisions", + "", + bulletList( + run.error + ? [`Latest run ended with \`${run.status}\`; inspect the error before continuing.`] + : ["No new blocker was recorded by the latest run."], + "No blockers or decisions captured.", + ), + "", + "## Next Action", + "", + `- ${nextAction}`, + ].join("\n"); + + return truncateText(body, ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS); +} + +export async function getIssueContinuationSummaryDocument( + db: Db, + issueId: string, +): Promise { + const row = await db + .select({ + key: issueDocuments.key, + title: documents.title, + body: documents.latestBody, + latestRevisionId: documents.latestRevisionId, + latestRevisionNumber: documents.latestRevisionNumber, + updatedAt: documents.updatedAt, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY))) + .then((rows) => rows[0] ?? null); + + if (!row) return null; + return { + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + title: row.title, + body: row.body, + latestRevisionId: row.latestRevisionId, + latestRevisionNumber: row.latestRevisionNumber, + updatedAt: row.updatedAt, + }; +} + +export async function refreshIssueContinuationSummary(input: { + db: Db; + issueId: string; + run: RunSummaryInput; + agent: AgentSummaryInput; +}) { + const { db, issueId, run, agent } = input; + const [issue, existing] = await Promise.all([ + db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + description: issues.description, + status: issues.status, + priority: issues.priority, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null), + getIssueContinuationSummaryDocument(db, issueId), + ]); + + if (!issue) return null; + const body = buildContinuationSummaryMarkdown({ + issue, + run, + agent, + previousSummaryBody: existing?.body ?? null, + }); + const result = await documentService(db).upsertIssueDocument({ + issueId, + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + title: ISSUE_CONTINUATION_SUMMARY_TITLE, + format: "markdown", + body, + baseRevisionId: existing?.latestRevisionId ?? null, + changeSummary: `Refresh continuation summary after run ${run.id}`, + createdByAgentId: agent.id, + createdByRunId: run.id, + }); + return result.document; +} diff --git a/server/src/services/issue-execution-policy.ts b/server/src/services/issue-execution-policy.ts index bc539e7..0735ad3 100644 --- a/server/src/services/issue-execution-policy.ts +++ b/server/src/services/issue-execution-policy.ts @@ -530,10 +530,10 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra skippedStageIds.length === (existingState?.completedStageIds ?? []).length ? existingState : buildStateWithCompletedStages({ - previous: existingState, - completedStageIds: skippedStageIds, - returnAssignee, - }), + previous: existingState, + completedStageIds: skippedStageIds, + returnAssignee, + }), policy: input.policy, stage: pendingStage, participant, diff --git a/server/src/services/issue-liveness.ts b/server/src/services/issue-liveness.ts new file mode 100644 index 0000000..c7cc58f --- /dev/null +++ b/server/src/services/issue-liveness.ts @@ -0,0 +1,324 @@ +export type IssueLivenessSeverity = "warning" | "critical"; + +export type IssueLivenessState = + | "blocked_by_unassigned_issue" + | "blocked_by_uninvokable_assignee" + | "blocked_by_cancelled_issue" + | "invalid_review_participant"; + +export interface IssueLivenessIssueInput { + id: string; + companyId: string; + identifier: string | null; + title: string; + status: string; + projectId?: string | null; + goalId?: string | null; + parentId?: string | null; + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + createdByAgentId?: string | null; + createdByUserId?: string | null; + executionState?: Record | null; +} + +export interface IssueLivenessRelationInput { + companyId: string; + blockerIssueId: string; + blockedIssueId: string; +} + +export interface IssueLivenessAgentInput { + id: string; + companyId: string; + name: string; + role: string; + title?: string | null; + status: string; + reportsTo?: string | null; +} + +export interface IssueLivenessExecutionPathInput { + companyId: string; + issueId: string | null; + agentId?: string | null; + status: string; +} + +export interface IssueLivenessDependencyPathEntry { + issueId: string; + identifier: string | null; + title: string; + status: string; +} + +export interface IssueLivenessFinding { + issueId: string; + companyId: string; + identifier: string | null; + state: IssueLivenessState; + severity: IssueLivenessSeverity; + reason: string; + dependencyPath: IssueLivenessDependencyPathEntry[]; + recommendedOwnerAgentId: string | null; + recommendedOwnerCandidateAgentIds: string[]; + recommendedAction: string; + incidentKey: string; +} + +export interface IssueGraphLivenessInput { + issues: IssueLivenessIssueInput[]; + relations: IssueLivenessRelationInput[]; + agents: IssueLivenessAgentInput[]; + activeRuns?: IssueLivenessExecutionPathInput[]; + queuedWakeRequests?: IssueLivenessExecutionPathInput[]; +} + +const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]); +const BLOCKING_AGENT_STATUSES = new Set(["paused", "terminated", "pending_approval"]); + +function issueLabel(issue: IssueLivenessIssueInput) { + return issue.identifier ?? issue.id; +} + +function pathEntry(issue: IssueLivenessIssueInput): IssueLivenessDependencyPathEntry { + return { + issueId: issue.id, + identifier: issue.identifier, + title: issue.title, + status: issue.status, + }; +} + +function isInvokableAgent(agent: IssueLivenessAgentInput | null | undefined) { + return Boolean(agent && INVOKABLE_AGENT_STATUSES.has(agent.status)); +} + +function hasActiveExecutionPath( + companyId: string, + issueId: string, + activeRuns: IssueLivenessExecutionPathInput[], + queuedWakeRequests: IssueLivenessExecutionPathInput[], +) { + return [...activeRuns, ...queuedWakeRequests].some( + (entry) => entry.companyId === companyId && entry.issueId === issueId, + ); +} + +function readPrincipalAgentId(principal: unknown): string | null { + if (!principal || typeof principal !== "object") return null; + const value = principal as Record; + return value.type === "agent" && typeof value.agentId === "string" && value.agentId.length > 0 + ? value.agentId + : null; +} + +function principalIsResolvableUser(principal: unknown): boolean { + if (!principal || typeof principal !== "object") return false; + const value = principal as Record; + return value.type === "user" && typeof value.userId === "string" && value.userId.length > 0; +} + +function agentChainCandidates( + startAgentId: string | null | undefined, + agentsById: Map, + companyId: string, +) { + const candidates: string[] = []; + const seen = new Set(); + let current = startAgentId ? agentsById.get(startAgentId) : null; + + while (current?.reportsTo) { + if (seen.has(current.reportsTo)) break; + seen.add(current.reportsTo); + const manager = agentsById.get(current.reportsTo); + if (!manager || manager.companyId !== companyId) break; + if (isInvokableAgent(manager)) candidates.push(manager.id); + current = manager; + } + + return candidates; +} + +function fallbackExecutiveCandidates(agents: IssueLivenessAgentInput[], companyId: string) { + const active = agents.filter((agent) => agent.companyId === companyId && isInvokableAgent(agent)); + const executive = active.filter((agent) => { + const haystack = `${agent.role} ${agent.title ?? ""} ${agent.name}`.toLowerCase(); + return /\b(cto|chief technology|ceo|chief executive)\b/.test(haystack); + }); + const roots = active.filter((agent) => !agent.reportsTo); + return [...executive, ...roots, ...active].map((agent) => agent.id); +} + +function ownerCandidatesForIssue( + issue: IssueLivenessIssueInput, + agents: IssueLivenessAgentInput[], + agentsById: Map, +) { + const candidates = [ + ...agentChainCandidates(issue.assigneeAgentId, agentsById, issue.companyId), + ...agentChainCandidates(issue.createdByAgentId, agentsById, issue.companyId), + ...fallbackExecutiveCandidates(agents, issue.companyId), + ]; + return [...new Set(candidates)]; +} + +function incidentKey(input: { + companyId: string; + issueId: string; + state: IssueLivenessState; + blockerIssueId?: string | null; + participantAgentId?: string | null; +}) { + return [ + "harness_liveness", + input.companyId, + input.issueId, + input.state, + input.blockerIssueId ?? input.participantAgentId ?? "none", + ].join(":"); +} + +function finding(input: { + issue: IssueLivenessIssueInput; + state: IssueLivenessState; + severity?: IssueLivenessSeverity; + reason: string; + dependencyPath: IssueLivenessIssueInput[]; + recommendedOwnerCandidateAgentIds: string[]; + recommendedAction: string; + blockerIssueId?: string | null; + participantAgentId?: string | null; +}): IssueLivenessFinding { + return { + issueId: input.issue.id, + companyId: input.issue.companyId, + identifier: input.issue.identifier, + state: input.state, + severity: input.severity ?? "critical", + reason: input.reason, + dependencyPath: input.dependencyPath.map(pathEntry), + recommendedOwnerAgentId: input.recommendedOwnerCandidateAgentIds[0] ?? null, + recommendedOwnerCandidateAgentIds: input.recommendedOwnerCandidateAgentIds, + recommendedAction: input.recommendedAction, + incidentKey: incidentKey({ + companyId: input.issue.companyId, + issueId: input.issue.id, + state: input.state, + blockerIssueId: input.blockerIssueId, + participantAgentId: input.participantAgentId, + }), + }; +} + +export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): IssueLivenessFinding[] { + const issuesById = new Map(input.issues.map((issue) => [issue.id, issue])); + const agentsById = new Map(input.agents.map((agent) => [agent.id, agent])); + const blockersByBlockedIssueId = new Map(); + const findings: IssueLivenessFinding[] = []; + const activeRuns = input.activeRuns ?? []; + const queuedWakeRequests = input.queuedWakeRequests ?? []; + + for (const relation of input.relations) { + const list = blockersByBlockedIssueId.get(relation.blockedIssueId) ?? []; + list.push(relation); + blockersByBlockedIssueId.set(relation.blockedIssueId, list); + } + + for (const issue of input.issues) { + const ownerCandidates = ownerCandidatesForIssue(issue, input.agents, agentsById); + + if (issue.status === "blocked") { + const relations = blockersByBlockedIssueId.get(issue.id) ?? []; + for (const relation of relations) { + if (relation.companyId !== issue.companyId) continue; + const blocker = issuesById.get(relation.blockerIssueId); + if (!blocker || blocker.companyId !== issue.companyId || blocker.status === "done") continue; + + if (blocker.status === "cancelled") { + findings.push(finding({ + issue, + state: "blocked_by_cancelled_issue", + reason: `${issueLabel(issue)} is still blocked by cancelled issue ${issueLabel(blocker)}.`, + dependencyPath: [issue, blocker], + recommendedOwnerCandidateAgentIds: ownerCandidates, + recommendedAction: + `Inspect ${issueLabel(blocker)} and either remove it from ${issueLabel(issue)}'s blockers or replace it with an actionable unblock issue.`, + blockerIssueId: blocker.id, + })); + continue; + } + + if (!blocker.assigneeAgentId && !blocker.assigneeUserId) { + if (hasActiveExecutionPath(issue.companyId, blocker.id, activeRuns, queuedWakeRequests)) continue; + findings.push(finding({ + issue, + state: "blocked_by_unassigned_issue", + reason: `${issueLabel(issue)} is blocked by unassigned issue ${issueLabel(blocker)} with no user owner.`, + dependencyPath: [issue, blocker], + recommendedOwnerCandidateAgentIds: ownerCandidates, + recommendedAction: + `Assign ${issueLabel(blocker)} to an owner who can complete it, or remove it from ${issueLabel(issue)}'s blockers if it is no longer required.`, + blockerIssueId: blocker.id, + })); + continue; + } + + if (!blocker.assigneeAgentId) continue; + if (hasActiveExecutionPath(issue.companyId, blocker.id, activeRuns, queuedWakeRequests)) continue; + + const blockerAgent = agentsById.get(blocker.assigneeAgentId); + if (!blockerAgent || blockerAgent.companyId !== issue.companyId || BLOCKING_AGENT_STATUSES.has(blockerAgent.status)) { + findings.push(finding({ + issue, + state: "blocked_by_uninvokable_assignee", + reason: blockerAgent + ? `${issueLabel(issue)} is blocked by ${issueLabel(blocker)}, but its assignee is ${blockerAgent.status}.` + : `${issueLabel(issue)} is blocked by ${issueLabel(blocker)}, but its assignee no longer exists.`, + dependencyPath: [issue, blocker], + recommendedOwnerCandidateAgentIds: ownerCandidates, + recommendedAction: + `Review ${issueLabel(blocker)} and assign it to an active owner or replace the blocker with an actionable issue.`, + blockerIssueId: blocker.id, + })); + } + } + } + + if (issue.status !== "in_review" || !issue.executionState) continue; + const participant = issue.executionState.currentParticipant; + const participantAgentId = readPrincipalAgentId(participant); + if (participantAgentId) { + const participantAgent = agentsById.get(participantAgentId); + if (!isInvokableAgent(participantAgent) || participantAgent?.companyId !== issue.companyId) { + findings.push(finding({ + issue, + state: "invalid_review_participant", + reason: participantAgent + ? `${issueLabel(issue)} is in review, but current participant agent is ${participantAgent.status}.` + : `${issueLabel(issue)} is in review, but current participant agent cannot be resolved.`, + dependencyPath: [issue], + recommendedOwnerCandidateAgentIds: ownerCandidates, + recommendedAction: + `Repair ${issueLabel(issue)}'s review participant or return the issue to an active assignee with a clear change request.`, + participantAgentId, + })); + } + continue; + } + + if (!principalIsResolvableUser(participant)) { + findings.push(finding({ + issue, + state: "invalid_review_participant", + reason: `${issueLabel(issue)} is in review, but its current participant cannot be resolved.`, + dependencyPath: [issue], + recommendedOwnerCandidateAgentIds: ownerCandidates, + recommendedAction: + `Repair ${issueLabel(issue)}'s review participant or return the issue to an active assignee with a clear change request.`, + })); + } + } + + return findings; +} diff --git a/server/src/services/issue-references.ts b/server/src/services/issue-references.ts new file mode 100644 index 0000000..961398f --- /dev/null +++ b/server/src/services/issue-references.ts @@ -0,0 +1,407 @@ +import { and, asc, eq, inArray, isNull } from "drizzle-orm"; +import type { Db } from "@taskcore/db"; +import { documents, issueComments, issueDocuments, issueReferenceMentions, issues } from "@taskcore/db"; +import type { + IssueReferenceSource, + IssueReferenceSourceKind, + IssueRelatedWorkItem, + IssueRelatedWorkSummary, + IssueRelationIssueSummary, +} from "@taskcore/shared"; +import { extractIssueReferenceMatches } from "@taskcore/shared"; +import { notFound } from "../errors.js"; + +const SOURCE_KIND_ORDER: Record = { + title: 0, + description: 1, + document: 2, + comment: 3, +}; + +function sourceLabel(kind: IssueReferenceSourceKind, documentKey: string | null): string { + if (kind === "document") return documentKey?.trim() || "document"; + return kind; +} + +function sourceWhere( + input: { + companyId?: string; + sourceIssueId?: string; + sourceKind: IssueReferenceSourceKind; + sourceRecordId?: string | null; + }, +) { + const conditions = [eq(issueReferenceMentions.sourceKind, input.sourceKind)]; + if (input.companyId) conditions.push(eq(issueReferenceMentions.companyId, input.companyId)); + if (input.sourceIssueId) conditions.push(eq(issueReferenceMentions.sourceIssueId, input.sourceIssueId)); + if (input.sourceRecordId) { + conditions.push(eq(issueReferenceMentions.sourceRecordId, input.sourceRecordId)); + } else { + conditions.push(isNull(issueReferenceMentions.sourceRecordId)); + } + return and(...conditions); +} + +function toIssueSummary(row: { + relatedIssueId: string; + relatedIssueIdentifier: string | null; + relatedIssueTitle: string; + relatedIssueStatus: IssueRelationIssueSummary["status"]; + relatedIssuePriority: IssueRelationIssueSummary["priority"]; + relatedIssueAssigneeAgentId: string | null; + relatedIssueAssigneeUserId: string | null; +}): IssueRelationIssueSummary { + return { + id: row.relatedIssueId, + identifier: row.relatedIssueIdentifier, + title: row.relatedIssueTitle, + status: row.relatedIssueStatus, + priority: row.relatedIssuePriority, + assigneeAgentId: row.relatedIssueAssigneeAgentId, + assigneeUserId: row.relatedIssueAssigneeUserId, + }; +} + +function sortSources(a: IssueReferenceSource, b: IssueReferenceSource) { + const orderDelta = SOURCE_KIND_ORDER[a.kind] - SOURCE_KIND_ORDER[b.kind]; + if (orderDelta !== 0) return orderDelta; + const labelDelta = a.label.localeCompare(b.label); + if (labelDelta !== 0) return labelDelta; + return (a.sourceRecordId ?? "").localeCompare(b.sourceRecordId ?? ""); +} + +function sortRelatedWork(a: IssueRelatedWorkItem, b: IssueRelatedWorkItem) { + if (b.mentionCount !== a.mentionCount) return b.mentionCount - a.mentionCount; + const leftLabel = a.issue.identifier ?? a.issue.title; + const rightLabel = b.issue.identifier ?? b.issue.title; + return leftLabel.localeCompare(rightLabel); +} + +function emptySummary(): IssueRelatedWorkSummary { + return { + outbound: [], + inbound: [], + }; +} + +function diffIssueSummaries( + before: IssueRelatedWorkSummary, + after: IssueRelatedWorkSummary, +): { + addedReferencedIssues: IssueRelationIssueSummary[]; + removedReferencedIssues: IssueRelationIssueSummary[]; + currentReferencedIssues: IssueRelationIssueSummary[]; +} { + const beforeById = new Map(before.outbound.map((item) => [item.issue.id, item.issue])); + const afterById = new Map(after.outbound.map((item) => [item.issue.id, item.issue])); + + return { + addedReferencedIssues: after.outbound + .map((item) => item.issue) + .filter((issue) => !beforeById.has(issue.id)), + removedReferencedIssues: before.outbound + .map((item) => item.issue) + .filter((issue) => !afterById.has(issue.id)), + currentReferencedIssues: after.outbound.map((item) => item.issue), + }; +} + +export function issueReferenceService(db: Db) { + async function replaceSourceMentions( + input: { + companyId: string; + sourceIssueId: string; + sourceKind: IssueReferenceSourceKind; + sourceRecordId: string | null; + documentKey: string | null; + text: string | null | undefined; + }, + dbOrTx: any = db, + ) { + const matches = extractIssueReferenceMatches(input.text ?? ""); + const identifiers = matches.map((match) => match.identifier); + type ResolvedTargetRow = { + id: string; + identifier: string | null; + }; + + const resolvedTargets: ResolvedTargetRow[] = identifiers.length > 0 + ? await dbOrTx + .select({ + id: issues.id, + identifier: issues.identifier, + }) + .from(issues) + .where(and(eq(issues.companyId, input.companyId), inArray(issues.identifier, identifiers))) + : []; + const targetByIdentifier = new Map( + resolvedTargets + .filter((row): row is ResolvedTargetRow & { identifier: string } => typeof row.identifier === "string") + .map((row) => [row.identifier, row.id]), + ); + + await dbOrTx.delete(issueReferenceMentions).where(sourceWhere(input)); + + if (matches.length === 0) return; + + const seenTargetIds = new Set(); + const values = matches.flatMap((match) => { + const targetIssueId = targetByIdentifier.get(match.identifier); + if (!targetIssueId || targetIssueId === input.sourceIssueId || seenTargetIds.has(targetIssueId)) { + return []; + } + seenTargetIds.add(targetIssueId); + return [{ + companyId: input.companyId, + sourceIssueId: input.sourceIssueId, + targetIssueId, + sourceKind: input.sourceKind, + sourceRecordId: input.sourceRecordId, + documentKey: input.documentKey, + matchedText: match.matchedText, + }]; + }); + + if (values.length > 0) { + await dbOrTx.insert(issueReferenceMentions).values(values); + } + } + + async function issueById(issueId: string, dbOrTx: any = db) { + return dbOrTx + .select({ + id: issues.id, + companyId: issues.companyId, + title: issues.title, + description: issues.description, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows: Array<{ id: string; companyId: string; title: string; description: string | null }>) => rows[0] ?? null); + } + + async function syncIssue(issueId: string, dbOrTx: any = db) { + const runSync = async (tx: any) => { + const issue = await issueById(issueId, tx); + if (!issue) throw notFound("Issue not found"); + + await replaceSourceMentions({ + companyId: issue.companyId, + sourceIssueId: issue.id, + sourceKind: "title", + sourceRecordId: null, + documentKey: null, + text: issue.title, + }, tx); + + await replaceSourceMentions({ + companyId: issue.companyId, + sourceIssueId: issue.id, + sourceKind: "description", + sourceRecordId: null, + documentKey: null, + text: issue.description, + }, tx); + }; + + return dbOrTx === db ? db.transaction(runSync) : runSync(dbOrTx); + } + + async function syncComment(commentId: string, dbOrTx: any = db) { + const comment = await dbOrTx + .select({ + id: issueComments.id, + companyId: issueComments.companyId, + issueId: issueComments.issueId, + body: issueComments.body, + }) + .from(issueComments) + .where(eq(issueComments.id, commentId)) + .then((rows: Array<{ id: string; companyId: string; issueId: string; body: string }>) => rows[0] ?? null); + if (!comment) throw notFound("Issue comment not found"); + + await replaceSourceMentions({ + companyId: comment.companyId, + sourceIssueId: comment.issueId, + sourceKind: "comment", + sourceRecordId: comment.id, + documentKey: null, + text: comment.body, + }, dbOrTx); + } + + async function syncDocument(documentId: string, dbOrTx: any = db) { + const document = await dbOrTx + .select({ + documentId: documents.id, + companyId: documents.companyId, + issueId: issueDocuments.issueId, + key: issueDocuments.key, + body: documents.latestBody, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(eq(documents.id, documentId)) + .then((rows: Array<{ documentId: string; companyId: string; issueId: string; key: string; body: string }>) => rows[0] ?? null); + + if (!document) { + await dbOrTx + .delete(issueReferenceMentions) + .where(and(eq(issueReferenceMentions.sourceKind, "document"), eq(issueReferenceMentions.sourceRecordId, documentId))); + return; + } + + await replaceSourceMentions({ + companyId: document.companyId, + sourceIssueId: document.issueId, + sourceKind: "document", + sourceRecordId: document.documentId, + documentKey: document.key, + text: document.body, + }, dbOrTx); + } + + async function deleteDocumentSource(documentId: string, dbOrTx: any = db) { + await dbOrTx + .delete(issueReferenceMentions) + .where(and(eq(issueReferenceMentions.sourceKind, "document"), eq(issueReferenceMentions.sourceRecordId, documentId))); + } + + async function syncAllForIssue(issueId: string, dbOrTx: any = db) { + const issue = await issueById(issueId, dbOrTx); + if (!issue) throw notFound("Issue not found"); + + await syncIssue(issueId, dbOrTx); + + const [comments, docs] = await Promise.all([ + dbOrTx + .select({ id: issueComments.id }) + .from(issueComments) + .where(eq(issueComments.issueId, issueId)), + dbOrTx + .select({ id: documents.id }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(eq(issueDocuments.issueId, issueId)), + ]); + + for (const comment of comments) { + await syncComment(comment.id, dbOrTx); + } + for (const doc of docs) { + await syncDocument(doc.id, dbOrTx); + } + } + + async function syncAllForCompany(companyId: string, dbOrTx: any = db) { + const issueRows = await dbOrTx + .select({ id: issues.id }) + .from(issues) + .where(eq(issues.companyId, companyId)) + .orderBy(asc(issues.createdAt), asc(issues.id)); + + for (const issue of issueRows) { + await syncAllForIssue(issue.id, dbOrTx); + } + } + + async function listIssueReferenceSummary(issueId: string, dbOrTx: any = db): Promise { + const issue = await issueById(issueId, dbOrTx); + if (!issue) throw notFound("Issue not found"); + + const [outboundRows, inboundRows] = await Promise.all([ + dbOrTx + .select({ + relatedIssueId: issues.id, + relatedIssueIdentifier: issues.identifier, + relatedIssueTitle: issues.title, + relatedIssueStatus: issues.status, + relatedIssuePriority: issues.priority, + relatedIssueAssigneeAgentId: issues.assigneeAgentId, + relatedIssueAssigneeUserId: issues.assigneeUserId, + sourceKind: issueReferenceMentions.sourceKind, + sourceRecordId: issueReferenceMentions.sourceRecordId, + documentKey: issueReferenceMentions.documentKey, + matchedText: issueReferenceMentions.matchedText, + }) + .from(issueReferenceMentions) + .innerJoin(issues, eq(issueReferenceMentions.targetIssueId, issues.id)) + .where(and( + eq(issueReferenceMentions.companyId, issue.companyId), + eq(issueReferenceMentions.sourceIssueId, issueId), + )), + dbOrTx + .select({ + relatedIssueId: issues.id, + relatedIssueIdentifier: issues.identifier, + relatedIssueTitle: issues.title, + relatedIssueStatus: issues.status, + relatedIssuePriority: issues.priority, + relatedIssueAssigneeAgentId: issues.assigneeAgentId, + relatedIssueAssigneeUserId: issues.assigneeUserId, + sourceKind: issueReferenceMentions.sourceKind, + sourceRecordId: issueReferenceMentions.sourceRecordId, + documentKey: issueReferenceMentions.documentKey, + matchedText: issueReferenceMentions.matchedText, + }) + .from(issueReferenceMentions) + .innerJoin(issues, eq(issueReferenceMentions.sourceIssueId, issues.id)) + .where(and( + eq(issueReferenceMentions.companyId, issue.companyId), + eq(issueReferenceMentions.targetIssueId, issueId), + )), + ]); + + const mapRows = (rows: Array<{ + relatedIssueId: string; + relatedIssueIdentifier: string | null; + relatedIssueTitle: string; + relatedIssueStatus: IssueRelationIssueSummary["status"]; + relatedIssuePriority: IssueRelationIssueSummary["priority"]; + relatedIssueAssigneeAgentId: string | null; + relatedIssueAssigneeUserId: string | null; + sourceKind: IssueReferenceSourceKind; + sourceRecordId: string | null; + documentKey: string | null; + matchedText: string | null; + }>) => { + const grouped = new Map(); + for (const row of rows) { + const existing = grouped.get(row.relatedIssueId) ?? { + issue: toIssueSummary(row), + mentionCount: 0, + sources: [], + }; + existing.mentionCount += 1; + existing.sources.push({ + kind: row.sourceKind, + sourceRecordId: row.sourceRecordId, + label: sourceLabel(row.sourceKind, row.documentKey), + matchedText: row.matchedText, + }); + grouped.set(row.relatedIssueId, existing); + } + + return [...grouped.values()] + .map((item) => ({ ...item, sources: [...item.sources].sort(sortSources) })) + .sort(sortRelatedWork); + }; + + return { + outbound: mapRows(outboundRows), + inbound: mapRows(inboundRows), + }; + } + + return { + syncIssue, + syncComment, + syncDocument, + deleteDocumentSource, + syncAllForIssue, + syncAllForCompany, + listIssueReferenceSummary, + diffIssueReferenceSummary: diffIssueSummaries, + emptySummary, + }; +} diff --git a/server/src/services/issue-thread-interactions.test.ts b/server/src/services/issue-thread-interactions.test.ts new file mode 100644 index 0000000..d2cd420 --- /dev/null +++ b/server/src/services/issue-thread-interactions.test.ts @@ -0,0 +1,215 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockCreateChild = vi.fn(); + +vi.mock("./issues.js", () => ({ + issueService: () => ({ + createChild: mockCreateChild, + }), +})); + +type SelectRow = Record; + +function createSelectChain(rows: SelectRow[]) { + return { + from() { + return { + where() { + return { + then(callback: (rows: SelectRow[]) => unknown) { + return Promise.resolve(callback(rows)); + }, + }; + }, + }; + }, + }; +} + +function createFakeDb(args: { + interactionRow: Record; + parentRows?: SelectRow[]; +}) { + let interactionRow = { ...args.interactionRow }; + const issueTouches: Array> = []; + const interactionUpdates: Array> = []; + let selectCallCount = 0; + + const db: any = { + select: vi.fn(() => { + selectCallCount += 1; + return createSelectChain(selectCallCount === 1 ? [interactionRow] : (args.parentRows ?? [])); + }), + update: vi.fn((table: unknown) => ({ + set(values: Record) { + return { + where() { + if ("status" in values || "result" in values || "resolvedAt" in values) { + interactionUpdates.push(values); + interactionRow = { ...interactionRow, ...values }; + return { + returning: async () => [interactionRow], + }; + } + if ("updatedAt" in values) { + issueTouches.push(values); + return Promise.resolve(undefined); + } + throw new Error(`Unexpected update target: ${String(table)}`); + }, + }; + }, + })), + insert: vi.fn(), + transaction: async (callback: (tx: typeof db) => Promise) => callback(db), + }; + + return { + db, + getInteractionRow: () => interactionRow, + issueTouches, + interactionUpdates, + }; +} + +describe("issueThreadInteractionService", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("create reuses an existing interaction for the same idempotency key", async () => { + const { issueThreadInteractionService } = await import("./issue-thread-interactions.js"); + + const existingRow = { + id: "interaction-1", + companyId: "company-1", + issueId: "11111111-1111-4111-8111-111111111111", + kind: "suggest_tasks", + status: "pending", + continuationPolicy: "wake_assignee", + idempotencyKey: "run-1:suggest", + sourceCommentId: null, + sourceRunId: "22222222-2222-4222-8222-222222222222", + title: "Break the work down", + summary: "Created from the current agent run.", + createdByAgentId: "agent-1", + createdByUserId: null, + resolvedByAgentId: null, + resolvedByUserId: null, + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + result: null, + resolvedAt: null, + createdAt: new Date("2026-04-20T10:00:00.000Z"), + updatedAt: new Date("2026-04-20T10:00:00.000Z"), + }; + + const db: any = { + select: vi.fn(() => createSelectChain([existingRow])), + insert: vi.fn(), + update: vi.fn(), + }; + + const svc = issueThreadInteractionService(db as never); + const created = await svc.create({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + }, { + kind: "suggest_tasks", + idempotencyKey: "run-1:suggest", + sourceRunId: "22222222-2222-4222-8222-222222222222", + title: "Break the work down", + summary: "Created from the current agent run.", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + tasks: [{ clientKey: "task-1", title: "One" }], + }, + }, { + agentId: "agent-1", + }); + + expect(created.id).toBe("interaction-1"); + expect(created.idempotencyKey).toBe("run-1:suggest"); + expect(db.insert).not.toHaveBeenCalled(); + }); + + it("answerQuestions normalizes duplicate option ids and persists answered results", async () => { + const { issueThreadInteractionService } = await import("./issue-thread-interactions.js"); + + const interactionRow = { + id: "interaction-2", + companyId: "company-1", + issueId: "11111111-1111-4111-8111-111111111111", + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + sourceCommentId: null, + sourceRunId: null, + title: null, + summary: null, + createdByAgentId: null, + createdByUserId: "local-board", + resolvedByAgentId: null, + resolvedByUserId: null, + payload: { + version: 1, + questions: [ + { + id: "scope", + prompt: "Pick one scope", + selectionMode: "single", + required: true, + options: [ + { id: "phase-1", label: "Phase 1" }, + { id: "phase-2", label: "Phase 2" }, + ], + }, + { + id: "extras", + prompt: "Pick extras", + selectionMode: "multi", + options: [ + { id: "tests", label: "Tests" }, + { id: "docs", label: "Docs" }, + ], + }, + ], + }, + result: null, + resolvedAt: null, + createdAt: new Date("2026-04-20T10:00:00.000Z"), + updatedAt: new Date("2026-04-20T10:00:00.000Z"), + }; + const state = createFakeDb({ interactionRow }); + const svc = issueThreadInteractionService(state.db as never); + + const result = await svc.answerQuestions({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + }, "interaction-2", { + answers: [ + { questionId: "scope", optionIds: ["phase-1"] }, + { questionId: "extras", optionIds: ["docs", "tests", "docs"] }, + ], + summaryMarkdown: "Phase 1 with tests and docs.", + }, { + userId: "local-board", + }); + + expect(result.status).toBe("answered"); + expect(result.result).toEqual({ + version: 1, + answers: [ + { questionId: "scope", optionIds: ["phase-1"] }, + { questionId: "extras", optionIds: ["docs", "tests"] }, + ], + summaryMarkdown: "Phase 1 with tests and docs.", + }); + expect(state.interactionUpdates).toHaveLength(1); + expect(state.issueTouches).toHaveLength(1); + }); +}); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts new file mode 100644 index 0000000..b65bb56 --- /dev/null +++ b/server/src/services/issue-thread-interactions.ts @@ -0,0 +1,1152 @@ +import { isDeepStrictEqual } from "node:util"; +import { and, asc, eq, inArray } from "drizzle-orm"; +import type { Db } from "@taskcore/db"; +import { + documents, + heartbeatRuns, + issueComments, + issueDocuments, + issueThreadInteractions, + issues, +} from "@taskcore/db"; +import type { + AcceptIssueThreadInteraction, + AskUserQuestionsAnswer, + AskUserQuestionsInteraction, + CreateIssueThreadInteraction, + IssueThreadInteraction, + RequestConfirmationInteraction, + RequestConfirmationTarget, + RejectIssueThreadInteraction, + RespondIssueThreadInteraction, + SuggestTasksInteraction, + SuggestTasksResultCreatedTask, +} from "@taskcore/shared"; +import { + acceptIssueThreadInteractionSchema, + askUserQuestionsPayloadSchema, + askUserQuestionsResultSchema, + createIssueThreadInteractionSchema, + rejectIssueThreadInteractionSchema, + requestConfirmationPayloadSchema, + requestConfirmationResultSchema, + suggestTasksPayloadSchema, + suggestTasksResultSchema, +} from "@taskcore/shared"; +import { conflict, notFound, unprocessable } from "../errors.js"; +import { issueService } from "./issues.js"; + +type InteractionActor = { + agentId?: string | null; + userId?: string | null; +}; + +const ISSUE_THREAD_INTERACTION_IDEMPOTENCY_CONSTRAINT = + "issue_thread_interactions_company_issue_idempotency_uq"; + +type IssueWakeTarget = { + id: string; + assigneeAgentId: string | null; + assigneeUserId?: string | null; + status: string; +}; + +type ResolvedInteractionResult = { + interaction: IssueThreadInteraction; + createdIssues: IssueWakeTarget[]; + continuationIssue?: IssueWakeTarget | null; +}; + +type IssueThreadInteractionRow = typeof issueThreadInteractions.$inferSelect; +type IssueTouchDb = Pick; + +type IssueResolutionContext = { + id: string; + companyId: string; + status: string; + assigneeAgentId: string | null; + assigneeUserId: string | null; +}; + +function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const err = error as { code?: string; constraint?: string; constraint_name?: string }; + const constraint = err.constraint ?? err.constraint_name; + return err.code === "23505" && constraint === ISSUE_THREAD_INTERACTION_IDEMPOTENCY_CONSTRAINT; +} + +function isEquivalentCreateRequest( + row: IssueThreadInteractionRow, + input: CreateIssueThreadInteraction, + actor: InteractionActor, +) { + return ( + row.kind === input.kind + && row.continuationPolicy === input.continuationPolicy + && (row.idempotencyKey ?? null) === (input.idempotencyKey ?? null) + && (row.sourceCommentId ?? null) === (input.sourceCommentId ?? null) + && (row.sourceRunId ?? null) === (input.sourceRunId ?? null) + && (row.title ?? null) === (input.title ?? null) + && (row.summary ?? null) === (input.summary ?? null) + && (row.createdByAgentId ?? null) === (actor.agentId ?? null) + && (row.createdByUserId ?? null) === (actor.userId ?? null) + && isDeepStrictEqual(row.payload, input.payload) + ); +} + +function hydrateInteraction( + row: IssueThreadInteractionRow, +): IssueThreadInteraction { + const base = { + ...row, + idempotencyKey: row.idempotencyKey ?? null, + status: row.status as IssueThreadInteraction["status"], + continuationPolicy: row.continuationPolicy as IssueThreadInteraction["continuationPolicy"], + }; + + switch (row.kind) { + case "suggest_tasks": + return { + ...base, + kind: "suggest_tasks", + payload: suggestTasksPayloadSchema.parse(row.payload), + result: row.result ? suggestTasksResultSchema.parse(row.result) : null, + } satisfies SuggestTasksInteraction; + case "ask_user_questions": + return { + ...base, + kind: "ask_user_questions", + payload: askUserQuestionsPayloadSchema.parse(row.payload), + result: row.result ? askUserQuestionsResultSchema.parse(row.result) : null, + } satisfies AskUserQuestionsInteraction; + case "request_confirmation": + return { + ...base, + kind: "request_confirmation", + payload: requestConfirmationPayloadSchema.parse(row.payload), + result: row.result ? requestConfirmationResultSchema.parse(row.result) : null, + } satisfies RequestConfirmationInteraction; + default: + throw unprocessable(`Unknown interaction kind: ${row.kind}`); + } +} + +async function touchIssue(db: IssueTouchDb, issueId: string) { + await db + .update(issues) + .set({ updatedAt: new Date() }) + .where(eq(issues.id, issueId)); +} + +function isTerminalIssueStatus(status: string) { + return status === "done" || status === "cancelled"; +} + +function shouldReturnAcceptedConfirmationToCreatorAgent(args: { + issue: IssueResolutionContext; + current: IssueThreadInteractionRow; + actor: InteractionActor; +}) { + if (args.current.kind !== "request_confirmation") return false; + if (!args.current.createdByAgentId) return false; + if (!args.actor.userId) return false; + if (!args.issue.assigneeUserId) return false; + if (args.issue.assigneeAgentId) return false; + if (isTerminalIssueStatus(args.issue.status)) return false; + return true; +} + +function buildTaskCreationOrder(tasks: ReadonlyArray) { + const taskByClientKey = new Map(tasks.map((task) => [task.clientKey, task] as const)); + const ordered: Array = []; + const state = new Map(); + + const visit = (clientKey: string) => { + const currentState = state.get(clientKey); + if (currentState === "done") return; + if (currentState === "visiting") { + throw unprocessable("Suggested tasks contain a parentClientKey cycle"); + } + + const task = taskByClientKey.get(clientKey); + if (!task) { + throw unprocessable(`Unknown parentClientKey: ${clientKey}`); + } + + state.set(clientKey, "visiting"); + if (task.parentClientKey) { + visit(task.parentClientKey); + } + state.set(clientKey, "done"); + ordered.push(task); + }; + + for (const task of tasks) { + visit(task.clientKey); + } + + return ordered; +} + +function resolveSelectedSuggestedTasks(args: { + interaction: SuggestTasksInteraction; + selectedClientKeys?: AcceptIssueThreadInteraction["selectedClientKeys"]; +}) { + const taskByClientKey = new Map( + args.interaction.payload.tasks.map((task) => [task.clientKey, task] as const), + ); + const selectedClientKeys = args.selectedClientKeys ?? args.interaction.payload.tasks.map((task) => task.clientKey); + const selectedClientKeySet = new Set(); + + for (const clientKey of selectedClientKeys) { + const task = taskByClientKey.get(clientKey); + if (!task) { + throw unprocessable(`Unknown suggested task clientKey: ${clientKey}`); + } + selectedClientKeySet.add(clientKey); + } + + if (selectedClientKeySet.size === 0) { + throw unprocessable("Select at least one suggested task to accept"); + } + + for (const clientKey of selectedClientKeySet) { + let parentClientKey = taskByClientKey.get(clientKey)?.parentClientKey ?? null; + while (parentClientKey) { + if (!selectedClientKeySet.has(parentClientKey)) { + throw unprocessable(`Suggested task ${clientKey} requires its parent ${parentClientKey} to also be selected`); + } + parentClientKey = taskByClientKey.get(parentClientKey)?.parentClientKey ?? null; + } + } + + return { + selectedTasks: args.interaction.payload.tasks.filter((task) => selectedClientKeySet.has(task.clientKey)), + skippedClientKeys: args.interaction.payload.tasks + .filter((task) => !selectedClientKeySet.has(task.clientKey)) + .map((task) => task.clientKey), + }; +} + +function normalizeQuestionAnswers(args: { + questions: AskUserQuestionsInteraction["payload"]["questions"]; + answers: RespondIssueThreadInteraction["answers"]; +}) { + const questionById = new Map(args.questions.map((question) => [question.id, question] as const)); + const answerByQuestionId = new Map(); + + for (const answer of args.answers) { + const question = questionById.get(answer.questionId); + if (!question) { + throw unprocessable(`Unknown questionId: ${answer.questionId}`); + } + if (answerByQuestionId.has(answer.questionId)) { + throw unprocessable(`Duplicate answer for questionId: ${answer.questionId}`); + } + + const uniqueOptionIds = [...new Set(answer.optionIds)]; + const validOptionIds = new Set(question.options.map((option) => option.id)); + for (const optionId of uniqueOptionIds) { + if (!validOptionIds.has(optionId)) { + throw unprocessable(`Unknown optionId for question ${answer.questionId}: ${optionId}`); + } + } + + if (question.selectionMode === "single" && uniqueOptionIds.length > 1) { + throw unprocessable(`Question ${answer.questionId} only allows one answer`); + } + + answerByQuestionId.set(answer.questionId, { + questionId: answer.questionId, + optionIds: uniqueOptionIds, + }); + } + + for (const question of args.questions) { + const answer = answerByQuestionId.get(question.id); + if (question.required && (!answer || answer.optionIds.length === 0)) { + throw unprocessable(`Question ${question.id} requires an answer`); + } + } + + return args.questions + .map((question) => answerByQuestionId.get(question.id)) + .filter((answer): answer is AskUserQuestionsAnswer => Boolean(answer)); +} + +async function getIssueDocumentTargetSnapshot(db: Db | any, args: { + companyId: string; + issueId: string; + target: RequestConfirmationTarget; +}) { + if (args.target.type !== "issue_document") return null; + const targetIssueId = args.target.issueId ?? args.issueId; + const row = await db + .select({ + issueId: issueDocuments.issueId, + documentId: issueDocuments.documentId, + key: issueDocuments.key, + latestRevisionId: documents.latestRevisionId, + latestRevisionNumber: documents.latestRevisionNumber, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and( + eq(issueDocuments.companyId, args.companyId), + eq(issueDocuments.issueId, targetIssueId), + eq(issueDocuments.key, args.target.key), + )) + .then((rows: Array<{ + issueId: string; + documentId: string; + key: string; + latestRevisionId: string | null; + latestRevisionNumber: number; + }>) => rows[0] ?? null); + + if (!row) return null; + if (args.target.documentId && args.target.documentId !== row.documentId) return null; + return row; +} + +function buildIssueDocumentTargetFromSnapshot(args: { + issueId: string; + snapshot: { + issueId: string; + documentId: string; + key: string; + latestRevisionId: string | null; + latestRevisionNumber: number; + } | null; +}): RequestConfirmationTarget | null { + if (!args.snapshot?.latestRevisionId) return null; + return { + type: "issue_document", + issueId: args.snapshot.issueId ?? args.issueId, + documentId: args.snapshot.documentId, + key: args.snapshot.key, + revisionId: args.snapshot.latestRevisionId, + revisionNumber: args.snapshot.latestRevisionNumber, + }; +} + +function buildIssueDocumentTargetFromDocument(args: { + issueId: string; + document: { id: string; key: string; latestRevisionId?: string | null; latestRevisionNumber?: number | null } | null; +}): RequestConfirmationTarget | null { + if (!args.document?.latestRevisionId) return null; + return { + type: "issue_document", + issueId: args.issueId, + documentId: args.document.id, + key: args.document.key, + revisionId: args.document.latestRevisionId, + revisionNumber: args.document.latestRevisionNumber ?? null, + }; +} + +async function assertRequestConfirmationTargetIsCurrent(db: Db | any, args: { + companyId: string; + issueId: string; + target?: RequestConfirmationTarget | null; +}) { + if (!args.target) return; + if (args.target.type !== "issue_document") return; + const snapshot = await getIssueDocumentTargetSnapshot(db, { + companyId: args.companyId, + issueId: args.issueId, + target: args.target, + }); + if (!snapshot || snapshot.latestRevisionId !== args.target.revisionId) { + throw unprocessable("request_confirmation target must reference the current issue document revision"); + } + if (args.target.revisionNumber && snapshot.latestRevisionNumber !== args.target.revisionNumber) { + throw unprocessable("request_confirmation target revisionNumber must match the current issue document revision"); + } +} + +async function expireStaleRequestConfirmationTarget(db: Db | any, args: { + row: IssueThreadInteractionRow; + actor: InteractionActor; +}): Promise { + if (args.row.kind !== "request_confirmation" || args.row.status !== "pending") return null; + const interaction = hydrateInteraction(args.row) as RequestConfirmationInteraction; + const target = interaction.payload.target ?? null; + if (!target) return null; + if (target.type !== "issue_document") return null; + + const snapshot = await getIssueDocumentTargetSnapshot(db, { + companyId: args.row.companyId, + issueId: args.row.issueId, + target, + }); + const isCurrent = + snapshot + && snapshot.latestRevisionId === target.revisionId + && (!target.revisionNumber || snapshot.latestRevisionNumber === target.revisionNumber); + if (isCurrent) return null; + + const now = new Date(); + const currentTarget = buildIssueDocumentTargetFromSnapshot({ + issueId: args.row.issueId, + snapshot, + }); + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + payload: currentTarget + ? { + ...interaction.payload, + target: currentTarget, + } + : interaction.payload, + result: { + version: 1, + outcome: "stale_target", + staleTarget: target, + }, + resolvedByAgentId: args.actor.agentId ?? null, + resolvedByUserId: args.actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, args.row.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + await touchIssue(db, args.row.issueId); + return hydrateInteraction(updated); +} + +export function issueThreadInteractionService(db: Db) { + async function getIdempotentInteraction(args: { + issueId: string; + companyId: string; + idempotencyKey: string; + }) { + return db + .select() + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, args.companyId), + eq(issueThreadInteractions.issueId, args.issueId), + eq(issueThreadInteractions.idempotencyKey, args.idempotencyKey), + )) + .then((rows) => rows[0] ?? null); + } + + async function getPendingInteractionForResolution(args: { + issue: { id: string; companyId: string }; + interactionId: string; + }) { + const current = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, args.interactionId)) + .then((rows) => rows[0] ?? null); + + if (!current) throw notFound("Interaction not found"); + if (current.companyId !== args.issue.companyId || current.issueId !== args.issue.id) { + throw notFound("Interaction not found"); + } + if (current.status !== "pending") { + throw conflict("Interaction has already been resolved"); + } + return current; + } + + async function acceptRequestConfirmation(args: { + issue: { id: string; companyId: string }; + current: IssueThreadInteractionRow; + actor: InteractionActor; + }): Promise<{ + interaction: IssueThreadInteraction; + continuationIssue: IssueWakeTarget | null; + }> { + const expired = await expireStaleRequestConfirmationTarget(db, { + row: args.current, + actor: args.actor, + }); + if (expired) { + return { interaction: expired, continuationIssue: null }; + } + + const now = new Date(); + return db.transaction(async (tx) => { + const [updated] = await tx + .update(issueThreadInteractions) + .set({ + status: "accepted", + result: { + version: 1, + outcome: "accepted", + }, + resolvedByAgentId: args.actor.agentId ?? null, + resolvedByUserId: args.actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, args.current.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + + const issueContext = await tx + .select({ + id: issues.id, + companyId: issues.companyId, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + }) + .from(issues) + .where(eq(issues.id, args.issue.id)) + .then((rows: IssueResolutionContext[]) => rows[0] ?? null); + + if (!issueContext || issueContext.companyId !== args.issue.companyId) { + throw notFound("Issue not found"); + } + + let continuationIssue: IssueWakeTarget | null = null; + if (shouldReturnAcceptedConfirmationToCreatorAgent({ + issue: issueContext, + current: args.current, + actor: args.actor, + })) { + const returnStatus = issueContext.status === "blocked" ? "blocked" : "todo"; + const returnedIssue = await issueService(db).update(args.issue.id, { + status: returnStatus, + assigneeAgentId: args.current.createdByAgentId, + assigneeUserId: null, + actorAgentId: args.actor.agentId ?? null, + actorUserId: args.actor.userId ?? null, + }, tx); + + if (returnedIssue) { + continuationIssue = { + id: returnedIssue.id, + assigneeAgentId: returnedIssue.assigneeAgentId ?? null, + assigneeUserId: returnedIssue.assigneeUserId ?? null, + status: returnedIssue.status, + }; + } + } else { + await touchIssue(tx, args.issue.id); + } + + return { + interaction: hydrateInteraction(updated), + continuationIssue, + }; + }); + } + + async function rejectRequestConfirmation(args: { + issue: { id: string; companyId: string }; + current: IssueThreadInteractionRow; + input: RejectIssueThreadInteraction; + actor: InteractionActor; + }): Promise { + const expired = await expireStaleRequestConfirmationTarget(db, { + row: args.current, + actor: args.actor, + }); + if (expired) { + return expired; + } + + const interaction = hydrateInteraction(args.current) as RequestConfirmationInteraction; + const reason = args.input.reason?.trim() ?? ""; + if (interaction.payload.rejectRequiresReason === true && reason.length === 0) { + throw unprocessable("A decline reason is required for this confirmation"); + } + + const now = new Date(); + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "rejected", + result: { + version: 1, + outcome: "rejected", + reason: reason || null, + }, + resolvedByAgentId: args.actor.agentId ?? null, + resolvedByUserId: args.actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, args.current.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + await touchIssue(db, args.issue.id); + return hydrateInteraction(updated); + } + + return { + listForIssue: async (issueId: string) => { + const rows = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.issueId, issueId)) + .orderBy(asc(issueThreadInteractions.createdAt), asc(issueThreadInteractions.id)); + + return rows.map((row) => hydrateInteraction(row)); + }, + + getById: async (interactionId: string) => { + const row = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, interactionId)) + .then((rows) => rows[0] ?? null); + + return row ? hydrateInteraction(row) : null; + }, + + create: async ( + issue: { id: string; companyId: string }, + input: CreateIssueThreadInteraction, + actor: InteractionActor, + ) => { + const data = createIssueThreadInteractionSchema.parse(input); + + if (data.idempotencyKey) { + const existing = await getIdempotentInteraction({ + issueId: issue.id, + companyId: issue.companyId, + idempotencyKey: data.idempotencyKey, + }); + if (existing) { + if (!isEquivalentCreateRequest(existing, data, actor)) { + throw conflict("Interaction idempotency key already exists for a different request", { + idempotencyKey: data.idempotencyKey, + }); + } + return hydrateInteraction(existing); + } + } + + if (data.sourceCommentId) { + const sourceComment = await db + .select({ + companyId: issueComments.companyId, + issueId: issueComments.issueId, + }) + .from(issueComments) + .where(eq(issueComments.id, data.sourceCommentId)) + .then((rows) => rows[0] ?? null); + if (!sourceComment || sourceComment.companyId !== issue.companyId || sourceComment.issueId !== issue.id) { + throw unprocessable("sourceCommentId must belong to the same issue and company"); + } + } + + if (data.sourceRunId) { + const sourceRun = await db + .select({ + companyId: heartbeatRuns.companyId, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, data.sourceRunId)) + .then((rows) => rows[0] ?? null); + if (!sourceRun || sourceRun.companyId !== issue.companyId) { + throw unprocessable("sourceRunId must belong to the same company"); + } + } + + if (data.kind === "request_confirmation") { + await assertRequestConfirmationTargetIsCurrent(db, { + companyId: issue.companyId, + issueId: issue.id, + target: data.payload.target ?? null, + }); + } + + let created: IssueThreadInteractionRow; + try { + [created] = await db + .insert(issueThreadInteractions) + .values({ + companyId: issue.companyId, + issueId: issue.id, + kind: data.kind, + status: "pending", + continuationPolicy: data.continuationPolicy, + idempotencyKey: data.idempotencyKey ?? null, + sourceCommentId: data.sourceCommentId ?? null, + sourceRunId: data.sourceRunId ?? null, + title: data.title ?? null, + summary: data.summary ?? null, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + payload: data.payload, + }) + .returning(); + } catch (error) { + if (!data.idempotencyKey || !isIssueThreadInteractionIdempotencyConflict(error)) { + throw error; + } + const existing = await getIdempotentInteraction({ + issueId: issue.id, + companyId: issue.companyId, + idempotencyKey: data.idempotencyKey, + }); + if (!existing) throw error; + if (!isEquivalentCreateRequest(existing, data, actor)) { + throw conflict("Interaction idempotency key already exists for a different request", { + idempotencyKey: data.idempotencyKey, + }); + } + return hydrateInteraction(existing); + } + + await touchIssue(db, issue.id); + return hydrateInteraction(created); + }, + + acceptInteraction: async ( + issue: { id: string; companyId: string; projectId: string | null; goalId: string | null }, + interactionId: string, + input: AcceptIssueThreadInteraction, + actor: InteractionActor, + ): Promise => { + const data = acceptIssueThreadInteractionSchema.parse(input); + const current = await getPendingInteractionForResolution({ issue, interactionId }); + switch (current.kind) { + case "suggest_tasks": + return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor); + case "request_confirmation": { + const accepted = await acceptRequestConfirmation({ + issue, + current, + actor, + }); + return { + interaction: accepted.interaction, + continuationIssue: accepted.continuationIssue, + createdIssues: [], + }; + } + default: + throw unprocessable(`Interactions of kind ${current.kind} cannot be accepted`); + } + }, + + acceptSuggestedTasks: async ( + issue: { id: string; companyId: string; projectId: string | null; goalId: string | null }, + interactionId: string, + input: AcceptIssueThreadInteraction, + actor: InteractionActor, + ) => { + const current = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, interactionId)) + .then((rows) => rows[0] ?? null); + + if (!current) throw notFound("Interaction not found"); + if (current.companyId !== issue.companyId || current.issueId !== issue.id) { + throw notFound("Interaction not found"); + } + if (current.kind !== "suggest_tasks") { + throw unprocessable("Only suggest_tasks interactions can be accepted"); + } + if (current.status !== "pending") { + throw conflict("Interaction has already been resolved"); + } + + const interaction = hydrateInteraction(current) as SuggestTasksInteraction; + const { selectedTasks, skippedClientKeys } = resolveSelectedSuggestedTasks({ + interaction, + selectedClientKeys: input.selectedClientKeys, + }); + const orderedTasks = buildTaskCreationOrder(selectedTasks); + const explicitParentIds = [...new Set([ + issue.id, + ...(interaction.payload.defaultParentId ? [interaction.payload.defaultParentId] : []), + ...selectedTasks + .map((task) => task.parentId ?? null) + .filter((value): value is string => Boolean(value)), + ])]; + + const parentRows = explicitParentIds.length === 0 + ? [] + : await db + .select({ + id: issues.id, + identifier: issues.identifier, + companyId: issues.companyId, + }) + .from(issues) + .where(and(eq(issues.companyId, issue.companyId), inArray(issues.id, explicitParentIds))); + if (parentRows.length !== explicitParentIds.length) { + throw unprocessable("Suggested tasks reference parent issues outside this company or issue tree"); + } + + const parentById = new Map(parentRows.map((row) => [row.id, row] as const)); + const createdByClientKey = new Map(); + const createdWakeTargets: IssueWakeTarget[] = []; + + await db.transaction(async (tx) => { + const resolvedAt = new Date(); + const [claimed] = await tx + .update(issueThreadInteractions) + .set({ + status: "accepted", + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt, + updatedAt: resolvedAt, + }) + .where(and( + eq(issueThreadInteractions.id, interactionId), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!claimed) { + throw conflict("Interaction has already been resolved"); + } + + for (const task of orderedTasks) { + const parentIssueId = task.parentClientKey + ? createdByClientKey.get(task.parentClientKey)?.issueId ?? null + : task.parentId ?? interaction.payload.defaultParentId ?? issue.id; + if (!parentIssueId) { + throw unprocessable(`Unable to resolve parent for suggested task ${task.clientKey}`); + } + + const { issue: createdIssue } = await issueService(tx as unknown as Db).createChild(parentIssueId, { + title: task.title, + description: task.description ?? null, + status: "todo", + priority: task.priority ?? "medium", + assigneeAgentId: task.assigneeAgentId ?? null, + assigneeUserId: task.assigneeUserId ?? null, + projectId: task.projectId ?? issue.projectId, + goalId: task.goalId ?? issue.goalId, + billingCode: task.billingCode ?? null, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + actorAgentId: actor.agentId ?? null, + actorUserId: actor.userId ?? null, + } as Parameters["createChild"]>[1]); + + const parentIdentifier = createdByClientKey.get(task.parentClientKey ?? "")?.identifier + ?? parentById.get(parentIssueId)?.identifier + ?? null; + createdByClientKey.set(task.clientKey, { + clientKey: task.clientKey, + issueId: createdIssue.id, + identifier: createdIssue.identifier ?? null, + title: createdIssue.title, + parentIssueId, + parentIdentifier, + }); + createdWakeTargets.push({ + id: createdIssue.id, + assigneeAgentId: createdIssue.assigneeAgentId ?? null, + status: createdIssue.status, + }); + } + + const [updated] = await tx + .update(issueThreadInteractions) + .set({ + result: { + version: 1, + createdTasks: [...createdByClientKey.values()], + ...(skippedClientKeys.length > 0 ? { skippedClientKeys } : {}), + }, + updatedAt: new Date(), + }) + .where(eq(issueThreadInteractions.id, interactionId)) + .returning(); + + await touchIssue(tx, issue.id); + current.status = updated.status; + current.result = updated.result; + current.resolvedByAgentId = updated.resolvedByAgentId; + current.resolvedByUserId = updated.resolvedByUserId; + current.resolvedAt = updated.resolvedAt; + current.updatedAt = updated.updatedAt; + }); + + return { + interaction: hydrateInteraction(current), + createdIssues: createdWakeTargets, + }; + }, + + rejectInteraction: async ( + issue: { id: string; companyId: string }, + interactionId: string, + input: RejectIssueThreadInteraction, + actor: InteractionActor, + ) => { + const data = rejectIssueThreadInteractionSchema.parse(input); + const current = await getPendingInteractionForResolution({ issue, interactionId }); + switch (current.kind) { + case "suggest_tasks": + return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor, current); + case "request_confirmation": + return rejectRequestConfirmation({ + issue, + current, + input: data, + actor, + }); + default: + throw unprocessable(`Interactions of kind ${current.kind} cannot be rejected`); + } + }, + + rejectSuggestedTasks: async ( + issue: { id: string; companyId: string }, + interactionId: string, + input: RejectIssueThreadInteraction, + actor: InteractionActor, + current: IssueThreadInteractionRow, + ) => { + if (current.companyId !== issue.companyId || current.issueId !== issue.id) { + throw notFound("Interaction not found"); + } + if (current.kind !== "suggest_tasks") { + throw unprocessable("Only suggest_tasks interactions can be rejected"); + } + if (current.status !== "pending") { + throw conflict("Interaction has already been resolved"); + } + + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "rejected", + result: { + version: 1, + rejectionReason: input.reason?.trim() || null, + }, + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .where(and( + eq(issueThreadInteractions.id, interactionId), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + + await touchIssue(db, issue.id); + return hydrateInteraction(updated); + }, + + expireRequestConfirmationsSupersededByComment: async ( + issue: { id: string; companyId: string }, + comment: { id: string; authorUserId?: string | null }, + actor: InteractionActor, + ) => { + if (!comment.authorUserId) return []; + + const rows = await db + .select() + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, issue.companyId), + eq(issueThreadInteractions.issueId, issue.id), + eq(issueThreadInteractions.kind, "request_confirmation"), + eq(issueThreadInteractions.status, "pending"), + )); + + const superseded = rows.filter((row) => { + const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; + return interaction.payload.supersedeOnUserComment === true; + }); + + if (superseded.length === 0) return []; + + const now = new Date(); + const expired: IssueThreadInteraction[] = []; + for (const row of superseded) { + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + result: { + version: 1, + outcome: "superseded_by_comment", + commentId: comment.id, + }, + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, row.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + if (updated) expired.push(hydrateInteraction(updated)); + } + + if (expired.length > 0) { + await touchIssue(db, issue.id); + } + return expired; + }, + + expireStaleRequestConfirmationsForIssueDocument: async ( + issue: { id: string; companyId: string }, + document: { id: string; key: string; latestRevisionId?: string | null; latestRevisionNumber?: number | null } | null, + actor: InteractionActor, + ) => { + const rows = await db + .select() + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, issue.companyId), + eq(issueThreadInteractions.issueId, issue.id), + eq(issueThreadInteractions.kind, "request_confirmation"), + eq(issueThreadInteractions.status, "pending"), + )); + + const staleRows = rows.filter((row) => { + const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; + const target = interaction.payload.target; + if (!target || target.type !== "issue_document") return false; + const targetIssueId = target.issueId ?? issue.id; + if (targetIssueId !== issue.id) return false; + if (document && target.documentId && target.documentId !== document.id) return false; + if (document && target.key !== document.key) return false; + if (!document) return true; + return ( + target.revisionId !== document.latestRevisionId + || (target.revisionNumber != null && target.revisionNumber !== document.latestRevisionNumber) + ); + }); + + if (staleRows.length === 0) return []; + + const now = new Date(); + const expired: IssueThreadInteraction[] = []; + for (const row of staleRows) { + const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; + const target = interaction.payload.target ?? null; + const currentTarget = buildIssueDocumentTargetFromDocument({ + issueId: issue.id, + document, + }); + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + payload: currentTarget + ? { + ...interaction.payload, + target: currentTarget, + } + : interaction.payload, + result: { + version: 1, + outcome: "stale_target", + staleTarget: target, + }, + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, row.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + if (updated) expired.push(hydrateInteraction(updated)); + } + + if (expired.length > 0) { + await touchIssue(db, issue.id); + } + return expired; + }, + + answerQuestions: async ( + issue: { id: string; companyId: string }, + interactionId: string, + input: RespondIssueThreadInteraction, + actor: InteractionActor, + ) => { + const current = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, interactionId)) + .then((rows) => rows[0] ?? null); + + if (!current) throw notFound("Interaction not found"); + if (current.companyId !== issue.companyId || current.issueId !== issue.id) { + throw notFound("Interaction not found"); + } + if (current.kind !== "ask_user_questions") { + throw unprocessable("Only ask_user_questions interactions can be answered"); + } + if (current.status !== "pending") { + throw conflict("Interaction has already been resolved"); + } + + const interaction = hydrateInteraction(current) as AskUserQuestionsInteraction; + const normalizedAnswers = normalizeQuestionAnswers({ + questions: interaction.payload.questions, + answers: input.answers, + }); + + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "answered", + result: { + version: 1, + answers: normalizedAnswers, + summaryMarkdown: input.summaryMarkdown ?? null, + }, + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .where(and( + eq(issueThreadInteractions.id, interactionId), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + + await touchIssue(db, issue.id); + return hydrateInteraction(updated); + }, + }; +} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 48a19ca..512c7da 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,3 +1,4 @@ +import { Buffer } from "node:buffer"; import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; import type { Db } from "@taskcore/db"; import { @@ -11,7 +12,6 @@ import { heartbeatRuns, executionWorkspaces, issueAttachments, - issueArtifacts, issueInboxArchives, issueLabels, issueRelations, @@ -39,6 +39,12 @@ import { getDefaultCompanyGoal } from "./goals.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500; +export const ISSUE_LIST_DEFAULT_LIMIT = 500; +export const ISSUE_LIST_MAX_LIMIT = 1000; +const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500; +export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25; +const MAX_CHILD_COMPLETION_SUMMARIES = 20; +const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500; function assertTransition(from: string, to: string) { if (from === to) return; @@ -74,12 +80,14 @@ export interface IssueFilters { inboxArchivedByUserId?: string; unreadForUserId?: string; projectId?: string; + workspaceId?: string; executionWorkspaceId?: string; parentId?: string; labelId?: string; originKind?: string; originId?: string; includeRoutineExecutions?: boolean; + excludeRoutineExecutions?: boolean; q?: string; limit?: number; } @@ -103,6 +111,10 @@ type IssueUserCommentStats = { myLastCommentAt: Date | null; lastExternalCommentAt: Date | null; }; +type IssueReadStat = { + issueId: string; + myLastReadAt: Date | null; +}; type IssueLastActivityStat = { issueId: string; latestCommentAt: Date | null; @@ -121,10 +133,35 @@ type IssueCreateInput = Omit & { blockedByIssueIds?: string[]; inheritExecutionWorkspaceFromIssueId?: string | null; }; +type IssueChildCreateInput = IssueCreateInput & { + acceptanceCriteria?: string[]; + blockParentUntilDone?: boolean; + actorAgentId?: string | null; + actorUserId?: string | null; +}; type IssueRelationSummaryMap = { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[]; }; +export type IssueDependencyReadiness = { + issueId: string; + blockerIssueIds: string[]; + unresolvedBlockerIssueIds: string[]; + unresolvedBlockerCount: number; + allBlockersDone: boolean; + isDependencyReady: boolean; +}; +export type ChildIssueCompletionSummary = { + id: string; + identifier: string | null; + title: string; + status: string; + priority: string; + assigneeAgentId: string | null; + assigneeUserId: string | null; + updatedAt: Date; + summary: string | null; +}; function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { if (actorRunId) return checkoutRunId === actorRunId; @@ -132,11 +169,125 @@ function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { } const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]); +const ISSUE_LIST_DESCRIPTION_MAX_CHARS = 1200; +const ISSUE_LIST_DESCRIPTION_MAX_BYTES = ISSUE_LIST_DESCRIPTION_MAX_CHARS * 4; function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, "\\$&"); } +export function clampIssueListLimit(limit: number): number { + return Math.min(ISSUE_LIST_MAX_LIMIT, Math.max(1, Math.floor(limit))); +} + +function chunkList(values: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let index = 0; index < values.length; index += size) { + chunks.push(values.slice(index, index + size)); + } + return chunks; +} + +function truncateInlineSummary(value: string | null | undefined, maxChars = CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS) { + const normalized = value?.trim(); + if (!normalized) return null; + return normalized.length > maxChars ? `${normalized.slice(0, Math.max(0, maxChars - 15)).trimEnd()} [truncated]` : normalized; +} + +function truncateByCodePoint(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return Array.from(value).slice(0, maxChars).join(""); +} + +function decodeDatabaseTextPreview(value: string | null | undefined, maxChars: number): string | null { + if (value == null) return null; + return truncateByCodePoint(Buffer.from(value, "base64").toString("utf8"), maxChars); +} + +function appendAcceptanceCriteriaToDescription(description: string | null | undefined, acceptanceCriteria: string[] | undefined) { + const criteria = (acceptanceCriteria ?? []).map((item) => item.trim()).filter(Boolean); + if (criteria.length === 0) return description ?? null; + const base = description?.trim() ?? ""; + const criteriaMarkdown = ["## Acceptance Criteria", "", ...criteria.map((item) => `- ${item}`)].join("\n"); + return base ? `${base}\n\n${criteriaMarkdown}` : criteriaMarkdown; +} + +function createIssueDependencyReadiness(issueId: string): IssueDependencyReadiness { + return { + issueId, + blockerIssueIds: [], + unresolvedBlockerIssueIds: [], + unresolvedBlockerCount: 0, + allBlockersDone: true, + isDependencyReady: true, + }; +} + +async function listIssueDependencyReadinessMap( + dbOrTx: Pick, + companyId: string, + issueIds: string[], +) { + const uniqueIssueIds = [...new Set(issueIds.filter(Boolean))]; + const readinessMap = new Map(); + for (const issueId of uniqueIssueIds) { + readinessMap.set(issueId, createIssueDependencyReadiness(issueId)); + } + if (uniqueIssueIds.length === 0) return readinessMap; + + const blockerRows = await dbOrTx + .select({ + issueId: issueRelations.relatedIssueId, + blockerIssueId: issueRelations.issueId, + blockerStatus: issues.status, + }) + .from(issueRelations) + .innerJoin(issues, eq(issueRelations.issueId, issues.id)) + .where( + and( + eq(issueRelations.companyId, companyId), + eq(issueRelations.type, "blocks"), + inArray(issueRelations.relatedIssueId, uniqueIssueIds), + ), + ); + + for (const row of blockerRows) { + const current = readinessMap.get(row.issueId) ?? createIssueDependencyReadiness(row.issueId); + current.blockerIssueIds.push(row.blockerIssueId); + // Only done blockers resolve dependents; cancelled blockers stay unresolved + // until an operator removes or replaces the blocker relationship explicitly. + if (row.blockerStatus !== "done") { + current.unresolvedBlockerIssueIds.push(row.blockerIssueId); + current.unresolvedBlockerCount += 1; + current.allBlockersDone = false; + current.isDependencyReady = false; + } + readinessMap.set(row.issueId, current); + } + + return readinessMap; +} + +async function listUnresolvedBlockerIssueIds( + dbOrTx: Pick, + companyId: string, + blockerIssueIds: string[], +) { + const uniqueBlockerIssueIds = [...new Set(blockerIssueIds.filter(Boolean))]; + if (uniqueBlockerIssueIds.length === 0) return []; + return dbOrTx + .select({ id: issues.id }) + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + inArray(issues.id, uniqueBlockerIssueIds), + // Cancelled blockers intentionally remain unresolved until the relation changes. + ne(issues.status, "done"), + ), + ) + .then((rows) => rows.map((row) => row.id)); +} async function getProjectDefaultGoalId( db: ProjectGoalReader, companyId: string, @@ -314,9 +465,9 @@ function issueLatestLogAtExpr(companyId: string) { 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`, `, - )}) + ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`), + sql`, `, + )}) ) `; } @@ -459,20 +610,22 @@ function latestIssueActivityAt(...values: Array> { const map = new Map(); if (issueIds.length === 0) return map; - const rows = await dbOrTx - .select({ - issueId: issueLabels.issueId, - label: labels, - }) - .from(issueLabels) - .innerJoin(labels, eq(issueLabels.labelId, labels.id)) - .where(inArray(issueLabels.issueId, issueIds)) - .orderBy(asc(labels.name), asc(labels.id)); - - for (const row of rows) { - const existing = map.get(row.issueId); - if (existing) existing.push(row.label); - else map.set(row.issueId, [row.label]); + for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + issueId: issueLabels.issueId, + label: labels, + }) + .from(issueLabels) + .innerJoin(labels, eq(issueLabels.labelId, labels.id)) + .where(inArray(issueLabels.issueId, issueIdChunk)) + .orderBy(asc(labels.name), asc(labels.id)); + + for (const row of rows) { + const existing = map.get(row.issueId); + if (existing) existing.push(row.label); + else map.set(row.issueId, [row.label]); + } } return map; } @@ -502,31 +655,85 @@ async function activeRunMapForIssues( .filter((id): id is string => id != null); if (runIds.length === 0) return map; - const rows = await dbOrTx - .select({ - id: heartbeatRuns.id, - status: heartbeatRuns.status, - agentId: heartbeatRuns.agentId, - invocationSource: heartbeatRuns.invocationSource, - triggerDetail: heartbeatRuns.triggerDetail, - startedAt: heartbeatRuns.startedAt, - finishedAt: heartbeatRuns.finishedAt, - createdAt: heartbeatRuns.createdAt, - }) - .from(heartbeatRuns) - .where( - and( - inArray(heartbeatRuns.id, runIds), - inArray(heartbeatRuns.status, ACTIVE_RUN_STATUSES), - ), - ); + for (const runIdChunk of chunkList([...new Set(runIds)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + id: heartbeatRuns.id, + status: heartbeatRuns.status, + agentId: heartbeatRuns.agentId, + invocationSource: heartbeatRuns.invocationSource, + triggerDetail: heartbeatRuns.triggerDetail, + startedAt: heartbeatRuns.startedAt, + finishedAt: heartbeatRuns.finishedAt, + createdAt: heartbeatRuns.createdAt, + }) + .from(heartbeatRuns) + .where( + and( + inArray(heartbeatRuns.id, runIdChunk), + inArray(heartbeatRuns.status, ACTIVE_RUN_STATUSES), + ), + ); - for (const row of rows) { - map.set(row.id, row); + for (const row of rows) { + map.set(row.id, row); + } } return map; } +const issueListSelect = { + id: issues.id, + companyId: issues.companyId, + projectId: issues.projectId, + projectWorkspaceId: issues.projectWorkspaceId, + goalId: issues.goalId, + parentId: issues.parentId, + title: issues.title, + description: sql` + CASE + WHEN ${issues.description} IS NULL THEN NULL + ELSE encode( + substring( + convert_to(${issues.description}, current_setting('server_encoding')) + FROM 1 FOR ${ISSUE_LIST_DESCRIPTION_MAX_BYTES} + ), + 'base64' + ) + END + `, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + checkoutRunId: issues.checkoutRunId, + executionRunId: issues.executionRunId, + executionAgentNameKey: issues.executionAgentNameKey, + executionLockedAt: issues.executionLockedAt, + createdByAgentId: issues.createdByAgentId, + createdByUserId: issues.createdByUserId, + issueNumber: issues.issueNumber, + identifier: issues.identifier, + originKind: issues.originKind, + originId: issues.originId, + originRunId: issues.originRunId, + originFingerprint: issues.originFingerprint, + requestDepth: issues.requestDepth, + billingCode: issues.billingCode, + assigneeAdapterOverrides: issues.assigneeAdapterOverrides, + executionPolicy: sql`null`, + executionState: sql`null`, + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspacePreference: issues.executionWorkspacePreference, + executionWorkspaceSettings: sql`null`, + startedAt: issues.startedAt, + completedAt: issues.completedAt, + cancelledAt: issues.cancelledAt, + hiddenAt: issues.hiddenAt, + createdAt: issues.createdAt, + updatedAt: issues.updatedAt, +}; + function withActiveRuns( issueRows: IssueWithLabels[], runMap: Map, @@ -537,6 +744,131 @@ function withActiveRuns( })); } +async function userCommentStatsForIssues( + dbOrTx: any, + companyId: string, + userId: string, + issueIds: string[], +): Promise { + const stats: IssueUserCommentStats[] = []; + for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + issueId: issueComments.issueId, + myLastCommentAt: sql` + MAX(CASE WHEN ${issueComments.authorUserId} = ${userId} THEN ${issueComments.createdAt} END) + `, + lastExternalCommentAt: sql` + MAX( + CASE + WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${userId} + THEN ${issueComments.createdAt} + END + ) + `, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, companyId), + inArray(issueComments.issueId, issueIdChunk), + ), + ) + .groupBy(issueComments.issueId); + stats.push(...rows); + } + return stats; +} + +async function userReadStatsForIssues( + dbOrTx: any, + companyId: string, + userId: string, + issueIds: string[], +): Promise { + const stats: IssueReadStat[] = []; + for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + issueId: issueReadStates.issueId, + myLastReadAt: issueReadStates.lastReadAt, + }) + .from(issueReadStates) + .where( + and( + eq(issueReadStates.companyId, companyId), + eq(issueReadStates.userId, userId), + inArray(issueReadStates.issueId, issueIdChunk), + ), + ); + stats.push(...rows); + } + return stats; +} + +async function lastActivityStatsForIssues( + dbOrTx: any, + companyId: string, + issueIds: string[], +): Promise { + const byIssueId = new Map(); + for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const [commentRows, logRows] = await Promise.all([ + dbOrTx + .select({ + issueId: issueComments.issueId, + latestCommentAt: sql`MAX(${issueComments.createdAt})`, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, companyId), + inArray(issueComments.issueId, issueIdChunk), + ), + ) + .groupBy(issueComments.issueId), + dbOrTx + .select({ + issueId: activityLog.entityId, + latestLogAt: sql`MAX(${activityLog.createdAt})`, + }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, companyId), + eq(activityLog.entityType, "issue"), + inArray(activityLog.entityId, issueIdChunk), + sql`${activityLog.action} NOT IN (${sql.join( + ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`), + sql`, `, + )})`, + ), + ) + .groupBy(activityLog.entityId), + ]); + + 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()]; +} + export function issueService(db: Db) { const instanceSettings = instanceSettingsService(db); @@ -962,6 +1294,12 @@ export function issueService(db: Db) { conditions.push(unreadForUserCondition(companyId, unreadForUserId)); } if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); + if (filters?.workspaceId) { + conditions.push(or( + eq(issues.executionWorkspaceId, filters.workspaceId), + eq(issues.projectWorkspaceId, filters.workspaceId), + )!); + } if (filters?.executionWorkspaceId) { conditions.push(eq(issues.executionWorkspaceId, filters.executionWorkspaceId)); } @@ -986,7 +1324,7 @@ export function issueService(db: Db) { )!, ); } - if (!filters?.includeRoutineExecutions && !filters?.originKind && !filters?.originId) { + if (filters?.excludeRoutineExecutions && !filters?.originKind && !filters?.originId) { conditions.push(ne(issues.originKind, "routine_execution")); } conditions.push(isNull(issues.hiddenAt)); @@ -1005,7 +1343,7 @@ export function issueService(db: Db) { `; const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId); const baseQuery = db - .select() + .select(issueListSelect) .from(issues) .where(and(...conditions)) .orderBy( @@ -1014,7 +1352,10 @@ export function issueService(db: Db) { desc(canonicalLastActivityAt), desc(issues.updatedAt), ); - const rows = limit === undefined ? await baseQuery : await baseQuery.limit(limit); + const rows = (limit === undefined ? await baseQuery : await baseQuery.limit(limit)).map((row) => ({ + ...row, + description: decodeDatabaseTextPreview(row.description, ISSUE_LIST_DESCRIPTION_MAX_CHARS), + })); const withLabels = await withIssueLabels(db, rows); const runMap = await activeRunMapForIssues(db, withLabels); const withRuns = withActiveRuns(withLabels, runMap); @@ -1025,99 +1366,12 @@ export function issueService(db: Db) { const issueIds = withRuns.map((row) => row.id); const [statsRows, readRows, lastActivityRows] = await Promise.all([ contextUserId - ? db - .select({ - issueId: issueComments.issueId, - myLastCommentAt: sql` - MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END) - `, - lastExternalCommentAt: sql` - MAX( - CASE - 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) + ? userCommentStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), contextUserId - ? db - .select({ - issueId: issueReadStates.issueId, - myLastReadAt: issueReadStates.lastReadAt, - }) - .from(issueReadStates) - .where( - and( - eq(issueReadStates.companyId, companyId), - eq(issueReadStates.userId, contextUserId), - inArray(issueReadStates.issueId, issueIds), - ), - ) + ? userReadStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), - Promise.all([ - db - .select({ - issueId: issueComments.issueId, - latestCommentAt: sql`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`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(); - 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()]; - }), + lastActivityStatsForIssues(db, companyId, issueIds), ]); const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); @@ -1163,7 +1417,6 @@ export function issueService(db: Db) { eq(issues.companyId, companyId), isNull(issues.hiddenAt), unreadForUserCondition(companyId, userId), - ne(issues.originKind, "routine_execution"), ]; if (status) { const statuses = status.split(",").map((s) => s.trim()).filter(Boolean); @@ -1278,6 +1531,21 @@ export function issueService(db: Db) { return relations.get(issueId) ?? { blockedBy: [], blocks: [] }; }, + getDependencyReadiness: async (issueId: string, dbOrTx: any = db) => { + const issue = await dbOrTx + .select({ id: issues.id, companyId: issues.companyId }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows: Array<{ id: string; companyId: string }>) => rows[0] ?? null); + if (!issue) throw notFound("Issue not found"); + const readiness = await listIssueDependencyReadinessMap(dbOrTx, issue.companyId, [issueId]); + return readiness.get(issueId) ?? createIssueDependencyReadiness(issueId); + }, + + listDependencyReadiness: async (companyId: string, issueIds: string[], dbOrTx: any = db) => { + return listIssueDependencyReadinessMap(dbOrTx, companyId, issueIds); + }, + listWakeableBlockedDependents: async (blockerIssueId: string) => { const blockerIssue = await db .select({ id: issues.id, companyId: issues.companyId }) @@ -1361,18 +1629,110 @@ export function issueService(db: Db) { } const children = await db - .select({ id: issues.id, status: issues.status }) + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + updatedAt: issues.updatedAt, + }) .from(issues) - .where(and(eq(issues.companyId, parent.companyId), eq(issues.parentId, parentIssueId))); + .where(and(eq(issues.companyId, parent.companyId), eq(issues.parentId, parentIssueId))) + .orderBy(asc(issues.issueNumber), asc(issues.createdAt)); if (children.length === 0) return null; if (!children.every((child) => child.status === "done" || child.status === "cancelled")) { return null; } + const childIdsForSummaries = children.slice(0, MAX_CHILD_COMPLETION_SUMMARIES).map((child) => child.id); + const commentRows = childIdsForSummaries.length > 0 + ? await db + .select({ + issueId: issueComments.issueId, + body: issueComments.body, + createdAt: issueComments.createdAt, + }) + .from(issueComments) + .where(and(eq(issueComments.companyId, parent.companyId), inArray(issueComments.issueId, childIdsForSummaries))) + .orderBy(desc(issueComments.createdAt), desc(issueComments.id)) + : []; + const latestCommentByIssueId = new Map(); + for (const comment of commentRows) { + if (!latestCommentByIssueId.has(comment.issueId)) { + latestCommentByIssueId.set(comment.issueId, comment.body); + } + } + const childIssueSummaries: ChildIssueCompletionSummary[] = children + .slice(0, MAX_CHILD_COMPLETION_SUMMARIES) + .map((child) => ({ + ...child, + summary: truncateInlineSummary(latestCommentByIssueId.get(child.id)), + })); + return { id: parent.id, assigneeAgentId: parent.assigneeAgentId, childIssueIds: children.map((child) => child.id), + childIssueSummaries, + childIssueSummaryTruncated: children.length > childIssueSummaries.length, + }; + }, + + createChild: async ( + parentIssueId: string, + data: IssueChildCreateInput, + ) => { + const parent = await db + .select() + .from(issues) + .where(eq(issues.id, parentIssueId)) + .then((rows) => rows[0] ?? null); + if (!parent) throw notFound("Parent issue not found"); + + const [{ childCount }] = await db + .select({ childCount: sql`count(*)::int` }) + .from(issues) + .where(and(eq(issues.companyId, parent.companyId), eq(issues.parentId, parent.id))); + if (childCount >= MAX_CHILD_ISSUES_CREATED_BY_HELPER) { + throw unprocessable(`Parent issue already has the maximum ${MAX_CHILD_ISSUES_CREATED_BY_HELPER} child issues for this helper`); + } + + const { + acceptanceCriteria, + blockParentUntilDone, + actorAgentId, + actorUserId, + ...issueData + } = data; + const child = await issueService(db).create(parent.companyId, { + ...issueData, + parentId: parent.id, + projectId: issueData.projectId ?? parent.projectId, + goalId: issueData.goalId ?? parent.goalId, + requestDepth: Math.max(parent.requestDepth + 1, issueData.requestDepth ?? 0), + description: appendAcceptanceCriteriaToDescription(issueData.description, acceptanceCriteria), + inheritExecutionWorkspaceFromIssueId: parent.id, + }); + + if (blockParentUntilDone) { + const existingBlockers = await db + .select({ blockerIssueId: issueRelations.issueId }) + .from(issueRelations) + .where(and(eq(issueRelations.companyId, parent.companyId), eq(issueRelations.relatedIssueId, parent.id), eq(issueRelations.type, "blocks"))); + await syncBlockedByIssueIds( + parent.id, + parent.companyId, + [...new Set([...existingBlockers.map((row) => row.blockerIssueId), child.id])], + { agentId: actorAgentId ?? null, userId: actorUserId ?? null }, + ); + } + + return { + issue: child, + parentBlockerAdded: Boolean(blockParentUntilDone), }; }, @@ -1606,6 +1966,16 @@ export function issueService(db: Db) { if (patch.status === "in_progress" && !nextAssigneeAgentId && !nextAssigneeUserId) { throw unprocessable("in_progress issues require an assignee"); } + if (patch.status === "in_progress") { + const unresolvedBlockerIssueIds = blockedByIssueIds !== undefined + ? await listUnresolvedBlockerIssueIds(dbOrTx, existing.companyId, blockedByIssueIds) + : ( + await listIssueDependencyReadinessMap(dbOrTx, existing.companyId, [id]) + ).get(id)?.unresolvedBlockerIssueIds ?? []; + if (unresolvedBlockerIssueIds.length > 0) { + throw unprocessable("Issue is blocked by unresolved blockers", { unresolvedBlockerIssueIds }); + } + } if (issueData.assigneeAgentId) { await assertAssignableAgent(existing.companyId, issueData.assigneeAgentId); } @@ -1775,6 +2145,12 @@ export function issueService(db: Db) { } }); + const dependencyReadiness = await listIssueDependencyReadinessMap(db, issueCompany.companyId, [id]); + const unresolvedBlockerIssueIds = dependencyReadiness.get(id)?.unresolvedBlockerIssueIds ?? []; + if (unresolvedBlockerIssueIds.length > 0) { + throw unprocessable("Issue is blocked by unresolved blockers", { unresolvedBlockerIssueIds }); + } + const sameRunAssigneeCondition = checkoutRunId ? and( eq(issues.assigneeAgentId, agentId), @@ -2105,13 +2481,13 @@ export function issueService(db: Db) { getComment: (commentId: string) => instanceSettings.getGeneral().then(({ censorUsernameInLogs }) => db - .select() - .from(issueComments) - .where(eq(issueComments.id, commentId)) - .then((rows) => { - const comment = rows[0] ?? null; - return comment ? redactIssueComment(comment, censorUsernameInLogs) : null; - })), + .select() + .from(issueComments) + .where(eq(issueComments.id, commentId)) + .then((rows) => { + const comment = rows[0] ?? null; + return comment ? redactIssueComment(comment, censorUsernameInLogs) : null; + })), removeComment: async (commentId: string) => { const currentUserRedactionOptions = { @@ -2351,7 +2727,10 @@ export function issueService(db: Db) { return [...resolved]; }, - findMentionedProjectIds: async (issueId: string) => { + findMentionedProjectIds: async ( + issueId: string, + opts?: { includeCommentBodies?: boolean }, + ) => { const issue = await db .select({ companyId: issues.companyId, @@ -2363,21 +2742,26 @@ export function issueService(db: Db) { .then((rows) => rows[0] ?? null); if (!issue) return []; - const comments = await db - .select({ body: issueComments.body }) - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); - const mentionedIds = new Set(); - for (const source of [ - issue.title, - issue.description ?? "", - ...comments.map((comment) => comment.body), - ]) { + for (const source of [issue.title, issue.description ?? ""]) { for (const projectId of extractProjectMentionIds(source)) { mentionedIds.add(projectId); } } + + if (opts?.includeCommentBodies !== false) { + const comments = await db + .select({ body: issueComments.body }) + .from(issueComments) + .where(eq(issueComments.issueId, issueId)); + + for (const comment of comments) { + for (const projectId of extractProjectMentionIds(comment.body)) { + mentionedIds.add(projectId); + } + } + } + if (mentionedIds.size === 0) return []; const rows = await db @@ -2516,71 +2900,5 @@ export function issueService(db: Db) { goal: a.goalId ? goalMap.get(a.goalId) ?? null : null, })); }, - - listArtifacts: async (issueId: string) => { - return db - .select() - .from(issueArtifacts) - .where(eq(issueArtifacts.issueId, issueId)) - .orderBy(desc(issueArtifacts.version), asc(issueArtifacts.artifactId)); - }, - - upsertArtifact: async (input: { - companyId: string; - issueId: string; - artifactId: string; - title: string; - mimeType: string; - provider: string; - objectKey: string; - sizeBytes: number; - sha256: string; - metadataJson?: Record; - agentId?: string | null; - userId?: string | null; - }) => { - const existing = await db - .select({ version: issueArtifacts.version }) - .from(issueArtifacts) - .where( - and( - eq(issueArtifacts.issueId, input.issueId), - eq(issueArtifacts.artifactId, input.artifactId), - ), - ) - .orderBy(desc(issueArtifacts.version)) - .limit(1) - .then((rows) => rows[0] ?? null); - - const nextVersion = existing ? existing.version + 1 : 1; - - return db - .insert(issueArtifacts) - .values({ - companyId: input.companyId, - issueId: input.issueId, - artifactId: input.artifactId, - version: nextVersion, - title: input.title, - mimeType: input.mimeType, - provider: input.provider, - objectKey: input.objectKey, - sizeBytes: input.sizeBytes, - sha256: input.sha256, - metadataJson: input.metadataJson ?? null, - createdByAgentId: input.agentId ?? null, - createdByUserId: input.userId ?? null, - }) - .returning() - .then((rows) => rows[0]); - }, - - getArtifactById: async (id: string) => { - return db - .select() - .from(issueArtifacts) - .where(eq(issueArtifacts.id, id)) - .then((rows) => rows[0] ?? null); - }, }; } diff --git a/server/src/services/onboarding.ts b/server/src/services/onboarding.ts deleted file mode 100644 index ea6ccde..0000000 --- a/server/src/services/onboarding.ts +++ /dev/null @@ -1,35 +0,0 @@ -export function onboardingService() { - return { - generateRecommendation: async (mission: string) => { - // For now, we use a rule-based recommendation system. - // In a production environment, this would call an LLM with a system prompt - // to break down the mission into a concrete starting project and task. - - const missionLower = mission.toLowerCase(); - - let recommendation = { - suggestedProjectName: "Initial Roadmap", - suggestedTaskTitle: "Define core objectives and technical stack", - suggestedTaskDescription: `Based on your mission: "${mission}"\n\nI recommend starting with a clear plan:\n1. Identify the primary goals\n2. Define the first milestone\n3. Set up the development environment`, - followUpQuestions: [ - "What is the primary target audience for this project?", - "Are there any specific technologies you definitely want to use?", - "What would you consider a successful 'Day 1' outcome?" - ] - }; - - if (missionLower.includes("web") || missionLower.includes("app") || missionLower.includes("site")) { - recommendation.suggestedProjectName = "Web Development"; - recommendation.suggestedTaskTitle = "Create project scaffold and basic architecture"; - } else if (missionLower.includes("data") || missionLower.includes("analysis") || missionLower.includes("research")) { - recommendation.suggestedProjectName = "Data Analysis"; - recommendation.suggestedTaskTitle = "Gather initial datasets and define analysis metrics"; - } else if (missionLower.includes("automation") || missionLower.includes("script") || missionLower.includes("tool")) { - recommendation.suggestedProjectName = "Automation Tools"; - recommendation.suggestedTaskTitle = "Map out the manual process to be automated"; - } - - return recommendation; - } - }; -} diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index a345241..c5bf885 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -51,6 +51,7 @@ const OPERATION_CAPABILITIES: Record = { "project.workspaces.get": ["project.workspaces.read"], "issues.list": ["issues.read"], "issues.get": ["issues.read"], + "issues.relations.get": ["issue.relations.read"], "issue.comments.list": ["issue.comments.read"], "issue.comments.get": ["issue.comments.read"], "agents.list": ["agents.read"], @@ -61,14 +62,27 @@ const OPERATION_CAPABILITIES: Record = { "activity.get": ["activity.read"], "costs.list": ["costs.read"], "costs.get": ["costs.read"], + "issues.summaries.getOrchestration": ["issues.orchestration.read"], + "db.namespace": ["database.namespace.read"], + "db.query": ["database.namespace.read"], // Data write operations "issues.create": ["issues.create"], "issues.update": ["issues.update"], + "issues.relations.setBlockedBy": ["issue.relations.write"], + "issues.relations.addBlockers": ["issue.relations.write"], + "issues.relations.removeBlockers": ["issue.relations.write"], + "issues.assertCheckoutOwner": ["issues.checkout"], + "issues.getSubtree": ["issue.subtree.read"], + "issues.requestWakeup": ["issues.wakeup"], + "issues.requestWakeups": ["issues.wakeup"], "issue.comments.create": ["issue.comments.create"], + "issue.interactions.create": ["issue.interactions.create"], "activity.log": ["activity.log.write"], "metrics.write": ["metrics.write"], "telemetry.track": ["telemetry.track"], + "db.migrate": ["database.namespace.migrate"], + "db.execute": ["database.namespace.write"], // Plugin state operations "plugin.state.get": ["plugin.state.read"], @@ -141,6 +155,7 @@ const FEATURE_CAPABILITIES: Record = { tools: "agent.tools.register", jobs: "jobs.schedule", webhooks: "webhooks.receive", + database: "database.namespace.migrate", }; // --------------------------------------------------------------------------- diff --git a/server/src/services/plugin-database.ts b/server/src/services/plugin-database.ts new file mode 100644 index 0000000..5f3a315 --- /dev/null +++ b/server/src/services/plugin-database.ts @@ -0,0 +1,498 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { and, eq, sql } from "drizzle-orm"; +import type { SQL } from "drizzle-orm"; +import type { Db } from "@taskcore/db"; +import { + pluginDatabaseNamespaces, + pluginMigrations, + plugins, +} from "@taskcore/db"; +import type { + TaskcorePluginManifestV1, + PluginDatabaseCoreReadTable, + PluginMigrationRecord, +} from "@taskcore/shared"; + +const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const MAX_POSTGRES_IDENTIFIER_LENGTH = 63; + +type SqlRef = { schema: string; table: string; keyword: string }; + +export type PluginDatabaseRuntimeResult> = { + rows?: T[]; + rowCount?: number; +}; + +export function derivePluginDatabaseNamespace( + pluginKey: string, + namespaceSlug?: string, +): string { + const hash = createHash("sha256").update(pluginKey).digest("hex").slice(0, 10); + const slug = (namespaceSlug ?? pluginKey) + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") + .replace(/_+/g, "_") + .slice(0, 36) || "plugin"; + const namespace = `plugin_${slug}_${hash}`; + return namespace.slice(0, MAX_POSTGRES_IDENTIFIER_LENGTH); +} + +function assertIdentifier(value: string, label = "identifier"): string { + if (!IDENTIFIER_RE.test(value)) { + throw new Error(`Unsafe SQL ${label}: ${value}`); + } + return value; +} + +function quoteIdentifier(value: string): string { + return `"${assertIdentifier(value).replaceAll("\"", "\"\"")}"`; +} + +function splitSqlStatements(input: string): string[] { + const statements: string[] = []; + let start = 0; + let quote: "'" | "\"" | null = null; + let lineComment = false; + let blockComment = false; + + for (let i = 0; i < input.length; i += 1) { + const char = input[i]!; + const next = input[i + 1]; + + if (lineComment) { + if (char === "\n") lineComment = false; + continue; + } + if (blockComment) { + if (char === "*" && next === "/") { + blockComment = false; + i += 1; + } + continue; + } + if (quote) { + if (char === quote) { + if (next === quote) { + i += 1; + } else { + quote = null; + } + } + continue; + } + if (char === "-" && next === "-") { + lineComment = true; + i += 1; + continue; + } + if (char === "/" && next === "*") { + blockComment = true; + i += 1; + continue; + } + if (char === "'" || char === "\"") { + quote = char; + continue; + } + if (char === ";") { + const statement = input.slice(start, i).trim(); + if (statement) statements.push(statement); + start = i + 1; + } + } + + const trailing = input.slice(start).trim(); + if (trailing) statements.push(trailing); + return statements; +} + +function stripSqlForKeywordScan(input: string): string { + return input + .replace(/'([^']|'')*'/g, "''") + .replace(/"([^"]|"")*"/g, "\"\"") + .replace(/--.*$/gm, "") + .replace(/\/\*[\s\S]*?\*\//g, ""); +} + +function normaliseSql(input: string): string { + return stripSqlForKeywordScan(input).replace(/\s+/g, " ").trim().toLowerCase(); +} + +function extractQualifiedRefs(statement: string): SqlRef[] { + const refs: SqlRef[] = []; + const patterns = [ + /\b(from|join|references|into|update)\s+"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi, + /\b(alter\s+table|create\s+table|create\s+view|drop\s+table|truncate\s+table)\s+(?:if\s+(?:not\s+)?exists\s+)?"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi, + ]; + + for (const pattern of patterns) { + for (const match of statement.matchAll(pattern)) { + refs.push({ keyword: match[1]!.toLowerCase(), schema: match[2]!, table: match[3]! }); + } + } + return refs; +} + +function assertAllowedPublicRead( + ref: SqlRef, + allowedCoreReadTables: ReadonlySet, +): void { + if (ref.schema !== "public") return; + if (!allowedCoreReadTables.has(ref.table)) { + throw new Error(`Plugin SQL references public.${ref.table}, which is not whitelisted`); + } + if (!["from", "join", "references"].includes(ref.keyword)) { + throw new Error(`Plugin SQL cannot mutate or define objects in public.${ref.table}`); + } +} + +function assertNoBannedSql(statement: string): void { + const normalized = normaliseSql(statement); + const banned = [ + /\bcreate\s+extension\b/, + /\bcreate\s+(?:event\s+)?trigger\b/, + /\bcreate\s+(?:or\s+replace\s+)?function\b/, + /\bcreate\s+language\b/, + /\bgrant\b/, + /\brevoke\b/, + /\bsecurity\s+definer\b/, + /\bcopy\b/, + /\bcall\b/, + /\bdo\s+(?:\$\$|language\b)/, + ]; + const matched = banned.find((pattern) => pattern.test(normalized)); + if (matched) { + throw new Error(`Plugin SQL contains a disallowed statement or clause: ${matched.source}`); + } +} + +export function validatePluginMigrationStatement( + statement: string, + namespace: string, + coreReadTables: readonly PluginDatabaseCoreReadTable[] = [], +): void { + assertIdentifier(namespace, "namespace"); + assertNoBannedSql(statement); + + const normalized = normaliseSql(statement); + if (/^\s*(drop|truncate)\b/.test(normalized)) { + throw new Error("Destructive plugin migrations are not allowed in Phase 1"); + } + + const ddlAllowed = /^(create|alter|comment)\b/.test(normalized); + if (!ddlAllowed) { + throw new Error("Plugin migrations may contain DDL statements only"); + } + + const refs = extractQualifiedRefs(statement); + if (refs.length === 0 && !normalized.startsWith("comment ")) { + throw new Error("Plugin migration objects must use fully qualified schema names"); + } + + const allowedCoreReadTables = new Set(coreReadTables); + for (const ref of refs) { + if (ref.schema === namespace) continue; + if (ref.schema === "public") { + assertAllowedPublicRead(ref, allowedCoreReadTables); + continue; + } + throw new Error(`Plugin SQL references schema "${ref.schema}" outside namespace "${namespace}"`); + } +} + +export function validatePluginRuntimeQuery( + query: string, + namespace: string, + coreReadTables: readonly PluginDatabaseCoreReadTable[] = [], +): void { + const statements = splitSqlStatements(query); + if (statements.length !== 1) { + throw new Error("Plugin runtime SQL must contain exactly one statement"); + } + const statement = statements[0]!; + assertNoBannedSql(statement); + const normalized = normaliseSql(statement); + if (!normalized.startsWith("select ") && !normalized.startsWith("with ")) { + throw new Error("ctx.db.query only allows SELECT statements"); + } + if (/\b(insert|update|delete|alter|create|drop|truncate)\b/.test(normalized)) { + throw new Error("ctx.db.query cannot contain mutation or DDL keywords"); + } + + const allowedCoreReadTables = new Set(coreReadTables); + for (const ref of extractQualifiedRefs(statement)) { + if (ref.schema === namespace) continue; + if (ref.schema === "public") { + assertAllowedPublicRead(ref, allowedCoreReadTables); + continue; + } + throw new Error(`ctx.db.query cannot read schema "${ref.schema}"`); + } +} + +export function validatePluginRuntimeExecute(query: string, namespace: string): void { + const statements = splitSqlStatements(query); + if (statements.length !== 1) { + throw new Error("Plugin runtime SQL must contain exactly one statement"); + } + const statement = statements[0]!; + assertNoBannedSql(statement); + const normalized = normaliseSql(statement); + if (!/^(insert\s+into|update|delete\s+from)\b/.test(normalized)) { + throw new Error("ctx.db.execute only allows INSERT, UPDATE, or DELETE"); + } + if (/\b(alter|create|drop|truncate)\b/.test(normalized)) { + throw new Error("ctx.db.execute cannot contain DDL keywords"); + } + + const refs = extractQualifiedRefs(statement); + const target = refs.find((ref) => ["into", "update", "from"].includes(ref.keyword)); + if (!target || target.schema !== namespace) { + throw new Error(`ctx.db.execute target must be inside plugin namespace "${namespace}"`); + } + for (const ref of refs) { + if (ref.schema !== namespace) { + throw new Error("ctx.db.execute cannot reference public or other non-plugin schemas"); + } + } +} + +function bindSql(statement: string, params: readonly unknown[] = []): SQL { + // Safe only after callers run the plugin SQL validators above. + if (params.length === 0) return sql.raw(statement); + const chunks: SQL[] = []; + let cursor = 0; + const placeholderPattern = /\$(\d+)/g; + const seen = new Set(); + + for (const match of statement.matchAll(placeholderPattern)) { + const index = Number(match[1]); + if (!Number.isInteger(index) || index < 1 || index > params.length) { + throw new Error(`SQL placeholder $${match[1]} has no matching parameter`); + } + chunks.push(sql.raw(statement.slice(cursor, match.index))); + chunks.push(sql`${params[index - 1]}`); + seen.add(index); + cursor = match.index! + match[0].length; + } + chunks.push(sql.raw(statement.slice(cursor))); + if (seen.size !== params.length) { + throw new Error("Every ctx.db parameter must be referenced by a $n placeholder"); + } + return sql.join(chunks, sql.raw("")); +} + +async function listSqlMigrationFiles(migrationsDir: string): Promise { + const entries = await readdir(migrationsDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".sql")) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b)); +} + +function resolveMigrationsDir(packageRoot: string, migrationsDir: string): string { + const resolvedRoot = path.resolve(packageRoot); + const resolvedDir = path.resolve(resolvedRoot, migrationsDir); + const relative = path.relative(resolvedRoot, resolvedDir); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`Plugin migrationsDir escapes package root: ${migrationsDir}`); + } + return resolvedDir; +} + +export function pluginDatabaseService(db: Db) { + async function getPluginRecord(pluginId: string) { + const rows = await db.select().from(plugins).where(eq(plugins.id, pluginId)).limit(1); + const plugin = rows[0]; + if (!plugin) throw new Error(`Plugin not found: ${pluginId}`); + return plugin; + } + + async function ensureNamespace(pluginId: string, manifest: TaskcorePluginManifestV1) { + if (!manifest.database) return null; + const namespaceName = derivePluginDatabaseNamespace( + manifest.id, + manifest.database.namespaceSlug, + ); + await db.execute(sql.raw(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(namespaceName)}`)); + const rows = await db + .insert(pluginDatabaseNamespaces) + .values({ + pluginId, + pluginKey: manifest.id, + namespaceName, + namespaceMode: "schema", + status: "active", + }) + .onConflictDoUpdate({ + target: pluginDatabaseNamespaces.pluginId, + set: { + pluginKey: manifest.id, + namespaceName, + namespaceMode: "schema", + status: "active", + updatedAt: new Date(), + }, + }) + .returning(); + return rows[0] ?? null; + } + + async function getNamespace(pluginId: string) { + const rows = await db + .select() + .from(pluginDatabaseNamespaces) + .where(eq(pluginDatabaseNamespaces.pluginId, pluginId)) + .limit(1); + return rows[0] ?? null; + } + + async function getRuntimeNamespace(pluginId: string) { + const namespace = await getNamespace(pluginId); + if (!namespace || namespace.status !== "active") { + throw new Error("Plugin database namespace is not active"); + } + return namespace.namespaceName; + } + + async function recordMigrationFailure(input: { + pluginId: string; + pluginKey: string; + namespaceName: string; + migrationKey: string; + checksum: string; + pluginVersion: string; + error: unknown; + }): Promise { + const message = input.error instanceof Error ? input.error.message : String(input.error); + await db + .insert(pluginMigrations) + .values({ + pluginId: input.pluginId, + pluginKey: input.pluginKey, + namespaceName: input.namespaceName, + migrationKey: input.migrationKey, + checksum: input.checksum, + pluginVersion: input.pluginVersion, + status: "failed", + errorMessage: message, + }) + .onConflictDoUpdate({ + target: [pluginMigrations.pluginId, pluginMigrations.migrationKey], + set: { + checksum: input.checksum, + pluginVersion: input.pluginVersion, + status: "failed", + errorMessage: message, + startedAt: new Date(), + appliedAt: null, + }, + }); + await db + .update(pluginDatabaseNamespaces) + .set({ status: "migration_failed", updatedAt: new Date() }) + .where(eq(pluginDatabaseNamespaces.pluginId, input.pluginId)); + } + + return { + ensureNamespace, + + async applyMigrations(pluginId: string, manifest: TaskcorePluginManifestV1, packageRoot: string) { + if (!manifest.database) return null; + const namespace = await ensureNamespace(pluginId, manifest); + if (!namespace) return null; + + const migrationDir = resolveMigrationsDir(packageRoot, manifest.database.migrationsDir); + const migrationFiles = await listSqlMigrationFiles(migrationDir); + const coreReadTables = manifest.database.coreReadTables ?? []; + const lockKey = Number.parseInt(createHash("sha256").update(pluginId).digest("hex").slice(0, 12), 16); + + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(${lockKey})`); + for (const migrationKey of migrationFiles) { + const content = await readFile(path.join(migrationDir, migrationKey), "utf8"); + const checksum = createHash("sha256").update(content).digest("hex"); + const existingRows = await tx + .select() + .from(pluginMigrations) + .where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.migrationKey, migrationKey))) + .limit(1); + const existing = existingRows[0] as PluginMigrationRecord | undefined; + if (existing?.status === "applied") { + if (existing.checksum !== checksum) { + throw new Error(`Plugin migration checksum mismatch for ${migrationKey}`); + } + continue; + } + + const statements = splitSqlStatements(content); + try { + if (statements.length === 0) { + throw new Error(`Plugin migration ${migrationKey} is empty`); + } + for (const statement of statements) { + validatePluginMigrationStatement(statement, namespace.namespaceName, coreReadTables); + await tx.execute(sql.raw(statement)); + } + await tx + .insert(pluginMigrations) + .values({ + pluginId, + pluginKey: manifest.id, + namespaceName: namespace.namespaceName, + migrationKey, + checksum, + pluginVersion: manifest.version, + status: "applied", + appliedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [pluginMigrations.pluginId, pluginMigrations.migrationKey], + set: { + checksum, + pluginVersion: manifest.version, + status: "applied", + errorMessage: null, + startedAt: new Date(), + appliedAt: new Date(), + }, + }); + } catch (error) { + await recordMigrationFailure({ + pluginId, + pluginKey: manifest.id, + namespaceName: namespace.namespaceName, + migrationKey, + checksum, + pluginVersion: manifest.version, + error, + }); + throw error; + } + } + }); + + return namespace; + }, + + getRuntimeNamespace, + + async query>(pluginId: string, statement: string, params?: unknown[]): Promise { + const plugin = await getPluginRecord(pluginId); + const namespace = await getRuntimeNamespace(pluginId); + validatePluginRuntimeQuery(statement, namespace, plugin.manifestJson.database?.coreReadTables ?? []); + const result = await db.execute(bindSql(statement, params)); + return Array.from(result as Iterable); + }, + + async execute(pluginId: string, statement: string, params?: unknown[]): Promise<{ rowCount: number }> { + const namespace = await getRuntimeNamespace(pluginId); + validatePluginRuntimeExecute(statement, namespace); + const result = await db.execute(bindSql(statement, params)); + return { rowCount: Number((result as { count?: number | string }).count ?? 0) }; + }, + }; +} diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 1e5ddee..ad1c9aa 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -1,6 +1,14 @@ import type { Db } from "@taskcore/db"; -import { pluginLogs, agentTaskSessions as agentTaskSessionsTable } from "@taskcore/db"; -import { eq, and, like, desc } from "drizzle-orm"; +import { + agentTaskSessions as agentTaskSessionsTable, + agents as agentsTable, + budgetIncidents, + costEvents, + heartbeatRuns, + issues as issuesTable, + pluginLogs, +} from "@taskcore/db"; +import { eq, and, like, desc, inArray, sql } from "drizzle-orm"; import type { HostServices, Company, @@ -10,14 +18,20 @@ import type { Goal, PluginWorkspace, IssueComment, + PluginIssueAssigneeSummary, + PluginIssueOrchestrationSummary, } from "@taskcore/plugin-sdk"; +import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@taskcore/shared"; import { companyService } from "./companies.js"; import { agentService } from "./agents.js"; import { projectService } from "./projects.js"; import { issueService } from "./issues.js"; +import { issueThreadInteractionService } from "./issue-thread-interactions.js"; import { goalService } from "./goals.js"; import { documentService } from "./documents.js"; import { heartbeatService } from "./heartbeat.js"; +import { budgetService } from "./budgets.js"; +import { issueApprovalService } from "./issue-approvals.js"; import { subscribeCompanyLiveEvents } from "./live-events.js"; import { randomUUID } from "node:crypto"; import { activityService } from "./activity.js"; @@ -25,6 +39,7 @@ import { costService } from "./costs.js"; import { assetService } from "./assets.js"; import { pluginRegistryService } from "./plugin-registry.js"; import { pluginStateStore } from "./plugin-state-store.js"; +import { pluginDatabaseService } from "./plugin-database.js"; import { createPluginSecretsHandler } from "./plugin-secrets-handler.js"; import { logActivity } from "./activity-log.js"; import type { PluginEventBus } from "./plugin-event-bus.js"; @@ -447,6 +462,7 @@ export function buildHostServices( ): HostServices & { dispose(): void } { const registry = pluginRegistryService(db); const stateStore = pluginStateStore(db); + const pluginDb = pluginDatabaseService(db); const secretsHandler = createPluginSecretsHandler({ db, pluginId }); const companies = companyService(db); const agents = agentService(db); @@ -457,6 +473,8 @@ export function buildHostServices( const goals = goalService(db); const activity = activityService(db); const costs = costService(db); + const budgets = budgetService(db); + const issueApprovals = issueApprovalService(db); const assets = assetService(db); const scopedBus = eventBus.forPlugin(pluginKey); @@ -494,7 +512,7 @@ export function buildHostServices( * required for company-scoped data access, but there is no per-company * availability gate to enforce here. */ - const ensurePluginAvailableForCompany = async (_companyId: string) => { }; + const ensurePluginAvailableForCompany = async (_companyId: string) => {}; const inCompany = ( record: T | null | undefined, @@ -512,6 +530,216 @@ export function buildHostServices( return record; }; + const pluginActivityDetails = ( + details: Record | null | undefined, + actor?: { actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null }, + ) => { + const initiatingActorType = actor?.actorAgentId ? "agent" : actor?.actorUserId ? "user" : null; + const initiatingActorId = actor?.actorAgentId ?? actor?.actorUserId ?? null; + return { + ...(details ?? {}), + sourcePluginId: pluginId, + sourcePluginKey: pluginKey, + initiatingActorType, + initiatingActorId, + initiatingAgentId: actor?.actorAgentId ?? null, + initiatingUserId: actor?.actorUserId ?? null, + initiatingRunId: actor?.actorRunId ?? null, + pluginId, + pluginKey, + }; + }; + + const defaultPluginOriginKind = `plugin:${pluginKey}`; + const normalizePluginOriginKind = (originKind: unknown = defaultPluginOriginKind) => { + if (originKind == null || originKind === "") return defaultPluginOriginKind; + if (typeof originKind !== "string") { + throw new Error("Plugin issue originKind must be a string"); + } + if (originKind === defaultPluginOriginKind || originKind.startsWith(`${defaultPluginOriginKind}:`)) { + return originKind; + } + throw new Error(`Plugin may only use originKind values under ${defaultPluginOriginKind}`); + }; + + const assertReadableOriginFilter = (originKind: unknown) => { + if (typeof originKind !== "string" || !originKind.startsWith("plugin:")) return; + normalizePluginOriginKind(originKind); + }; + + const logPluginActivity = async (input: { + companyId: string; + action: string; + entityType: string; + entityId: string; + details?: Record | null; + actor?: { actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null }; + }) => { + await logActivity(db, { + companyId: input.companyId, + actorType: "plugin", + actorId: pluginId, + agentId: input.actor?.actorAgentId ?? null, + runId: input.actor?.actorRunId ?? null, + action: input.action, + entityType: input.entityType, + entityId: input.entityId, + details: pluginActivityDetails(input.details, input.actor), + }); + }; + + const collectIssueSubtreeIds = async (companyId: string, rootIssueId: string) => { + const seen = new Set([rootIssueId]); + let frontier = [rootIssueId]; + + while (frontier.length > 0) { + const children = await db + .select({ id: issuesTable.id }) + .from(issuesTable) + .where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.parentId, frontier))); + frontier = children.map((child) => child.id).filter((id) => !seen.has(id)); + for (const id of frontier) seen.add(id); + } + + return [...seen]; + }; + + const getIssueRunSummaries = async ( + companyId: string, + issueIds: string[], + options: { activeOnly?: boolean } = {}, + ) => { + if (issueIds.length === 0) return []; + const issueIdExpr = sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`; + const statusCondition = options.activeOnly + ? inArray(heartbeatRuns.status, ["queued", "running"]) + : undefined; + const rows = await db + .select({ + id: heartbeatRuns.id, + issueId: issueIdExpr, + agentId: heartbeatRuns.agentId, + status: heartbeatRuns.status, + invocationSource: heartbeatRuns.invocationSource, + triggerDetail: heartbeatRuns.triggerDetail, + startedAt: heartbeatRuns.startedAt, + finishedAt: heartbeatRuns.finishedAt, + error: heartbeatRuns.error, + createdAt: heartbeatRuns.createdAt, + }) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.companyId, companyId), inArray(issueIdExpr, issueIds), statusCondition)) + .orderBy(desc(heartbeatRuns.createdAt)) + .limit(100); + + return rows.map((row) => ({ + ...row, + startedAt: row.startedAt?.toISOString() ?? null, + finishedAt: row.finishedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + })); + }; + + const setBlockedByWithActivity = async (params: { + issueId: string; + companyId: string; + blockedByIssueIds: string[]; + mutation: "set" | "add" | "remove"; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; + }) => { + const existing = requireInCompany("Issue", await issues.getById(params.issueId), params.companyId); + const previous = await issues.getRelationSummaries(params.issueId); + await issues.update(params.issueId, { + blockedByIssueIds: params.blockedByIssueIds, + actorAgentId: params.actorAgentId ?? null, + actorUserId: params.actorUserId ?? null, + } as any); + const relations = await issues.getRelationSummaries(params.issueId); + await logPluginActivity({ + companyId: params.companyId, + action: "issue.relations.updated", + entityType: "issue", + entityId: params.issueId, + actor: { + actorAgentId: params.actorAgentId, + actorUserId: params.actorUserId, + actorRunId: params.actorRunId, + }, + details: { + identifier: existing.identifier, + mutation: params.mutation, + blockedByIssueIds: params.blockedByIssueIds, + previousBlockedByIssueIds: previous.blockedBy.map((relation) => relation.id), + }, + }); + return relations; + }; + + const getIssueCostSummary = async ( + companyId: string, + issueIds: string[], + billingCode?: string | null, + ) => { + const scopeConditions = [ + issueIds.length > 0 ? inArray(costEvents.issueId, issueIds) : undefined, + billingCode ? eq(costEvents.billingCode, billingCode) : undefined, + ].filter((condition): condition is NonNullable => Boolean(condition)); + if (scopeConditions.length === 0) { + return { + costCents: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + billingCode: billingCode ?? null, + }; + } + const scopeCondition = scopeConditions.length === 1 ? scopeConditions[0]! : and(...scopeConditions); + const [row] = await db + .select({ + costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, + inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::double precision`, + cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::double precision`, + outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::double precision`, + }) + .from(costEvents) + .where(and(eq(costEvents.companyId, companyId), scopeCondition)); + + return { + costCents: Number(row?.costCents ?? 0), + inputTokens: Number(row?.inputTokens ?? 0), + cachedInputTokens: Number(row?.cachedInputTokens ?? 0), + outputTokens: Number(row?.outputTokens ?? 0), + billingCode: billingCode ?? null, + }; + }; + + const getOpenBudgetIncidents = async (companyId: string) => { + const rows = await db + .select({ + id: budgetIncidents.id, + scopeType: budgetIncidents.scopeType, + scopeId: budgetIncidents.scopeId, + metric: budgetIncidents.metric, + windowKind: budgetIncidents.windowKind, + thresholdType: budgetIncidents.thresholdType, + amountLimit: budgetIncidents.amountLimit, + amountObserved: budgetIncidents.amountObserved, + status: budgetIncidents.status, + approvalId: budgetIncidents.approvalId, + createdAt: budgetIncidents.createdAt, + }) + .from(budgetIncidents) + .where(and(eq(budgetIncidents.companyId, companyId), eq(budgetIncidents.status, "open"))) + .orderBy(desc(budgetIncidents.createdAt)); + + return rows.map((row) => ({ + ...row, + createdAt: row.createdAt.toISOString(), + })); + }; + return { config: { async get() { @@ -544,6 +772,18 @@ export function buildHostServices( }, }, + db: { + async namespace() { + return pluginDb.getRuntimeNamespace(pluginId); + }, + async query(params) { + return pluginDb.query(pluginId, params.sql, params.params); + }, + async execute(params) { + return pluginDb.execute(pluginId, params.sql, params.params); + }, + }, + entities: { async upsert(params) { return registry.upsertEntity(pluginId, params as any) as any; @@ -604,12 +844,12 @@ export function buildHostServices( await ensurePluginAvailableForCompany(companyId); await logActivity(db, { companyId, - actorType: "system", + actorType: "plugin", actorId: pluginId, action: params.message, entityType: params.entityType ?? "plugin", entityId: params.entityId ?? pluginId, - details: params.metadata, + details: pluginActivityDetails(params.metadata), }); }, }, @@ -775,6 +1015,7 @@ export function buildHostServices( async list(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); + assertReadableOriginFilter(params.originKind); return applyWindow((await issues.list(companyId, params as any)) as Issue[], params); }, async get(params) { @@ -786,13 +1027,456 @@ export function buildHostServices( async create(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - return (await issues.create(companyId, params as any)) as Issue; + const { actorAgentId, actorUserId, actorRunId, originKind, ...issueInput } = params; + const normalizedOriginKind = normalizePluginOriginKind(originKind); + const issue = (await issues.create(companyId, { + ...(issueInput as any), + originKind: normalizedOriginKind, + originId: params.originId ?? null, + originRunId: params.originRunId ?? actorRunId ?? null, + createdByAgentId: actorAgentId ?? null, + createdByUserId: actorUserId ?? null, + })) as Issue; + await logPluginActivity({ + companyId, + action: "issue.created", + entityType: "issue", + entityId: issue.id, + actor: { actorAgentId, actorUserId, actorRunId }, + details: { + title: issue.title, + identifier: issue.identifier, + originKind: normalizedOriginKind, + originId: issue.originId, + billingCode: issue.billingCode, + blockedByIssueIds: params.blockedByIssueIds ?? [], + }, + }); + return issue; }, async update(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const existing = requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const patch = { ...(params.patch as Record) }; + const actorAgentId = typeof patch.actorAgentId === "string" ? patch.actorAgentId : null; + const actorUserId = typeof patch.actorUserId === "string" ? patch.actorUserId : null; + const actorRunId = typeof patch.actorRunId === "string" ? patch.actorRunId : null; + delete patch.actorAgentId; + delete patch.actorUserId; + delete patch.actorRunId; + if (patch.originKind !== undefined) { + patch.originKind = normalizePluginOriginKind(patch.originKind); + } + const updated = (await issues.update(params.issueId, { + ...(patch as any), + actorAgentId, + actorUserId, + })) as Issue; + await logPluginActivity({ + companyId, + action: "issue.updated", + entityType: "issue", + entityId: updated.id, + actor: { actorAgentId, actorUserId, actorRunId }, + details: { + identifier: updated.identifier, + patch, + _previous: { + status: existing.status, + assigneeAgentId: existing.assigneeAgentId, + assigneeUserId: existing.assigneeUserId, + }, + }, + }); + return updated; + }, + async getRelations(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + return await issues.getRelationSummaries(params.issueId); + }, + async setBlockedBy(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return setBlockedByWithActivity({ + companyId, + issueId: params.issueId, + blockedByIssueIds: params.blockedByIssueIds, + mutation: "set", + actorAgentId: params.actorAgentId, + actorUserId: params.actorUserId, + actorRunId: params.actorRunId, + }); + }, + async addBlockers(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); requireInCompany("Issue", await issues.getById(params.issueId), companyId); - return (await issues.update(params.issueId, params.patch as any)) as Issue; + const previous = await issues.getRelationSummaries(params.issueId); + const nextBlockedByIssueIds = [ + ...new Set([ + ...previous.blockedBy.map((relation) => relation.id), + ...params.blockerIssueIds, + ]), + ]; + return setBlockedByWithActivity({ + companyId, + issueId: params.issueId, + blockedByIssueIds: nextBlockedByIssueIds, + mutation: "add", + actorAgentId: params.actorAgentId, + actorUserId: params.actorUserId, + actorRunId: params.actorRunId, + }); + }, + async removeBlockers(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const previous = await issues.getRelationSummaries(params.issueId); + const removals = new Set(params.blockerIssueIds); + const nextBlockedByIssueIds = previous.blockedBy + .map((relation) => relation.id) + .filter((issueId) => !removals.has(issueId)); + return setBlockedByWithActivity({ + companyId, + issueId: params.issueId, + blockedByIssueIds: nextBlockedByIssueIds, + mutation: "remove", + actorAgentId: params.actorAgentId, + actorUserId: params.actorUserId, + actorRunId: params.actorRunId, + }); + }, + async assertCheckoutOwner(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const ownership = await issues.assertCheckoutOwner( + params.issueId, + params.actorAgentId, + params.actorRunId, + ); + if (ownership.adoptedFromRunId) { + await logPluginActivity({ + companyId, + action: "issue.checkout_lock_adopted", + entityType: "issue", + entityId: params.issueId, + actor: { + actorAgentId: params.actorAgentId, + actorRunId: params.actorRunId, + }, + details: { + previousCheckoutRunId: ownership.adoptedFromRunId, + checkoutRunId: params.actorRunId, + reason: "stale_checkout_run", + }, + }); + } + return { + issueId: ownership.id, + status: ownership.status as Issue["status"], + assigneeAgentId: ownership.assigneeAgentId, + checkoutRunId: ownership.checkoutRunId, + adoptedFromRunId: ownership.adoptedFromRunId, + }; + }, + async getSubtree(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const rootIssue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const includeRoot = params.includeRoot !== false; + const subtreeIssueIds = await collectIssueSubtreeIds(companyId, rootIssue.id); + const issueIds = includeRoot ? subtreeIssueIds : subtreeIssueIds.filter((issueId) => issueId !== rootIssue.id); + const issueRows = issueIds.length > 0 + ? await db + .select() + .from(issuesTable) + .where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, issueIds))) + : []; + const issuesById = new Map(issueRows.map((issue) => [issue.id, issue as Issue])); + const outputIssues = issueIds + .map((issueId) => issuesById.get(issueId)) + .filter((issue): issue is Issue => Boolean(issue)); + + const assigneeAgentIds = [ + ...new Set(outputIssues.map((issue) => issue.assigneeAgentId).filter((id): id is string => Boolean(id))), + ]; + + const [relationPairs, documentPairs, activeRunRows, assigneeRows] = await Promise.all([ + params.includeRelations + ? Promise.all(issueIds.map(async (issueId) => [issueId, await issues.getRelationSummaries(issueId)] as const)) + : Promise.resolve(null), + params.includeDocuments + ? Promise.all( + issueIds.map(async (issueId) => { + const docs = await documents.listIssueDocuments(issueId); + const summaries: IssueDocumentSummary[] = docs.map((document) => { + const { body: _body, ...summary } = document as typeof document & { body?: string }; + return { ...summary, format: "markdown" as const }; + }); + return [ + issueId, + summaries, + ] as const; + }), + ) + : Promise.resolve(null), + params.includeActiveRuns + ? getIssueRunSummaries(companyId, issueIds, { activeOnly: true }) + : Promise.resolve(null), + params.includeAssignees && assigneeAgentIds.length > 0 + ? db + .select({ + id: agentsTable.id, + name: agentsTable.name, + role: agentsTable.role, + title: agentsTable.title, + status: agentsTable.status, + }) + .from(agentsTable) + .where(and(eq(agentsTable.companyId, companyId), inArray(agentsTable.id, assigneeAgentIds))) + : Promise.resolve(params.includeAssignees ? [] : null), + ]); + + const activeRuns = activeRunRows + ? Object.fromEntries(issueIds.map((issueId) => [ + issueId, + activeRunRows.filter((run) => run.issueId === issueId), + ])) + : undefined; + + return { + rootIssueId: rootIssue.id, + companyId, + issueIds, + issues: outputIssues, + ...(relationPairs ? { relations: Object.fromEntries(relationPairs) } : {}), + ...(documentPairs ? { documents: Object.fromEntries(documentPairs) } : {}), + ...(activeRuns ? { activeRuns } : {}), + ...(assigneeRows + ? { + assignees: Object.fromEntries(assigneeRows.map((agent) => [ + agent.id, + { ...agent, status: agent.status as Agent["status"] } as PluginIssueAssigneeSummary, + ])), + } + : {}), + }; + }, + async requestWakeup(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); + if (!issue.assigneeAgentId) { + throw new Error("Issue has no assigned agent to wake"); + } + if (["backlog", "done", "cancelled"].includes(issue.status)) { + throw new Error(`Issue is not wakeable in status: ${issue.status}`); + } + const relations = await issues.getRelationSummaries(issue.id); + const unresolvedBlockers = relations.blockedBy.filter((blocker) => blocker.status !== "done"); + if (unresolvedBlockers.length > 0) { + throw new Error("Issue is blocked by unresolved blockers"); + } + const budgetBlock = await budgets.getInvocationBlock(companyId, issue.assigneeAgentId, { + issueId: issue.id, + projectId: issue.projectId, + }); + if (budgetBlock) { + throw new Error(budgetBlock.reason); + } + const contextSource = params.contextSource ?? "plugin.issue.requestWakeup"; + const run = await heartbeat.wakeup(issue.assigneeAgentId, { + source: "assignment", + triggerDetail: "system", + reason: params.reason ?? "plugin_issue_wakeup_requested", + payload: { + issueId: issue.id, + mutation: "plugin_wakeup", + pluginId, + pluginKey, + contextSource, + }, + idempotencyKey: params.idempotencyKey ?? null, + requestedByActorType: "system", + requestedByActorId: pluginId, + contextSnapshot: { + issueId: issue.id, + taskId: issue.id, + wakeReason: params.reason ?? "plugin_issue_wakeup_requested", + source: contextSource, + pluginId, + pluginKey, + }, + }); + await logPluginActivity({ + companyId, + action: "issue.assignment_wakeup_requested", + entityType: "issue", + entityId: issue.id, + actor: { + actorAgentId: params.actorAgentId, + actorUserId: params.actorUserId, + actorRunId: params.actorRunId, + }, + details: { + identifier: issue.identifier, + assigneeAgentId: issue.assigneeAgentId, + runId: run?.id ?? null, + reason: params.reason ?? "plugin_issue_wakeup_requested", + contextSource, + }, + }); + return { queued: Boolean(run), runId: run?.id ?? null }; + }, + async requestWakeups(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const results = []; + for (const issueId of [...new Set(params.issueIds)]) { + const issue = requireInCompany("Issue", await issues.getById(issueId), companyId); + if (!issue.assigneeAgentId) { + throw new Error("Issue has no assigned agent to wake"); + } + if (["backlog", "done", "cancelled"].includes(issue.status)) { + throw new Error(`Issue is not wakeable in status: ${issue.status}`); + } + const relations = await issues.getRelationSummaries(issue.id); + const unresolvedBlockers = relations.blockedBy.filter((blocker) => blocker.status !== "done"); + if (unresolvedBlockers.length > 0) { + throw new Error("Issue is blocked by unresolved blockers"); + } + const budgetBlock = await budgets.getInvocationBlock(companyId, issue.assigneeAgentId, { + issueId: issue.id, + projectId: issue.projectId, + }); + if (budgetBlock) { + throw new Error(budgetBlock.reason); + } + const contextSource = params.contextSource ?? "plugin.issue.requestWakeups"; + const run = await heartbeat.wakeup(issue.assigneeAgentId, { + source: "assignment", + triggerDetail: "system", + reason: params.reason ?? "plugin_issue_wakeup_requested", + payload: { + issueId: issue.id, + mutation: "plugin_wakeup", + pluginId, + pluginKey, + contextSource, + }, + idempotencyKey: params.idempotencyKeyPrefix ? `${params.idempotencyKeyPrefix}:${issue.id}` : null, + requestedByActorType: "system", + requestedByActorId: pluginId, + contextSnapshot: { + issueId: issue.id, + taskId: issue.id, + wakeReason: params.reason ?? "plugin_issue_wakeup_requested", + source: contextSource, + pluginId, + pluginKey, + }, + }); + await logPluginActivity({ + companyId, + action: "issue.assignment_wakeup_requested", + entityType: "issue", + entityId: issue.id, + actor: { + actorAgentId: params.actorAgentId, + actorUserId: params.actorUserId, + actorRunId: params.actorRunId, + }, + details: { + identifier: issue.identifier, + assigneeAgentId: issue.assigneeAgentId, + runId: run?.id ?? null, + reason: params.reason ?? "plugin_issue_wakeup_requested", + contextSource, + }, + }); + results.push({ issueId: issue.id, queued: Boolean(run), runId: run?.id ?? null }); + } + return results; + }, + async getOrchestrationSummary(params): Promise { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const rootIssue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const subtreeIssueIds = params.includeSubtree + ? await collectIssueSubtreeIds(companyId, rootIssue.id) + : [rootIssue.id]; + const relationPairs = await Promise.all( + subtreeIssueIds.map(async (issueId) => [issueId, await issues.getRelationSummaries(issueId)] as const), + ); + const approvalRows = ( + await Promise.all( + subtreeIssueIds.map(async (issueId) => { + const rows = await issueApprovals.listApprovalsForIssue(issueId); + return rows.map((approval) => ({ + issueId, + id: approval.id, + type: approval.type, + status: approval.status, + requestedByAgentId: approval.requestedByAgentId, + requestedByUserId: approval.requestedByUserId, + decidedByUserId: approval.decidedByUserId, + decidedAt: approval.decidedAt?.toISOString() ?? null, + createdAt: approval.createdAt.toISOString(), + })); + }), + ) + ).flat(); + const [runs, costsSummary, openBudgetIncidents] = await Promise.all([ + getIssueRunSummaries(companyId, subtreeIssueIds), + getIssueCostSummary(companyId, subtreeIssueIds, params.billingCode ?? rootIssue.billingCode ?? null), + getOpenBudgetIncidents(companyId), + ]); + const issueRows = await db + .select({ + id: issuesTable.id, + assigneeAgentId: issuesTable.assigneeAgentId, + projectId: issuesTable.projectId, + }) + .from(issuesTable) + .where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, subtreeIssueIds))); + const invocationBlocks = ( + await Promise.all( + issueRows + .filter((issueRow) => issueRow.assigneeAgentId) + .map(async (issueRow) => { + const block = await budgets.getInvocationBlock(companyId, issueRow.assigneeAgentId!, { + issueId: issueRow.id, + projectId: issueRow.projectId, + }); + return block + ? { + issueId: issueRow.id, + agentId: issueRow.assigneeAgentId!, + scopeType: block.scopeType, + scopeId: block.scopeId, + scopeName: block.scopeName, + reason: block.reason, + } + : null; + }), + ) + ).filter((block): block is NonNullable => block !== null); + return { + issueId: rootIssue.id, + companyId, + subtreeIssueIds, + relations: Object.fromEntries(relationPairs), + approvals: approvalRows, + runs, + costs: costsSummary, + openBudgetIncidents, + invocationBlocks, + }; }, async listComments(params) { const companyId = ensureCompanyId(params.companyId); @@ -803,12 +1487,48 @@ export function buildHostServices( async createComment(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - requireInCompany("Issue", await issues.getById(params.issueId), companyId); - return (await issues.addComment( + const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const comment = (await issues.addComment( params.issueId, params.body, { agentId: params.authorAgentId }, )) as IssueComment; + await logPluginActivity({ + companyId, + action: "issue.comment.created", + entityType: "issue", + entityId: issue.id, + actor: { actorAgentId: params.authorAgentId ?? null }, + details: { + identifier: issue.identifier, + commentId: comment.id, + bodySnippet: comment.body.slice(0, 120), + }, + }); + return comment; + }, + async createInteraction(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const interaction = await issueThreadInteractionService(db).create(issue, params.interaction as CreateIssueThreadInteraction, { + agentId: params.authorAgentId ?? null, + }); + await logPluginActivity({ + companyId, + action: "issue.thread_interaction_created", + entityType: "issue", + entityId: issue.id, + actor: { actorAgentId: params.authorAgentId ?? null }, + details: { + identifier: issue.identifier, + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + continuationPolicy: interaction.continuationPolicy, + }, + }); + return interaction as any; }, }, @@ -830,7 +1550,7 @@ export function buildHostServices( async upsert(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); const result = await documents.upsertIssueDocument({ issueId: params.issueId, key: params.key, @@ -839,13 +1559,35 @@ export function buildHostServices( format: params.format ?? "markdown", changeSummary: params.changeSummary ?? null, }); + await logPluginActivity({ + companyId, + action: "issue.document_upserted", + entityType: "issue", + entityId: issue.id, + details: { + identifier: issue.identifier, + documentKey: params.key, + title: params.title ?? null, + format: params.format ?? "markdown", + }, + }); return result.document as any; }, async delete(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId); await documents.deleteIssueDocument(params.issueId, params.key); + await logPluginActivity({ + companyId, + action: "issue.document_deleted", + entityType: "issue", + entityId: issue.id, + details: { + identifier: issue.identifier, + documentKey: params.key, + }, + }); }, }, diff --git a/server/src/services/plugin-lifecycle.ts b/server/src/services/plugin-lifecycle.ts index f2b08b8..43d459d 100644 --- a/server/src/services/plugin-lifecycle.ts +++ b/server/src/services/plugin-lifecycle.ts @@ -495,7 +495,7 @@ export function pluginLifecycleManager( if (plugin.status !== "disabled" && plugin.status !== "error" && plugin.status !== "upgrade_pending") { throw badRequest( `Cannot enable plugin in status '${plugin.status}'. ` + - `Plugin must be in 'disabled', 'error', or 'upgrade_pending' status to be enabled.`, + `Plugin must be in 'disabled', 'error', or 'upgrade_pending' status to be enabled.`, ); } @@ -516,7 +516,7 @@ export function pluginLifecycleManager( if (plugin.status !== "ready") { throw badRequest( `Cannot disable plugin in status '${plugin.status}'. ` + - `Plugin must be in 'ready' status to be disabled.`, + `Plugin must be in 'ready' status to be disabled.`, ); } @@ -556,7 +556,7 @@ export function pluginLifecycleManager( } throw badRequest( `Plugin ${plugin.pluginKey} is already uninstalled. ` + - `Use removeData=true to permanently delete it.`, + `Use removeData=true to permanently delete it.`, ); } @@ -643,7 +643,7 @@ export function pluginLifecycleManager( if (plugin.status !== "ready" && plugin.status !== "upgrade_pending") { throw badRequest( `Cannot upgrade plugin in status '${plugin.status}'. ` + - `Plugin must be in 'ready' or 'upgrade_pending' status to be upgraded.`, + `Plugin must be in 'ready' or 'upgrade_pending' status to be upgraded.`, ); } @@ -716,7 +716,7 @@ export function pluginLifecycleManager( if (!workerManager) { throw badRequest( "Cannot start worker: no PluginWorkerManager is configured. " + - "Provide a workerManager option when constructing the lifecycle manager.", + "Provide a workerManager option when constructing the lifecycle manager.", ); } @@ -724,7 +724,7 @@ export function pluginLifecycleManager( if (plugin.status !== "ready") { throw badRequest( `Cannot start worker for plugin in status '${plugin.status}'. ` + - `Plugin must be in 'ready' status.`, + `Plugin must be in 'ready' status.`, ); } @@ -765,7 +765,7 @@ export function pluginLifecycleManager( if (plugin.status !== "ready") { throw badRequest( `Cannot restart worker for plugin in status '${plugin.status}'. ` + - `Plugin must be in 'ready' status.`, + `Plugin must be in 'ready' status.`, ); } diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index e69cefd..1681fe1 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -48,6 +48,7 @@ import type { PluginJobScheduler } from "./plugin-job-scheduler.js"; import type { PluginJobStore } from "./plugin-job-store.js"; import type { PluginToolDispatcher } from "./plugin-tool-dispatcher.js"; import type { PluginLifecycleManager } from "./plugin-lifecycle.js"; +import { pluginDatabaseService } from "./plugin-database.js"; const execFileAsync = promisify(execFile); const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -147,6 +148,9 @@ export interface PluginLoaderOptions { */ localPluginDir?: string; + /** Optional direct Postgres connection used for plugin DDL migrations. */ + migrationDb?: Db; + /** * Whether to scan the local filesystem directory for plugins. * Defaults to true. @@ -735,6 +739,7 @@ export function pluginLoader( ): PluginLoader { const { localPluginDir = DEFAULT_LOCAL_PLUGIN_DIR, + migrationDb = db, enableLocalFilesystem = true, enableNpmDiscovery = true, } = options; @@ -878,7 +883,7 @@ export function pluginLoader( if (!manifestValidator.getSupportedVersions().includes(manifest.apiVersion)) { throw new Error( `Plugin ${manifest.id} declares apiVersion ${manifest.apiVersion} which is not supported by this host. ` + - `Supported versions: ${manifestValidator.getSupportedVersions().join(", ")}`, + `Supported versions: ${manifestValidator.getSupportedVersions().join(", ")}`, ); } @@ -887,7 +892,7 @@ export function pluginLoader( if (!capResult.allowed) { throw new Error( `Plugin ${manifest.id} manifest has inconsistent capabilities. ` + - `Missing required capabilities for declared features: ${capResult.missing.join(", ")}`, + `Missing required capabilities for declared features: ${capResult.missing.join(", ")}`, ); } @@ -899,7 +904,7 @@ export function pluginLoader( if (compareSemver(hostVersion, minimumHostVersion) < 0) { throw new Error( `Plugin ${manifest.id} requires host version ${minimumHostVersion} or newer, ` + - `but this server is running ${hostVersion}`, + `but this server is running ${hostVersion}`, ); } } @@ -1351,8 +1356,8 @@ export function pluginLoader( ); throw new Error( `Upgrade for "${pluginId}" introduces new capabilities that require approval: ${escalated.join(", ")}. ` + - `The previous version declared [${[...oldCaps].join(", ")}]. ` + - `Please review and approve the capability escalation before upgrading.`, + `The previous version declared [${[...oldCaps].join(", ")}]. ` + + `Please review and approve the capability escalation before upgrading.`, ); } @@ -1456,7 +1461,7 @@ export function pluginLoader( if (!runtimeServices) { throw new Error( "Cannot loadAll: no PluginRuntimeServices provided. " + - "Pass runtime services as the third argument to pluginLoader().", + "Pass runtime services as the third argument to pluginLoader().", ); } @@ -1528,7 +1533,7 @@ export function pluginLoader( if (!runtimeServices) { throw new Error( "Cannot loadSingle: no PluginRuntimeServices provided. " + - "Pass runtime services as the third argument to pluginLoader().", + "Pass runtime services as the third argument to pluginLoader().", ); } @@ -1556,7 +1561,7 @@ export function pluginLoader( if (plugin.status !== "ready") { throw new Error( `Cannot load plugin in status '${plugin.status}'. ` + - `Plugin must be in 'installed' or 'ready' status.`, + `Plugin must be in 'installed' or 'ready' status.`, ); } @@ -1701,14 +1706,22 @@ export function pluginLoader( // 1. Resolve worker entrypoint // ------------------------------------------------------------------ const workerEntrypoint = resolveWorkerEntrypoint(plugin, localPluginDir); + const packageRoot = resolvePluginPackageRoot(plugin, localPluginDir); + + // ------------------------------------------------------------------ + // 2. Apply restricted database migrations before worker startup + // ------------------------------------------------------------------ + const databaseNamespace = manifest.database + ? (await pluginDatabaseService(migrationDb).applyMigrations(pluginId, manifest, packageRoot))?.namespaceName ?? null + : null; // ------------------------------------------------------------------ - // 2. Build host handlers for this plugin + // 3. Build host handlers for this plugin // ------------------------------------------------------------------ const hostHandlers = buildHostHandlers(pluginId, manifest); // ------------------------------------------------------------------ - // 3. Retrieve plugin config (if any) + // 4. Retrieve plugin config (if any) // ------------------------------------------------------------------ let config: Record = {}; try { @@ -1722,7 +1735,7 @@ export function pluginLoader( } // ------------------------------------------------------------------ - // 4. Spawn worker process + // 5. Spawn worker process // ------------------------------------------------------------------ const workerOptions: WorkerStartOptions = { entrypointPath: workerEntrypoint, @@ -1730,6 +1743,7 @@ export function pluginLoader( config, instanceInfo, apiVersion: manifest.apiVersion, + databaseNamespace, hostHandlers, autoRestart: true, }; @@ -1750,7 +1764,7 @@ export function pluginLoader( ); // ------------------------------------------------------------------ - // 5. Sync job declarations and register with scheduler + // 6. Sync job declarations and register with scheduler // ------------------------------------------------------------------ const jobDeclarations = manifest.jobs ?? []; if (jobDeclarations.length > 0) { @@ -1934,11 +1948,31 @@ function resolveWorkerEntrypoint( throw new Error( `Worker entrypoint not found for plugin "${plugin.pluginKey}". ` + - `Checked: ${path.resolve(packageDir, workerRelPath)}, ` + - `${path.resolve(directDir, workerRelPath)}`, + `Checked: ${path.resolve(packageDir, workerRelPath)}, ` + + `${path.resolve(directDir, workerRelPath)}`, ); } +function resolvePluginPackageRoot( + plugin: PluginRecord & { packagePath?: string | null }, + localPluginDir: string, +): string { + if (plugin.packagePath && existsSync(plugin.packagePath)) { + return path.resolve(plugin.packagePath); + } + + const packageName = plugin.packageName; + const packageDir = packageName.startsWith("@") + ? path.join(localPluginDir, "node_modules", ...packageName.split("/")) + : path.join(localPluginDir, "node_modules", packageName); + if (existsSync(packageDir)) return packageDir; + + const directDir = path.join(localPluginDir, packageName); + if (existsSync(directDir)) return directDir; + + throw new Error(`Package root not found for plugin "${plugin.pluginKey}"`); +} + function resolveManagedInstallPackageDir(localPluginDir: string, packageName: string): string { if (packageName.startsWith("@")) { return path.join(localPluginDir, "node_modules", ...packageName.split("/")); diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index f10e16a..b43dcff 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -166,6 +166,8 @@ export interface WorkerStartOptions { }; /** Host API version. */ apiVersion: number; + /** Host-derived plugin database namespace, when declared. */ + databaseNamespace?: string | null; /** Handlers for worker→host RPC calls. */ hostHandlers: WorkerToHostHandlers; /** Default timeout for RPC calls (ms). Defaults to 30s. */ @@ -828,6 +830,7 @@ export function createPluginWorkerHandle( config: options.config, instanceInfo: options.instanceInfo, apiVersion: options.apiVersion, + databaseNamespace: options.databaseNamespace ?? null, }; try { @@ -1066,7 +1069,8 @@ export function createPluginWorkerHandle( pendingRequests.delete(id); reject( new Error( - `Failed to send "${method}" to worker: ${err instanceof Error ? err.message : String(err) + `Failed to send "${method}" to worker: ${ + err instanceof Error ? err.message : String(err) }`, ), ); diff --git a/server/src/services/project-workspace-runtime-config.ts b/server/src/services/project-workspace-runtime-config.ts index 1c11b9b..f13d627 100644 --- a/server/src/services/project-workspace-runtime-config.ts +++ b/server/src/services/project-workspace-runtime-config.ts @@ -9,12 +9,14 @@ function cloneRecord(value: unknown): Record | null { } function readDesiredState(value: unknown): ProjectWorkspaceRuntimeConfig["desiredState"] { - return value === "running" || value === "stopped" ? value : null; + return value === "running" || value === "stopped" || value === "manual" ? value : null; } function readServiceStates(value: unknown): ProjectWorkspaceRuntimeConfig["serviceStates"] { if (!isRecord(value)) return null; - const entries = Object.entries(value).filter(([, state]) => state === "running" || state === "stopped"); + const entries = Object.entries(value).filter(([, state]) => + state === "running" || state === "stopped" || state === "manual" + ); if (entries.length === 0) return null; return Object.fromEntries(entries) as ProjectWorkspaceRuntimeConfig["serviceStates"]; } diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 1e806da..6766eaa 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -621,9 +621,9 @@ export function projectService(db: Db) { metadata: data.runtimeConfig !== undefined ? mergeProjectWorkspaceRuntimeConfig( - (data.metadata as Record | null | undefined) ?? null, - data.runtimeConfig ?? null, - ) + (data.metadata as Record | null | undefined) ?? null, + data.runtimeConfig ?? null, + ) : (data.metadata as Record | null | undefined) ?? null, isPrimary: shouldBePrimary, }) @@ -698,11 +698,11 @@ export function projectService(db: Db) { patch.metadata = data.runtimeConfig !== undefined ? mergeProjectWorkspaceRuntimeConfig( - data.metadata !== undefined - ? (data.metadata as Record | null | undefined) - : ((existing.metadata as Record | null | undefined) ?? null), - data.runtimeConfig ?? null, - ) + data.metadata !== undefined + ? (data.metadata as Record | null | undefined) + : ((existing.metadata as Record | null | undefined) ?? null), + data.runtimeConfig ?? null, + ) : data.metadata; } diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 5ce2cd1..f4a7607 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -4,6 +4,7 @@ import type { Db } from "@taskcore/db"; import { agents, companySecrets, + executionWorkspaces, goals, heartbeatRuns, issues, @@ -27,7 +28,9 @@ import type { UpdateRoutineTrigger, } from "@taskcore/shared"; import { + WORKSPACE_BRANCH_ROUTINE_VARIABLE, getBuiltinRoutineVariableValues, + extractRoutineVariableNames, interpolateRoutineTemplate, stringifyRoutineVariableValue, syncRoutineVariablesWithTemplate, @@ -44,7 +47,7 @@ import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "./is import { logActivity } from "./activity-log.js"; const OPEN_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"]; -const LIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running"]; +const LIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"]; const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); const MAX_CATCH_UP_RUNS = 25; const WEEKDAY_INDEX: Record = { @@ -269,15 +272,23 @@ function resolveRoutineVariableValues( source: "schedule" | "manual" | "api" | "webhook"; payload?: Record | null; variables?: Record | null; + automaticVariables?: Record; }, ) { if (variables.length === 0) return {} as Record; const provided = collectProvidedRoutineVariables(input.source, input.payload, input.variables); + const automaticVariables = input.automaticVariables ?? {}; const resolved: Record = {}; const missing: string[] = []; for (const variable of variables) { - const candidate = provided[variable.name] !== undefined ? provided[variable.name] : variable.defaultValue; + // Workspace-derived automatic values are authoritative for variables that + // Taskcore manages from execution context, so callers cannot override them. + const candidate = automaticVariables[variable.name] !== undefined + ? automaticVariables[variable.name] + : 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); @@ -309,6 +320,42 @@ function mergeRoutineRunPayload( }; } +function normalizeRoutineDispatchFingerprintValue(value: unknown): unknown { + if (value === undefined) return null; + if (value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value; + } + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) return value.map((item) => normalizeRoutineDispatchFingerprintValue(item)); + if (isPlainRecord(value)) { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, normalizeRoutineDispatchFingerprintValue(value[key])]), + ); + } + return String(value); +} + +function createRoutineDispatchFingerprint(input: { + payload: Record | null; + projectId: string | null; + assigneeAgentId: string | null; + executionWorkspaceId?: string | null; + executionWorkspacePreference?: string | null; + executionWorkspaceSettings?: Record | null; + title: string; + description: string | null; +}) { + const canonical = JSON.stringify(normalizeRoutineDispatchFingerprintValue(input)); + return crypto.createHash("sha256").update(canonical).digest("hex"); +} + +function routineUsesWorkspaceBranch(routine: typeof routines.$inferSelect) { + return (routine.variables ?? []).some((variable) => variable.name === WORKSPACE_BRANCH_ROUTINE_VARIABLE) + || extractRoutineVariableNames([routine.title, routine.description]).includes(WORKSPACE_BRANCH_ROUTINE_VARIABLE); +} + export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeupDeps } = {}) { const issueSvc = issueService(db); const secretsSvc = secretService(db); @@ -410,6 +457,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup triggeredAt: routineRuns.triggeredAt, idempotencyKey: routineRuns.idempotencyKey, triggerPayload: routineRuns.triggerPayload, + dispatchFingerprint: routineRuns.dispatchFingerprint, linkedIssueId: routineRuns.linkedIssueId, coalescedIntoRunId: routineRuns.coalescedIntoRunId, failureReason: routineRuns.failureReason, @@ -442,6 +490,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup triggeredAt: row.triggeredAt, idempotencyKey: row.idempotencyKey, triggerPayload: row.triggerPayload as Record | null, + dispatchFingerprint: row.dispatchFingerprint, linkedIssueId: row.linkedIssueId, coalescedIntoRunId: row.coalescedIntoRunId, failureReason: row.failureReason, @@ -590,7 +639,22 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup } } - async function findLiveExecutionIssue(routine: typeof routines.$inferSelect, executor: Db = db) { + function routineExecutionFingerprintCondition(dispatchFingerprint?: string | null) { + if (!dispatchFingerprint) return null; + // The "default" arm preserves coalescing against pre-migration open issues. + // It becomes inert once those legacy routine execution issues drain out. + return or( + eq(issues.originFingerprint, dispatchFingerprint), + eq(issues.originFingerprint, "default"), + ); + } + + async function findLiveExecutionIssue( + routine: typeof routines.$inferSelect, + executor: Db = db, + dispatchFingerprint?: string | null, + ) { + const fingerprintCondition = routineExecutionFingerprintCondition(dispatchFingerprint); const executionBoundIssue = await executor .select() .from(issues) @@ -608,6 +672,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup eq(issues.originId, routine.id), inArray(issues.status, OPEN_ISSUE_STATUSES), isNull(issues.hiddenAt), + ...(fingerprintCondition ? [fingerprintCondition] : []), ), ) .orderBy(desc(issues.updatedAt), desc(issues.createdAt)) @@ -633,6 +698,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup eq(issues.originId, routine.id), inArray(issues.status, OPEN_ISSUE_STATUSES), isNull(issues.hiddenAt), + ...(fingerprintCondition ? [fingerprintCondition] : []), ), ) .orderBy(desc(issues.updatedAt), desc(issues.createdAt)) @@ -701,11 +767,44 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup if (!assigneeAgentId) { throw unprocessable("Default agent required"); } - const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input); - const allVariables = { ...getBuiltinRoutineVariableValues(), ...resolvedVariables }; + const automaticVariables: Record = {}; + if (input.executionWorkspaceId && routineUsesWorkspaceBranch(input.routine)) { + const workspace = await db + .select({ + branchName: executionWorkspaces.branchName, + mode: executionWorkspaces.mode, + }) + .from(executionWorkspaces) + .where( + and( + eq(executionWorkspaces.id, input.executionWorkspaceId), + eq(executionWorkspaces.companyId, input.routine.companyId), + ), + ) + .then((rows) => rows[0] ?? null); + const branchName = workspace?.branchName?.trim(); + if (workspace && workspace.mode !== "shared_workspace" && branchName) { + automaticVariables[WORKSPACE_BRANCH_ROUTINE_VARIABLE] = branchName; + } + } + const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], { + ...input, + automaticVariables, + }); + const allVariables = { ...getBuiltinRoutineVariableValues(), ...automaticVariables, ...resolvedVariables }; const title = interpolateRoutineTemplate(input.routine.title, allVariables) ?? input.routine.title; const description = interpolateRoutineTemplate(input.routine.description, allVariables); - const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables); + const triggerPayload = mergeRoutineRunPayload(input.payload, { ...automaticVariables, ...resolvedVariables }); + const dispatchFingerprint = createRoutineDispatchFingerprint({ + payload: triggerPayload, + projectId, + assigneeAgentId, + executionWorkspaceId: input.executionWorkspaceId ?? null, + executionWorkspacePreference: input.executionWorkspacePreference ?? null, + executionWorkspaceSettings: input.executionWorkspaceSettings ?? null, + title, + description, + }); const run = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute( @@ -743,6 +842,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup triggeredAt, idempotencyKey: input.idempotencyKey ?? null, triggerPayload, + dispatchFingerprint, }) .returning(); @@ -752,7 +852,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup let createdIssue: Awaited> | null = null; try { - const activeIssue = await findLiveExecutionIssue(input.routine, txDb); + const activeIssue = await findLiveExecutionIssue(input.routine, txDb, dispatchFingerprint); if (activeIssue && input.routine.concurrencyPolicy !== "always_enqueue") { const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced"; const updated = await finalizeRun(createdRun.id, { @@ -785,6 +885,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup originKind: "routine_execution", originId: input.routine.id, originRunId: createdRun.id, + originFingerprint: dispatchFingerprint, executionWorkspaceId: input.executionWorkspaceId ?? null, executionWorkspacePreference: input.executionWorkspacePreference ?? null, executionWorkspaceSettings: input.executionWorkspaceSettings ?? null, @@ -801,7 +902,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup throw error; } - const existingIssue = await findLiveExecutionIssue(input.routine, txDb); + const existingIssue = await findLiveExecutionIssue(input.routine, txDb, dispatchFingerprint); if (!existingIssue) throw error; const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced"; const updated = await finalizeRun(createdRun.id, { @@ -921,6 +1022,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup kind: trigger.kind as RoutineListItem["triggers"][number]["kind"], label: trigger.label, enabled: trigger.enabled, + cronExpression: trigger.cronExpression, + timezone: trigger.timezone, nextRunAt: trigger.nextRunAt, lastFiredAt: trigger.lastFiredAt, lastResult: trigger.lastResult, @@ -953,6 +1056,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup triggeredAt: routineRuns.triggeredAt, idempotencyKey: routineRuns.idempotencyKey, triggerPayload: routineRuns.triggerPayload, + dispatchFingerprint: routineRuns.dispatchFingerprint, linkedIssueId: routineRuns.linkedIssueId, coalescedIntoRunId: routineRuns.coalescedIntoRunId, failureReason: routineRuns.failureReason, @@ -984,6 +1088,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup triggeredAt: run.triggeredAt, idempotencyKey: run.idempotencyKey, triggerPayload: run.triggerPayload as Record | null, + dispatchFingerprint: run.dispatchFingerprint, linkedIssueId: run.linkedIssueId, coalescedIntoRunId: run.coalescedIntoRunId, failureReason: run.failureReason, @@ -1396,6 +1501,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup triggeredAt: routineRuns.triggeredAt, idempotencyKey: routineRuns.idempotencyKey, triggerPayload: routineRuns.triggerPayload, + dispatchFingerprint: routineRuns.dispatchFingerprint, linkedIssueId: routineRuns.linkedIssueId, coalescedIntoRunId: routineRuns.coalescedIntoRunId, failureReason: routineRuns.failureReason, @@ -1427,6 +1533,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup triggeredAt: row.triggeredAt, idempotencyKey: row.idempotencyKey, triggerPayload: row.triggerPayload as Record | null, + dispatchFingerprint: row.dispatchFingerprint, linkedIssueId: row.linkedIssueId, coalescedIntoRunId: row.coalescedIntoRunId, failureReason: row.failureReason, diff --git a/server/src/services/run-continuations.ts b/server/src/services/run-continuations.ts new file mode 100644 index 0000000..0472963 --- /dev/null +++ b/server/src/services/run-continuations.ts @@ -0,0 +1,188 @@ +import { and, eq, inArray } from "drizzle-orm"; +import type { Db } from "@taskcore/db"; +import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@taskcore/db"; +import type { RunLivenessState } from "@taskcore/shared"; + +export const RUN_LIVENESS_CONTINUATION_REASON = "run_liveness_continuation"; +export const DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS = 2; + +const ACTIONABLE_LIVENESS_STATES = new Set(["plan_only", "empty_response"]); +const CONTINUATION_ACTIVE_ISSUE_STATUSES = new Set(["todo", "in_progress"]); +// A prior adapter error should not permanently suppress bounded liveness +// continuations; the max-attempt/idempotency guards prevent unbounded retries. +const CONTINUATION_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]); +const IDEMPOTENT_WAKE_STATUSES = ["queued", "deferred_issue_execution", "completed"]; + +type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect; +type IssueRow = Pick< + typeof issues.$inferSelect, + "id" | "companyId" | "identifier" | "title" | "status" | "assigneeAgentId" | "executionState" | "projectId" +>; +type AgentRow = Pick; + +export type RunContinuationDecision = + | { + kind: "enqueue"; + nextAttempt: number; + idempotencyKey: string; + payload: Record; + contextSnapshot: Record; + } + | { + kind: "exhausted"; + attempt: number; + maxAttempts: number; + comment: string; + } + | { + kind: "skip"; + reason: string; + }; + +export function readContinuationAttempt(value: unknown): number { + const numeric = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10); + return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 0; +} + +export function buildRunLivenessContinuationIdempotencyKey(input: { + issueId: string; + sourceRunId: string; + livenessState: RunLivenessState; + nextAttempt: number; +}) { + return [ + "run_liveness_continuation", + input.issueId, + input.sourceRunId, + input.livenessState, + String(input.nextAttempt), + ].join(":"); +} + +export async function findExistingRunLivenessContinuationWake( + db: Db, + input: { + companyId: string; + idempotencyKey: string; + }, +) { + return db + .select({ id: agentWakeupRequests.id, status: agentWakeupRequests.status }) + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, input.companyId), + eq(agentWakeupRequests.idempotencyKey, input.idempotencyKey), + inArray(agentWakeupRequests.status, IDEMPOTENT_WAKE_STATUSES), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); +} + +export function decideRunLivenessContinuation(input: { + run: HeartbeatRunRow; + issue: IssueRow | null; + agent: AgentRow | null; + livenessState: RunLivenessState | null; + livenessReason: string | null; + nextAction: string | null; + budgetBlocked: boolean; + idempotentWakeExists: boolean; + maxAttempts?: number; +}): RunContinuationDecision { + const { + run, + issue, + agent, + livenessState, + livenessReason, + nextAction, + budgetBlocked, + idempotentWakeExists, + } = input; + const maxAttempts = input.maxAttempts ?? DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS; + + if (!livenessState || !ACTIONABLE_LIVENESS_STATES.has(livenessState)) { + return { kind: "skip", reason: "liveness state is not actionable for continuation" }; + } + if (!issue) return { kind: "skip", reason: "issue not found" }; + if (!agent) return { kind: "skip", reason: "agent not found" }; + if (issue.companyId !== run.companyId || agent.companyId !== run.companyId) { + return { kind: "skip", reason: "company scope mismatch" }; + } + if (issue.assigneeAgentId !== run.agentId) { + return { kind: "skip", reason: "issue is no longer assigned to the source run agent" }; + } + if (!CONTINUATION_ACTIVE_ISSUE_STATUSES.has(issue.status)) { + return { kind: "skip", reason: `issue status ${issue.status} is not continuable` }; + } + if (issue.executionState) { + return { kind: "skip", reason: "issue is blocked by execution policy state" }; + } + if (!CONTINUATION_AGENT_STATUSES.has(agent.status)) { + return { kind: "skip", reason: `agent status ${agent.status} is not invokable` }; + } + if (budgetBlocked) { + return { kind: "skip", reason: "budget hard stop blocks continuation" }; + } + + const currentAttempt = readContinuationAttempt(run.continuationAttempt); + if (currentAttempt >= maxAttempts) { + return { + kind: "exhausted", + attempt: currentAttempt, + maxAttempts, + comment: [ + "Bounded liveness continuation exhausted", + "", + `- Last liveness state: \`${livenessState}\``, + `- Attempts used: ${currentAttempt}/${maxAttempts}`, + `- Reason: ${livenessReason ?? "Run ended without concrete progress"}`, + "- Next action: a human or manager should inspect the run and either clarify the task, mark it blocked, or assign a concrete follow-up.", + ].join("\n"), + }; + } + + const nextAttempt = currentAttempt + 1; + const idempotencyKey = buildRunLivenessContinuationIdempotencyKey({ + issueId: issue.id, + sourceRunId: run.id, + livenessState, + nextAttempt, + }); + if (idempotentWakeExists) { + return { kind: "skip", reason: "continuation wake already exists for this source run and attempt" }; + } + + const payload = { + issueId: issue.id, + sourceRunId: run.id, + livenessState, + livenessReason, + continuationAttempt: nextAttempt, + maxContinuationAttempts: maxAttempts, + instruction: + nextAction ?? + "The previous run ended without concrete progress. Take the first concrete action now or mark the issue blocked with a specific unblock request.", + }; + + return { + kind: "enqueue", + nextAttempt, + idempotencyKey, + payload, + contextSnapshot: { + issueId: issue.id, + taskId: issue.id, + taskKey: issue.id, + wakeReason: RUN_LIVENESS_CONTINUATION_REASON, + livenessContinuationAttempt: nextAttempt, + livenessContinuationMaxAttempts: maxAttempts, + livenessContinuationSourceRunId: run.id, + livenessContinuationState: livenessState, + livenessContinuationReason: livenessReason, + livenessContinuationInstruction: payload.instruction, + }, + }; +} diff --git a/server/src/services/run-liveness.ts b/server/src/services/run-liveness.ts new file mode 100644 index 0000000..6620501 --- /dev/null +++ b/server/src/services/run-liveness.ts @@ -0,0 +1,227 @@ +import type { HeartbeatRunStatus, IssueStatus, RunLivenessState } from "@taskcore/shared"; + +export interface RunLivenessIssueInput { + status: IssueStatus | string; + title: string; + description: string | null; +} + +export interface RunLivenessEvidenceInput { + issueCommentsCreated: number; + documentRevisionsCreated: number; + planDocumentRevisionsCreated: number; + workProductsCreated: number; + workspaceOperationsCreated: number; + activityEventsCreated: number; + toolOrActionEventsCreated: number; + latestEvidenceAt: Date | null; +} + +export interface RunLivenessClassificationInput { + runStatus: HeartbeatRunStatus | string; + issue: RunLivenessIssueInput | null; + resultJson?: Record | null; + stdoutExcerpt?: string | null; + stderrExcerpt?: string | null; + error?: string | null; + errorCode?: string | null; + continuationAttempt?: number | null; + evidence?: Partial | null; +} + +export interface RunLivenessClassification { + livenessState: RunLivenessState; + livenessReason: string; + continuationAttempt: number; + lastUsefulActionAt: Date | null; + nextAction: string | null; +} + +const DEFAULT_EVIDENCE: RunLivenessEvidenceInput = { + issueCommentsCreated: 0, + documentRevisionsCreated: 0, + planDocumentRevisionsCreated: 0, + workProductsCreated: 0, + workspaceOperationsCreated: 0, + activityEventsCreated: 0, + toolOrActionEventsCreated: 0, + latestEvidenceAt: null, +}; + +const PLANNING_ONLY_RE = + /\b(?:i(?:'ll| will| am going to|'m going to)|let me|i need to|next(?:,| i will| i'll)?|my next step is|the next step is)\s+(?:first\s+)?(?:inspect|check|review|look|investigate|analy[sz]e|open|read|start|begin|work on|implement|fix|test|update|create|add)\b/i; +const NEXT_STEPS_RE = /^\s*(?:next steps?|plan)\s*:/im; +const BLOCKER_RE = + /\b(?:blocked|can't proceed|cannot proceed|unable to proceed|waiting on|need(?:s|ed)? .{0,80}\b(?:approval|access|credential|credentials|secret|api key|token|input|clarification)|requires? .{0,80}\b(?:approval|access|credential|credentials|secret|api key|token|input|clarification))\b/i; +const NEGATED_BLOCKER_RE = /\b(?:not blocked|no blocker|no blockers|unblocked)\b/i; +const PLAN_TASK_TITLE_RE = /\b(?:plan|planning|analysis|investigation|research|report|proposal|design doc|write-?up)\b/i; +const PLAN_TASK_DESCRIPTION_RE = + /\b(?:create|write|produce|draft|update|revise|prepare)\s+(?:a\s+|the\s+)?(?:plan|analysis|investigation|research report|report|proposal|design doc|write-?up)\b/i; + +function compactReason(reason: string) { + return reason.length <= 500 ? reason : `${reason.slice(0, 497)}...`; +} + +function normalizeCount(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; +} + +function normalizeContinuationAttempt(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; +} + +function readText(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function resultText(resultJson: Record | null | undefined) { + if (!resultJson) return ""; + return [ + readText(resultJson.summary), + readText(resultJson.result), + readText(resultJson.message), + readText(resultJson.stdout), + readText(resultJson.stderr), + ] + .filter((value): value is string => Boolean(value)) + .join("\n"); +} + +function combinedOutput(input: RunLivenessClassificationInput) { + return [ + resultText(input.resultJson), + readText(input.stdoutExcerpt), + readText(input.stderrExcerpt), + readText(input.error), + ] + .filter((value): value is string => Boolean(value)) + .join("\n") + .trim(); +} + +export function hasUsefulOutput(input: RunLivenessClassificationInput) { + return combinedOutput(input).length > 0; +} + +export function declaredBlocker(input: RunLivenessClassificationInput) { + if (input.issue?.status === "blocked") return true; + const text = combinedOutput(input); + if (!text || NEGATED_BLOCKER_RE.test(text)) return false; + return BLOCKER_RE.test(text); +} + +export function looksLikePlanningOnly(input: RunLivenessClassificationInput) { + const text = combinedOutput(input); + if (!text) return false; + return PLANNING_ONLY_RE.test(text) || NEXT_STEPS_RE.test(text); +} + +export function isPlanningOrDocumentTask(issue: RunLivenessIssueInput | null | undefined) { + if (!issue) return false; + if (PLAN_TASK_TITLE_RE.test(issue.title)) return true; + return PLAN_TASK_DESCRIPTION_RE.test(issue.description ?? ""); +} + +function normalizeEvidence(evidence: Partial | null | undefined): RunLivenessEvidenceInput { + return { + issueCommentsCreated: normalizeCount(evidence?.issueCommentsCreated), + documentRevisionsCreated: normalizeCount(evidence?.documentRevisionsCreated), + planDocumentRevisionsCreated: normalizeCount(evidence?.planDocumentRevisionsCreated), + workProductsCreated: normalizeCount(evidence?.workProductsCreated), + workspaceOperationsCreated: normalizeCount(evidence?.workspaceOperationsCreated), + activityEventsCreated: normalizeCount(evidence?.activityEventsCreated), + toolOrActionEventsCreated: normalizeCount(evidence?.toolOrActionEventsCreated), + latestEvidenceAt: evidence?.latestEvidenceAt instanceof Date ? evidence.latestEvidenceAt : null, + }; +} + +export function hasConcreteActionEvidence(evidence: Partial | null | undefined) { + const normalized = normalizeEvidence(evidence); + // Workspace creation is setup evidence, not task progress by itself. It can + // appear in reasons alongside durable activity, but it must not prevent a + // planning-only or empty run from receiving a bounded continuation. + return ( + normalized.issueCommentsCreated + + normalized.documentRevisionsCreated + + normalized.workProductsCreated + + normalized.activityEventsCreated + + normalized.toolOrActionEventsCreated > + 0 + ); +} + +function evidenceReason(evidence: RunLivenessEvidenceInput) { + const parts: string[] = []; + if (evidence.issueCommentsCreated > 0) parts.push(`${evidence.issueCommentsCreated} issue comment(s)`); + if (evidence.documentRevisionsCreated > 0) parts.push(`${evidence.documentRevisionsCreated} document revision(s)`); + if (evidence.workProductsCreated > 0) parts.push(`${evidence.workProductsCreated} work product(s)`); + if (evidence.workspaceOperationsCreated > 0) parts.push(`${evidence.workspaceOperationsCreated} workspace operation(s)`); + if (evidence.activityEventsCreated > 0) parts.push(`${evidence.activityEventsCreated} activity event(s)`); + if (evidence.toolOrActionEventsCreated > 0) parts.push(`${evidence.toolOrActionEventsCreated} tool/action event(s)`); + return parts.join(", "); +} + +function extractNextAction(input: RunLivenessClassificationInput) { + const text = combinedOutput(input); + if (!text) return null; + const line = text + .split(/\r?\n/) + .map((entry) => entry.trim()) + .find((entry) => PLANNING_ONLY_RE.test(entry) || /^next(?: steps?| action)?\s*:/i.test(entry)); + if (!line) return null; + return line.length <= 500 ? line : `${line.slice(0, 497)}...`; +} + +export function classifyRunLiveness(input: RunLivenessClassificationInput): RunLivenessClassification { + const evidence = normalizeEvidence(input.evidence); + const continuationAttempt = normalizeContinuationAttempt(input.continuationAttempt); + const issueStatus = input.issue?.status ?? null; + const usefulOutput = hasUsefulOutput(input); + const concreteEvidence = hasConcreteActionEvidence(evidence); + const planExempt = isPlanningOrDocumentTask(input.issue) || evidence.planDocumentRevisionsCreated > 0; + const lastUsefulActionAt = concreteEvidence ? evidence.latestEvidenceAt : null; + + const output = (state: RunLivenessState, reason: string, nextAction: string | null = null): RunLivenessClassification => ({ + livenessState: state, + livenessReason: compactReason(reason), + continuationAttempt, + lastUsefulActionAt: state === "advanced" || state === "completed" || state === "blocked" ? lastUsefulActionAt : null, + nextAction, + }); + + if (input.runStatus !== "succeeded") { + return output("failed", input.errorCode ? `Run ended with ${input.runStatus} (${input.errorCode})` : `Run ended with ${input.runStatus}`); + } + + if (issueStatus === "done" || issueStatus === "cancelled") { + return output("completed", `Issue is ${issueStatus}`); + } + + if (declaredBlocker(input)) { + return output("blocked", issueStatus === "blocked" ? "Issue status is blocked" : "Run output declared a concrete blocker", extractNextAction(input)); + } + + if (!usefulOutput && !concreteEvidence) { + return output("empty_response", "Run succeeded without useful output or concrete action evidence"); + } + + if (concreteEvidence) { + return output("advanced", `Run produced concrete action evidence: ${evidenceReason(evidence)}`); + } + + if (planExempt && usefulOutput) { + return output("advanced", "Planning/document task produced useful output and is exempt from plan-only classification"); + } + + if (looksLikePlanningOnly(input)) { + return output("plan_only", "Run described future work without concrete action evidence", extractNextAction(input)); + } + + if (usefulOutput) { + return output("needs_followup", "Run produced useful output but no concrete action evidence", extractNextAction(input)); + } + + return output("empty_response", "Run succeeded without useful output"); +} diff --git a/server/src/services/workspace-operations.ts b/server/src/services/workspace-operations.ts index a701ab2..6274cc7 100644 --- a/server/src/services/workspace-operations.ts +++ b/server/src/services/workspace-operations.ts @@ -250,9 +250,9 @@ export function workspaceOperationService(db: Db) { store: operation.logStore, logRef: operation.logRef, ...result, - content: redactCurrentUserText(result.content, { - enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, - }), + // Workspace-operation log chunks are sanitized before append-time storage. + // Returning the stored chunk avoids another whole-string rewrite per poll. + content: result.content, }; }, }; diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index f92e8a0..4b6aeb7 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -413,32 +413,33 @@ function formatCommandForDisplay(command: string, args: string[]) { .join(" "); } +function trimToLastBytes(value: string, limit: number) { + const byteLength = Buffer.byteLength(value, "utf8"); + if (byteLength <= limit) return value; + return Buffer.from(value, "utf8").subarray(byteLength - limit).toString("utf8"); +} + function createProcessOutputCapture(maxBytes: number): ProcessOutputAccumulator { const limit = Math.max(1, Math.trunc(maxBytes)); - let chunks: string[] = []; + let text = ""; let truncated = false; let totalBytes = 0; return { append(chunk: string) { if (!chunk) return; - chunks.push(chunk); totalBytes += Buffer.byteLength(chunk, "utf8"); - let currentBytes = chunks.reduce((sum, value) => sum + Buffer.byteLength(value, "utf8"), 0); - if (currentBytes <= limit) return; + const combined = text + chunk; + if (Buffer.byteLength(combined, "utf8") <= limit) { + text = combined; + return; + } - const combined = Buffer.from(chunks.join(""), "utf8"); - const tail = combined.subarray(Math.max(0, combined.length - limit)).toString("utf8"); - chunks = [tail]; + text = trimToLastBytes(combined, limit); truncated = true; - currentBytes = Buffer.byteLength(tail, "utf8"); - if (currentBytes > limit) { - chunks = [Buffer.from(tail, "utf8").subarray(Math.max(0, currentBytes - limit)).toString("utf8")]; - } }, finish(): ProcessOutputCapture { - const text = chunks.join(""); if (!truncated) { return { text, @@ -800,11 +801,11 @@ async function recordGitOperation( metadata: result.stdoutTruncated || result.stderrTruncated ? { - stdoutTruncated: result.stdoutTruncated, - stderrTruncated: result.stderrTruncated, - stdoutBytes: result.stdoutBytes, - stderrBytes: result.stderrBytes, - } + stdoutTruncated: result.stdoutTruncated, + stderrTruncated: result.stderrTruncated, + stdoutBytes: result.stdoutBytes, + stderrBytes: result.stderrBytes, + } : null, }; }, @@ -867,11 +868,11 @@ async function recordWorkspaceCommandOperation( metadata: result.stdoutTruncated || result.stderrTruncated ? { - stdoutTruncated: result.stdoutTruncated, - stderrTruncated: result.stderrTruncated, - stdoutBytes: result.stdoutBytes, - stderrBytes: result.stderrBytes, - } + stdoutTruncated: result.stdoutTruncated, + stderrTruncated: result.stderrTruncated, + stdoutBytes: result.stdoutBytes, + stderrBytes: result.stderrBytes, + } : null, }; }, @@ -1232,7 +1233,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: { } await fs.mkdir(path.dirname(worktreePath), { recursive: true }); - await runGit(["worktree", "prune"], repoRoot).catch(() => { }); + await runGit(["worktree", "prune"], repoRoot).catch(() => {}); let created = false; try { @@ -1424,9 +1425,9 @@ export async function cleanupExecutionWorkspaceArtifacts(input: { const resolvedWorkspacePath = path.resolve(workspacePath); const containsProjectWorkspace = projectWorkspaceCwd ? ( - resolvedWorkspacePath === projectWorkspaceCwd || - projectWorkspaceCwd.startsWith(`${resolvedWorkspacePath}${path.sep}`) - ) + resolvedWorkspacePath === projectWorkspaceCwd || + projectWorkspaceCwd.startsWith(`${resolvedWorkspacePath}${path.sep}`) + ) : false; if (containsProjectWorkspace) { warnings.push(`Refusing to remove path "${workspacePath}" because it contains the project workspace.`); @@ -1567,18 +1568,18 @@ function resolveRuntimeServiceReuseIdentity(input: { const reuseKey = lifecycle === "shared" ? createHash("sha256") - .update( - stableStringify({ - scopeType: input.scopeType, - scopeId: input.scopeId, - serviceName, - command, - cwd: serviceCwd, - port: identityPort, - env: renderedEnv, - }), - ) - .digest("hex") + .update( + stableStringify({ + scopeType: input.scopeType, + scopeId: input.scopeId, + serviceName, + command, + cwd: serviceCwd, + port: identityPort, + env: renderedEnv, + }), + ) + .digest("hex") : null; return { @@ -1685,8 +1686,8 @@ function resolveServiceScopeId(input: { const scopeTypeRaw = asString(input.service.reuseScope, input.service.lifecycle === "shared" ? "project_workspace" : "run"); const scopeType = scopeTypeRaw === "project_workspace" || - scopeTypeRaw === "execution_workspace" || - scopeTypeRaw === "agent" + scopeTypeRaw === "execution_workspace" || + scopeTypeRaw === "agent" ? scopeTypeRaw : "run"; if (scopeType === "project_workspace") return { scopeType, scopeId: input.workspace.workspaceId ?? input.workspace.projectId }; @@ -2239,13 +2240,17 @@ function readConfiguredServiceStates(config: Record) { const raw = parseObject(config.serviceStates); const states: WorkspaceRuntimeServiceStateMap = {}; for (const [key, value] of Object.entries(raw)) { - if (value === "running" || value === "stopped") { + if (value === "running" || value === "stopped" || value === "manual") { states[key] = value; } } return states; } +function readDesiredRuntimeState(value: unknown): WorkspaceRuntimeDesiredState | null { + return value === "running" || value === "stopped" || value === "manual" ? value : null; +} + export function buildWorkspaceRuntimeDesiredStatePatch(input: { config: Record; currentDesiredState: WorkspaceRuntimeDesiredState | null; @@ -2257,7 +2262,7 @@ export function buildWorkspaceRuntimeDesiredStatePatch(input: { serviceStates: WorkspaceRuntimeServiceStateMap | null; } { const configuredServices = listConfiguredRuntimeServiceEntries(input.config); - const fallbackState: WorkspaceRuntimeDesiredState = input.currentDesiredState === "running" ? "running" : "stopped"; + const fallbackState: WorkspaceRuntimeDesiredState = readDesiredRuntimeState(input.currentDesiredState) ?? "stopped"; const nextServiceStates: WorkspaceRuntimeServiceStateMap = {}; for (let index = 0; index < configuredServices.length; index += 1) { @@ -2265,15 +2270,26 @@ export function buildWorkspaceRuntimeDesiredStatePatch(input: { } const nextState: WorkspaceRuntimeDesiredState = input.action === "stop" ? "stopped" : "running"; + const applyActionState = (index: number) => { + const key = String(index); + // Manual services are intentionally left under operator control even when + // an API action targets that individual service. + if (nextServiceStates[key] === "manual") return; + nextServiceStates[key] = nextState; + }; if (input.serviceIndex === undefined || input.serviceIndex === null) { for (let index = 0; index < configuredServices.length; index += 1) { - nextServiceStates[String(index)] = nextState; + applyActionState(index); } } else if (input.serviceIndex >= 0 && input.serviceIndex < configuredServices.length) { - nextServiceStates[String(input.serviceIndex)] = nextState; + applyActionState(input.serviceIndex); } - const desiredState = Object.values(nextServiceStates).some((state) => state === "running") ? "running" : "stopped"; + const desiredState = Object.values(nextServiceStates).some((state) => state === "running") + ? "running" + : Object.values(nextServiceStates).some((state) => state === "manual") + ? "manual" + : "stopped"; return { desiredState, @@ -2290,7 +2306,7 @@ function selectRuntimeServiceEntries(input: { }) { const entries = listConfiguredRuntimeServiceEntries(input.config); const states = input.serviceStates ?? readConfiguredServiceStates(input.config); - const fallbackState: WorkspaceRuntimeDesiredState = input.defaultDesiredState === "running" ? "running" : "stopped"; + const fallbackState: WorkspaceRuntimeDesiredState = readDesiredRuntimeState(input.defaultDesiredState) ?? "stopped"; return entries.filter((_, index) => { if (input.serviceIndex !== undefined && input.serviceIndex !== null) { @@ -2312,7 +2328,12 @@ export async function ensureRuntimeServicesForRun(input: { adapterEnv: Record; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; }): Promise { - const rawServices = readRuntimeServiceEntries(input.config); + const rawServices = selectRuntimeServiceEntries({ + config: input.config, + respectDesiredStates: true, + defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "running", + serviceStates: readConfiguredServiceStates(input.config), + }); const acquiredServiceIds: string[] = []; const refs: RuntimeServiceRef[] = []; runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds); @@ -2400,7 +2421,7 @@ export async function startRuntimeServicesForWorkspaceControl(input: { config: input.config, serviceIndex: input.serviceIndex, respectDesiredStates: input.respectDesiredStates, - defaultDesiredState: input.config.desiredState === "running" ? "running" : "stopped", + defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "stopped", serviceStates: readConfiguredServiceStates(input.config), }); const refs: RuntimeServiceRef[] = []; @@ -2565,10 +2586,10 @@ export async function stopRuntimeServicesForProjectWorkspace(input: { input.runtimeServiceId ? eq(workspaceRuntimeServices.id, input.runtimeServiceId) : and( - eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId), - eq(workspaceRuntimeServices.scopeType, "project_workspace"), - inArray(workspaceRuntimeServices.status, ["starting", "running"]), - ), + eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId), + eq(workspaceRuntimeServices.scopeType, "project_workspace"), + inArray(workspaceRuntimeServices.status, ["starting", "running"]), + ), ); } } @@ -2752,8 +2773,8 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) { const config = readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null); const inheritedRuntimeConfig = row.projectWorkspaceId ? readProjectWorkspaceRuntimeConfig( - (projectWorkspaceRowsById.get(row.projectWorkspaceId)?.metadata as Record | null) ?? null, - )?.workspaceRuntime ?? null + (projectWorkspaceRowsById.get(row.projectWorkspaceId)?.metadata as Record | null) ?? null, + )?.workspaceRuntime ?? null : null; const effectiveRuntimeConfig = config?.workspaceRuntime ?? inheritedRuntimeConfig; if (config?.desiredState !== "running" || !effectiveRuntimeConfig || !row.cwd) continue; @@ -2764,10 +2785,10 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) { actor: { id: null, name: "Taskcore", companyId: row.companyId }, issue: row.sourceIssueId ? { - id: row.sourceIssueId, - identifier: null, - title: row.name, - } + id: row.sourceIssueId, + identifier: null, + title: row.name, + } : null, workspace: { baseCwd: row.cwd, diff --git a/server/src/startup-banner.ts b/server/src/startup-banner.ts index 0bfc1b0..6adedd7 100644 --- a/server/src/startup-banner.ts +++ b/server/src/startup-banner.ts @@ -135,25 +135,19 @@ export function printStartupBanner(opts: StartupBannerOptions): void { : color("disabled", "yellow"); const art = [ - color(" ██████╗ ██╗ ██████╗ ██████╗ ██╗ ██╗██╗ █████╗ ██╗ ██╗███████╗███╗ ██╗", "cyan"), - color(" ██╔════╝ ██║ ██╔═══██╗██╔══██╗╚██╗ ██╔╝██║ ██╔══██╗██║ ██║██╔════╝████╗ ██║", "cyan"), - color(" ██║ ███╗██║ ██║ ██║██████╔╝ ╚████╔╝ ██║ ███████║██║ ██║███████╗██╔██╗ ██║", "cyan"), - color(" ██║ ██║██║ ██║ ██║██╔══██╗ ╚██╔╝ ██║ ██╔══██║██║ ██║╚════██║██║╚██╗██║", "cyan"), - color(" ╚██████╔╝███████╗╚██████╔╝██║ ██║ ██║ ███████╗██║ ██║███████╗██║███████║██║ ╚████║", "cyan"), - color(" ╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚═╝ ╚═══╝", "cyan"), - color(" ██████╗ ██████╗ ███╗ ███╗███╗ ███╗ █████╗ ███╗ ██╗██████╗ ", "magenta"), - color(" ██╔════╝██╔═══██╗████╗ ████║████╗ ████║██╔══██╗████╗ ██║██╔══██╗", "magenta"), - color(" ██║ ██║ ██║██╔████╔██║██╔████╔██║███████║██╔██╗ ██║██║ ██║", "magenta"), - color(" ██║ ██║ ██║██║╚██╔╝██║██║╚██╔╝██║██╔══██║██║╚██╗██║██║ ██║", "magenta"), - color(" ╚██████╗╚██████╔╝██║ ╚═╝ ██║██║ ╚═╝ ██║██║ ██║██║ ╚████║██████╔╝", "magenta"), - color(" ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═════╝ ", "magenta"), + color("██████╗ █████╗ ██████╗ ███████╗██████╗ ██████╗██╗ ██╗██████╗ ", "cyan"), + color("██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔════╝██║ ██║██╔══██╗", "cyan"), + color("██████╔╝███████║██████╔╝█████╗ ██████╔╝██║ ██║ ██║██████╔╝", "cyan"), + color("██╔═══╝ ██╔══██║██╔═══╝ ██╔══╝ ██╔══██╗██║ ██║ ██║██╔═══╝ ", "cyan"), + color("██║ ██║ ██║██║ ███████╗██║ ██║╚██████╗███████╗██║██║ ", "cyan"), + color("╚═╝ ╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝╚══════╝╚═╝╚═╝ ", "cyan"), ]; const lines = [ "", ...art, - color(" ╔══════════════════════════════════════════════════════════════════════════╗", "blue"), - row("Mode", `${dbMode} │ ${uiMode}`), + color(" ───────────────────────────────────────────────────────", "blue"), + row("Mode", `${dbMode} | ${uiMode}`), row("Deploy", `${opts.deploymentMode} (${opts.deploymentExposure})`), row("Bind", `${opts.bind} ${color(`(${opts.host})`, "dim")}`), row("Auth", opts.authReady ? color("ready", "green") : color("not-ready", "yellow")), @@ -173,10 +167,9 @@ export function printStartupBanner(opts: StartupBannerOptions): void { row("Backup Dir", opts.databaseBackupDir), row("Config", configPath), agentJwtSecret.status === "warn" - ? color(" ╠══════════════════════════════════════════════════════════════════════════╣", "yellow") + ? color(" ───────────────────────────────────────────────────────", "yellow") : null, - color(" ╚══════════════════════════════════════════════════════════════════════════╝", "blue"), - color(` ✦ Ready at ${new Date().toLocaleTimeString()} ✦`, "dim"), + color(" ───────────────────────────────────────────────────────", "blue"), "", ]; diff --git a/server/src/vite-html-renderer.ts b/server/src/vite-html-renderer.ts index 4834591..983a3ea 100644 --- a/server/src/vite-html-renderer.ts +++ b/server/src/vite-html-renderer.ts @@ -63,7 +63,7 @@ export function createCachedViteHtmlRenderer(opts: { function onWatchEvent(filePath: string): void { const resolvedPath = path.resolve(filePath); - if (resolvedPath === templatePath || resolvedPath.startsWith(`${uiRoot}${path.sep}`)) { + if (resolvedPath === templatePath) { invalidate(); } } diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts index 456ffb4..2da7681 100644 --- a/server/src/worktree-config.ts +++ b/server/src/worktree-config.ts @@ -118,13 +118,21 @@ function resolveWorktreeRuntimeContext( const configPath = resolveTaskcoreConfigPath(overrideConfigPath); const envPath = resolveTaskcoreEnvPath(configPath); + const persistedEnv = readEnvEntries(envPath); const worktreeRoot = path.resolve(path.dirname(configPath), ".."); - const worktreeName = nonEmpty(env.TASKCORE_WORKTREE_NAME) ?? path.basename(worktreeRoot); - const instanceId = nonEmpty(env.TASKCORE_INSTANCE_ID) ?? sanitizeWorktreeInstanceId(worktreeName); + const worktreeName = + nonEmpty(persistedEnv.TASKCORE_WORKTREE_NAME) ?? + nonEmpty(env.TASKCORE_WORKTREE_NAME) ?? + path.basename(worktreeRoot); + const instanceId = + nonEmpty(persistedEnv.TASKCORE_INSTANCE_ID) ?? + nonEmpty(env.TASKCORE_INSTANCE_ID) ?? + sanitizeWorktreeInstanceId(worktreeName); const homeDir = resolveHomeAwarePath( - nonEmpty(env.TASKCORE_HOME) ?? - nonEmpty(env.TASKCORE_WORKTREES_DIR) ?? - "~/.taskcore-worktrees", + nonEmpty(persistedEnv.TASKCORE_HOME) ?? + nonEmpty(env.TASKCORE_HOME) ?? + nonEmpty(env.TASKCORE_WORKTREES_DIR) ?? + "~/.taskcore-worktrees", ); const instanceRoot = path.resolve(homeDir, "instances", instanceId); @@ -238,13 +246,13 @@ function buildIsolatedWorktreeConfig( ...config.database, ...(config.database.mode === "embedded-postgres" ? { - embeddedPostgresDataDir: context.embeddedPostgresDataDir, - embeddedPostgresPort: databasePort ?? config.database.embeddedPostgresPort, - backup: { - ...config.database.backup, - dir: context.backupDir, - }, - } + embeddedPostgresDataDir: context.embeddedPostgresDataDir, + embeddedPostgresPort: databasePort ?? config.database.embeddedPostgresPort, + backup: { + ...config.database.backup, + dir: context.backupDir, + }, + } : {}), }, server: { @@ -396,11 +404,11 @@ export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): { const selectedDatabasePort = parsed.database.mode === "embedded-postgres" ? findNextUnclaimedPort( - parsed.database.embeddedPostgresPort === 54329 - ? 54330 - : parsed.database.embeddedPostgresPort, - new Set([...siblingPorts.databasePorts, selectedServerPort]), - ) + parsed.database.embeddedPostgresPort === 54329 + ? 54330 + : parsed.database.embeddedPostgresPort, + new Set([...siblingPorts.databasePorts, selectedServerPort]), + ) : undefined; writeConfigFile( From 01731b4eacff35d5eaf8f4bf4ed06eab442d15f0 Mon Sep 17 00:00:00 2001 From: NeoPilot <221231603+neopilotai@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:08:54 +0000 Subject: [PATCH 06/13] =?UTF-8?q?=F0=9F=92=84=20ui:=20update=20React=20com?= =?UTF-8?q?ponents=20and=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/README.md | 9 + ui/index.html | 94 +- ui/package.json | 10 +- ui/public/site.webmanifest | 2 +- ui/src/App.test.tsx | 146 ++ ui/src/App.tsx | 94 +- ui/src/adapters/codex-local/config-fields.tsx | 16 +- ui/src/adapters/dynamic-loader.ts | 303 +++- ui/src/adapters/hermes-local/index.ts | 4 +- ui/src/adapters/registry.ts | 4 +- .../adapters/sandboxed-parser-worker.test.ts | 25 + ui/src/adapters/sandboxed-parser-worker.ts | 182 +++ ui/src/adapters/schema-config-fields.tsx | 5 +- ui/src/adapters/use-adapter-capabilities.ts | 54 + ui/src/api/access.ts | 256 +++- ui/src/api/activity.ts | 17 +- ui/src/api/adapters.ts | 8 + ui/src/api/agents.ts | 1 + ui/src/api/auth.ts | 114 +- ui/src/api/companies.ts | 7 +- ui/src/api/execution-workspaces.test.ts | 29 + ui/src/api/execution-workspaces.ts | 24 +- ui/src/api/heartbeats.ts | 18 + ui/src/api/issues.test.ts | 8 + ui/src/api/issues.ts | 29 +- ui/src/api/userProfiles.ts | 9 + ui/src/components/ActiveAgentsPanel.tsx | 33 +- ui/src/components/ActivityCharts.test.tsx | 105 ++ ui/src/components/ActivityCharts.tsx | 54 +- ui/src/components/ActivityRow.tsx | 39 +- ui/src/components/AgentConfigForm.tsx | 473 +++--- ui/src/components/ArtifactPreviewModal.tsx | 161 -- ui/src/components/BudgetIncidentCard.tsx | 19 +- ui/src/components/BudgetSidebarMarker.tsx | 30 +- ui/src/components/CloudAccessGate.tsx | 114 ++ ui/src/components/CommentThread.test.tsx | 16 +- ui/src/components/CommentThread.tsx | 22 +- ui/src/components/CompanyPatternIcon.tsx | 7 +- ui/src/components/CompanyRail.tsx | 5 +- .../CompanySettingsSidebar.test.tsx | 137 ++ ui/src/components/CompanySettingsSidebar.tsx | 69 + ui/src/components/CompanySwitcher.tsx | 13 +- ui/src/components/CopyText.tsx | 21 +- .../components/ExecutionParticipantPicker.tsx | 39 +- .../ExecutionWorkspaceCloseDialog.tsx | 74 +- ui/src/components/HermesIcon.tsx | 86 +- ui/src/components/Identity.tsx | 4 +- ui/src/components/InlineEditor.test.tsx | 56 + ui/src/components/InlineEditor.tsx | 11 +- ui/src/components/InlineEntitySelector.tsx | 11 +- ui/src/components/InstanceSidebar.tsx | 4 +- ui/src/components/IssueArtifactList.tsx | 112 -- ui/src/components/IssueChatThread.test.tsx | 443 +++++- ui/src/components/IssueChatThread.tsx | 766 +++++++--- ui/src/components/IssueColumns.tsx | 15 +- .../IssueContinuationHandoff.test.tsx | 107 ++ .../components/IssueContinuationHandoff.tsx | 101 ++ .../components/IssueDocumentsSection.test.tsx | 45 + ui/src/components/IssueDocumentsSection.tsx | 52 +- ui/src/components/IssueFileBrowser.tsx | 100 -- ui/src/components/IssueFiltersPopover.tsx | 106 +- ui/src/components/IssueLinkQuicklook.tsx | 8 +- ui/src/components/IssueProperties.test.tsx | 489 ++++++- ui/src/components/IssueProperties.tsx | 568 +++++-- .../IssueReferenceActivitySummary.tsx | 73 + ui/src/components/IssueReferencePill.tsx | 55 + .../components/IssueRelatedWorkPanel.test.tsx | 96 ++ ui/src/components/IssueRelatedWorkPanel.tsx | 110 ++ ui/src/components/IssueRunLedger.test.tsx | 305 ++++ ui/src/components/IssueRunLedger.tsx | 480 ++++++ .../IssueThreadInteractionCard.test.tsx | 258 ++++ .../components/IssueThreadInteractionCard.tsx | 1268 ++++++++++++++++ ui/src/components/IssueWorkspaceCard.test.tsx | 4 +- ui/src/components/IssueWorkspaceCard.tsx | 21 +- ui/src/components/IssuesList.test.tsx | 245 +++- ui/src/components/IssuesList.tsx | 234 ++- ui/src/components/KanbanBoard.tsx | 10 +- .../KeyboardShortcutsCheatsheet.tsx | 76 +- ui/src/components/Layout.test.tsx | 249 ++++ ui/src/components/Layout.tsx | 280 ++-- ui/src/components/MarkdownBody.test.tsx | 112 +- ui/src/components/MarkdownBody.tsx | 120 +- ui/src/components/MarkdownEditor.test.tsx | 35 +- ui/src/components/MarkdownEditor.tsx | 113 +- ui/src/components/NewIssueDialog.tsx | 843 +++++------ ui/src/components/NewProjectDialog.tsx | 28 +- ui/src/components/OnboardingInterview.tsx | 113 -- ui/src/components/OnboardingWizard.tsx | 241 ++- ui/src/components/PackageFileTree.tsx | 12 +- ui/src/components/ProjectProperties.tsx | 6 +- .../ProjectWorkspaceSummaryCard.test.tsx | 108 +- .../ProjectWorkspaceSummaryCard.tsx | 51 +- .../components/ProjectWorkspacesContent.tsx | 119 ++ .../RoutineRunVariablesDialog.test.tsx | 114 +- .../components/RoutineRunVariablesDialog.tsx | 52 +- ui/src/components/RunChatSurface.tsx | 22 +- ui/src/components/Sidebar.test.tsx | 153 ++ ui/src/components/Sidebar.tsx | 21 +- ui/src/components/SidebarAccountMenu.test.tsx | 117 ++ ui/src/components/SidebarAccountMenu.tsx | 256 ++++ ui/src/components/SidebarCompanyMenu.test.tsx | 125 ++ ui/src/components/SidebarCompanyMenu.tsx | 109 ++ ui/src/components/StatusBadge.tsx | 2 +- ui/src/components/TaskcoreIcon.tsx | 18 - .../WorkspaceRuntimeControls.test.tsx | 180 +++ .../components/WorkspaceRuntimeControls.tsx | 26 +- .../access/CompanySettingsNav.test.tsx | 99 ++ .../components/access/CompanySettingsNav.tsx | 46 + ui/src/components/access/ModeBadge.tsx | 19 + ui/src/components/agent-config-primitives.tsx | 20 +- .../transcript/RunTranscriptView.test.tsx | 8 +- .../transcript/RunTranscriptView.tsx | 158 +- .../transcript/useLiveRunTranscripts.test.tsx | 96 +- .../transcript/useLiveRunTranscripts.ts | 70 +- ui/src/components/ui/avatar.tsx | 2 +- ui/src/components/ui/command.tsx | 2 +- ui/src/components/ui/select.tsx | 2 +- ui/src/context/BreadcrumbContext.test.tsx | 61 + ui/src/context/BreadcrumbContext.tsx | 13 +- ui/src/context/LiveUpdatesProvider.test.ts | 164 ++- ui/src/context/LiveUpdatesProvider.tsx | 63 +- .../issueThreadInteractionFixtures.ts | 537 +++++++ ui/src/hooks/useInboxBadge.ts | 11 +- ui/src/hooks/useTaskcoreIssueRuntime.test.tsx | 6 +- ui/src/hooks/useTaskcoreIssueRuntime.ts | 12 +- ui/src/index.css | 16 +- ui/src/lib/activity-format.ts | 30 +- ui/src/lib/agent-config-patch.ts | 20 +- ui/src/lib/assignees.ts | 7 + ui/src/lib/company-members.test.ts | 105 ++ ui/src/lib/company-members.ts | 116 ++ ui/src/lib/company-routes.test.ts | 32 - ui/src/lib/company-routes.ts | 2 + ui/src/lib/inbox.test.ts | 218 ++- ui/src/lib/inbox.ts | 164 ++- ui/src/lib/invite-memory.ts | 36 + ui/src/lib/issue-chat-messages.test.ts | 112 ++ ui/src/lib/issue-chat-messages.ts | 80 +- ui/src/lib/issue-filters.test.ts | 69 + ui/src/lib/issue-filters.ts | 40 +- ui/src/lib/issue-properties-panel-key.test.ts | 47 + ui/src/lib/issue-properties-panel-key.ts | 42 +- ui/src/lib/issue-reference.test.ts | 17 + ui/src/lib/issue-reference.ts | 8 +- ui/src/lib/issue-thread-interactions.test.ts | 150 ++ ui/src/lib/issue-thread-interactions.ts | 140 ++ ui/src/lib/issueDetailCache.ts | 14 + ui/src/lib/issueDetailQuery.test.tsx | 129 ++ ui/src/lib/liveIssueIds.test.ts | 64 + ui/src/lib/liveIssueIds.ts | 9 + ui/src/lib/mention-chips.ts | 58 +- ui/src/lib/new-agent-runtime-config.test.ts | 4 +- ui/src/lib/new-agent-runtime-config.ts | 3 +- ui/src/lib/optimistic-issue-comments.test.ts | 69 + ui/src/lib/optimistic-issue-comments.ts | 33 +- ui/src/lib/optimistic-issue-runs.test.ts | 32 +- ui/src/lib/optimistic-issue-runs.ts | 15 + ui/src/lib/project-workspaces-tab.test.ts | 94 +- ui/src/lib/project-workspaces-tab.ts | 140 +- ui/src/lib/queryKeys.ts | 13 + ui/src/lib/recent-assignees.ts | 51 +- ui/src/lib/recent-projects.ts | 14 + ui/src/lib/recent-selections.test.ts | 79 + ui/src/lib/recent-selections.ts | 50 + ui/src/lib/router.tsx | 7 +- ui/src/lib/runRetryState.test.ts | 42 + ui/src/lib/runRetryState.ts | 93 ++ ui/src/lib/status-colors.ts | 1 + ui/src/lib/utils.ts | 7 +- ui/src/lib/vite-watch.test.ts | 29 + ui/src/lib/vite-watch.ts | 29 + ui/src/pages/Activity.tsx | 72 +- ui/src/pages/AdapterManager.tsx | 10 +- ui/src/pages/AgentDetail.tsx | 241 +-- ui/src/pages/Agents.tsx | 4 +- ui/src/pages/ApprovalDetail.tsx | 24 +- ui/src/pages/Auth.tsx | 6 +- ui/src/pages/CompanyAccess.test.tsx | 385 +++++ ui/src/pages/CompanyAccess.tsx | 677 +++++++++ ui/src/pages/CompanyExport.tsx | 30 +- ui/src/pages/CompanyImport.tsx | 18 +- ui/src/pages/CompanyInvites.test.tsx | 267 ++++ ui/src/pages/CompanyInvites.tsx | 374 +++++ ui/src/pages/CompanySettings.tsx | 10 +- ui/src/pages/CompanySkills.tsx | 6 +- ui/src/pages/Costs.tsx | 142 +- ui/src/pages/Dashboard.tsx | 25 +- ui/src/pages/DesignGuide.tsx | 28 +- ui/src/pages/ExecutionWorkspaceDetail.tsx | 451 +++--- ui/src/pages/Inbox.test.tsx | 78 +- ui/src/pages/Inbox.tsx | 603 +++++--- ui/src/pages/InstanceAccess.tsx | 249 ++++ ui/src/pages/InstanceGeneralSettings.tsx | 60 +- ui/src/pages/InviteLanding.test.tsx | 657 +++++++++ ui/src/pages/InviteLanding.tsx | 893 ++++++++--- ui/src/pages/InviteUxLab.test.tsx | 52 + ui/src/pages/InviteUxLab.tsx | 927 ++++++++++++ ui/src/pages/IssueDetail.tsx | 957 +++++++----- ui/src/pages/Issues.test.tsx | 16 + ui/src/pages/Issues.tsx | 53 +- ui/src/pages/JoinRequestQueue.tsx | 194 +++ ui/src/pages/OrgChart.test.tsx | 265 ++++ ui/src/pages/OrgChart.tsx | 257 +++- ui/src/pages/PluginManager.tsx | 4 +- ui/src/pages/ProfileSettings.test.tsx | 133 ++ ui/src/pages/ProfileSettings.tsx | 273 ++++ ui/src/pages/ProjectDetail.tsx | 124 +- ui/src/pages/ProjectWorkspaceDetail.tsx | 2 +- ui/src/pages/RoutineDetail.tsx | 30 +- ui/src/pages/Routines.tsx | 30 +- ui/src/pages/RunTranscriptUxLab.tsx | 44 +- ui/src/pages/UserProfile.tsx | 359 +++++ ui/src/pages/Workspaces.tsx | 163 +++ ui/src/plugins/bridge.ts | 14 +- ui/storybook/.gitignore | 1 + ui/storybook/.storybook/main.ts | 32 + ui/storybook/.storybook/preview.tsx | 271 ++++ ui/storybook/.storybook/styles.css | 49 + ui/storybook/.storybook/tailwind-entry.css | 2 + ui/storybook/fixtures/taskcoreData.ts | 1301 +++++++++++++++++ .../stories/agent-management.stories.tsx | 757 ++++++++++ .../stories/budget-finance.stories.tsx | 774 ++++++++++ .../stories/chat-comments.stories.tsx | 713 +++++++++ .../control-plane-surfaces.stories.tsx | 266 ++++ .../stories/data-viz-misc.stories.tsx | 761 ++++++++++ .../stories/dialogs-modals.stories.tsx | 836 +++++++++++ .../stories/forms-editors.stories.tsx | 712 +++++++++ ui/storybook/stories/foundations.stories.tsx | 300 ++++ .../stories/issue-management.stories.tsx | 601 ++++++++ .../issue-thread-interactions.stories.tsx | 681 +++++++++ .../stories/navigation-layout.stories.tsx | 360 +++++ ui/storybook/stories/overview.stories.tsx | 203 +++ .../projects-goals-workspaces.stories.tsx | 516 +++++++ .../stories/status-language.stories.tsx | 211 +++ ui/storybook/stories/ux-labs.stories.tsx | 79 + ui/tsconfig.json | 2 +- ui/vite.config.ts | 18 +- 237 files changed, 30950 insertions(+), 4465 deletions(-) create mode 100644 ui/src/App.test.tsx create mode 100644 ui/src/adapters/sandboxed-parser-worker.test.ts create mode 100644 ui/src/adapters/sandboxed-parser-worker.ts create mode 100644 ui/src/adapters/use-adapter-capabilities.ts create mode 100644 ui/src/api/execution-workspaces.test.ts create mode 100644 ui/src/api/userProfiles.ts create mode 100644 ui/src/components/ActivityCharts.test.tsx delete mode 100644 ui/src/components/ArtifactPreviewModal.tsx create mode 100644 ui/src/components/CloudAccessGate.tsx create mode 100644 ui/src/components/CompanySettingsSidebar.test.tsx create mode 100644 ui/src/components/CompanySettingsSidebar.tsx delete mode 100644 ui/src/components/IssueArtifactList.tsx create mode 100644 ui/src/components/IssueContinuationHandoff.test.tsx create mode 100644 ui/src/components/IssueContinuationHandoff.tsx delete mode 100644 ui/src/components/IssueFileBrowser.tsx create mode 100644 ui/src/components/IssueReferenceActivitySummary.tsx create mode 100644 ui/src/components/IssueReferencePill.tsx create mode 100644 ui/src/components/IssueRelatedWorkPanel.test.tsx create mode 100644 ui/src/components/IssueRelatedWorkPanel.tsx create mode 100644 ui/src/components/IssueRunLedger.test.tsx create mode 100644 ui/src/components/IssueRunLedger.tsx create mode 100644 ui/src/components/IssueThreadInteractionCard.test.tsx create mode 100644 ui/src/components/IssueThreadInteractionCard.tsx create mode 100644 ui/src/components/Layout.test.tsx delete mode 100644 ui/src/components/OnboardingInterview.tsx create mode 100644 ui/src/components/ProjectWorkspacesContent.tsx create mode 100644 ui/src/components/Sidebar.test.tsx create mode 100644 ui/src/components/SidebarAccountMenu.test.tsx create mode 100644 ui/src/components/SidebarAccountMenu.tsx create mode 100644 ui/src/components/SidebarCompanyMenu.test.tsx create mode 100644 ui/src/components/SidebarCompanyMenu.tsx delete mode 100644 ui/src/components/TaskcoreIcon.tsx create mode 100644 ui/src/components/access/CompanySettingsNav.test.tsx create mode 100644 ui/src/components/access/CompanySettingsNav.tsx create mode 100644 ui/src/components/access/ModeBadge.tsx create mode 100644 ui/src/context/BreadcrumbContext.test.tsx create mode 100644 ui/src/fixtures/issueThreadInteractionFixtures.ts create mode 100644 ui/src/lib/company-members.test.ts create mode 100644 ui/src/lib/company-members.ts create mode 100644 ui/src/lib/invite-memory.ts create mode 100644 ui/src/lib/issue-filters.test.ts create mode 100644 ui/src/lib/issue-thread-interactions.test.ts create mode 100644 ui/src/lib/issue-thread-interactions.ts create mode 100644 ui/src/lib/issueDetailQuery.test.tsx create mode 100644 ui/src/lib/liveIssueIds.test.ts create mode 100644 ui/src/lib/liveIssueIds.ts create mode 100644 ui/src/lib/recent-projects.ts create mode 100644 ui/src/lib/recent-selections.test.ts create mode 100644 ui/src/lib/recent-selections.ts create mode 100644 ui/src/lib/runRetryState.test.ts create mode 100644 ui/src/lib/runRetryState.ts create mode 100644 ui/src/lib/vite-watch.test.ts create mode 100644 ui/src/lib/vite-watch.ts create mode 100644 ui/src/pages/CompanyAccess.test.tsx create mode 100644 ui/src/pages/CompanyAccess.tsx create mode 100644 ui/src/pages/CompanyInvites.test.tsx create mode 100644 ui/src/pages/CompanyInvites.tsx create mode 100644 ui/src/pages/InstanceAccess.tsx create mode 100644 ui/src/pages/InviteLanding.test.tsx create mode 100644 ui/src/pages/InviteUxLab.test.tsx create mode 100644 ui/src/pages/InviteUxLab.tsx create mode 100644 ui/src/pages/Issues.test.tsx create mode 100644 ui/src/pages/JoinRequestQueue.tsx create mode 100644 ui/src/pages/OrgChart.test.tsx create mode 100644 ui/src/pages/ProfileSettings.test.tsx create mode 100644 ui/src/pages/ProfileSettings.tsx create mode 100644 ui/src/pages/UserProfile.tsx create mode 100644 ui/src/pages/Workspaces.tsx create mode 100644 ui/storybook/.gitignore create mode 100644 ui/storybook/.storybook/main.ts create mode 100644 ui/storybook/.storybook/preview.tsx create mode 100644 ui/storybook/.storybook/styles.css create mode 100644 ui/storybook/.storybook/tailwind-entry.css create mode 100644 ui/storybook/fixtures/taskcoreData.ts create mode 100644 ui/storybook/stories/agent-management.stories.tsx create mode 100644 ui/storybook/stories/budget-finance.stories.tsx create mode 100644 ui/storybook/stories/chat-comments.stories.tsx create mode 100644 ui/storybook/stories/control-plane-surfaces.stories.tsx create mode 100644 ui/storybook/stories/data-viz-misc.stories.tsx create mode 100644 ui/storybook/stories/dialogs-modals.stories.tsx create mode 100644 ui/storybook/stories/forms-editors.stories.tsx create mode 100644 ui/storybook/stories/foundations.stories.tsx create mode 100644 ui/storybook/stories/issue-management.stories.tsx create mode 100644 ui/storybook/stories/issue-thread-interactions.stories.tsx create mode 100644 ui/storybook/stories/navigation-layout.stories.tsx create mode 100644 ui/storybook/stories/overview.stories.tsx create mode 100644 ui/storybook/stories/projects-goals-workspaces.stories.tsx create mode 100644 ui/storybook/stories/status-language.stories.tsx create mode 100644 ui/storybook/stories/ux-labs.stories.tsx diff --git a/ui/README.md b/ui/README.md index 7d38ee5..4f47ae8 100644 --- a/ui/README.md +++ b/ui/README.md @@ -6,6 +6,15 @@ Published static assets for the Taskcore board UI. The npm package contains the production build under `dist/`. It does not ship the UI source tree or workspace-only dependencies. +## Storybook + +Storybook config, stories, and fixtures live under `ui/storybook/`. + +```sh +pnpm --filter @taskcore/ui storybook +pnpm --filter @taskcore/ui build-storybook +``` + ## Typical use Install the package, then serve or copy the built files from `node_modules/@taskcore/ui/dist`. diff --git a/ui/index.html b/ui/index.html index 0ef83b4..de887cd 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1,52 +1,48 @@ - - - - - - - - - - Taskcore - - - - - - - - - - - - - - -
    - - - - \ No newline at end of file + })(); + + + +
    + + + diff --git a/ui/package.json b/ui/package.json index bca3144..59fc35f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -16,9 +16,11 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "storybook": "storybook dev -p 6006 -c storybook/.storybook", + "build-storybook": "storybook build -c storybook/.storybook -o storybook-static", "preview": "vite preview", "typecheck": "tsc -b", - "clean": "rm -rf dist tsconfig.tsbuildinfo", + "clean": "rm -rf dist storybook-static tsconfig.tsbuildinfo", "prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../scripts/generate-ui-package-json.mjs", "postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi" }, @@ -61,13 +63,17 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.7", + "@storybook/addon-a11y": "10.3.5", + "@storybook/addon-docs": "10.3.5", + "@storybook/react-vite": "10.3.5", "@types/node": "^25.2.3", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.4", "tailwindcss": "^4.0.7", "typescript": "^5.7.3", + "storybook": "10.3.5", "vite": "^6.1.0", "vitest": "^3.0.5" } -} \ No newline at end of file +} diff --git a/ui/public/site.webmanifest b/ui/public/site.webmanifest index 157bb45..5ec24b2 100644 --- a/ui/public/site.webmanifest +++ b/ui/public/site.webmanifest @@ -27,4 +27,4 @@ "purpose": "maskable" } ] -} \ No newline at end of file +} diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx new file mode 100644 index 0000000..bec9c19 --- /dev/null +++ b/ui/src/App.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom + +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CloudAccessGate } from "./components/CloudAccessGate"; + +const mockHealthApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +const mockAuthApi = vi.hoisted(() => ({ + getSession: vi.fn(), +})); + +const mockAccessApi = vi.hoisted(() => ({ + getCurrentBoardAccess: vi.fn(), +})); + +vi.mock("./api/health", () => ({ + healthApi: mockHealthApi, +})); + +vi.mock("./api/auth", () => ({ + authApi: mockAuthApi, +})); + +vi.mock("./api/access", () => ({ + accessApi: mockAccessApi, +})); + +vi.mock("@/lib/router", () => ({ + Navigate: ({ to }: { to: string }) =>
    Navigate:{to}
    , + Outlet: () =>
    Outlet content
    , + Route: ({ children }: { children?: ReactNode }) => <>{children}, + Routes: ({ children }: { children?: ReactNode }) => <>{children}, + useLocation: () => ({ pathname: "/instance/settings/general", search: "", hash: "" }), + useParams: () => ({}), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("CloudAccessGate", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockHealthApi.get.mockResolvedValue({ + status: "ok", + deploymentMode: "authenticated", + bootstrapStatus: "ready", + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("shows a no-access message for signed-in users without org access", async () => { + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + }); + mockAccessApi.getCurrentBoardAccess.mockResolvedValue({ + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + userId: "user-1", + isInstanceAdmin: false, + companyIds: [], + source: "session", + keyId: null, + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("No company access"); + expect(container.textContent).not.toContain("Outlet content"); + + await act(async () => { + root.unmount(); + }); + }); + + it("allows authenticated users with company access through to the board", async () => { + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + }); + mockAccessApi.getCurrentBoardAccess.mockResolvedValue({ + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + userId: "user-1", + isInstanceAdmin: false, + companyIds: ["company-1"], + source: "session", + keyId: null, + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Outlet content"); + expect(container.textContent).not.toContain("No company access"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 4ed4628..f495dc0 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,10 +1,8 @@ import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router"; -import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Layout } from "./components/Layout"; import { OnboardingWizard } from "./components/OnboardingWizard"; -import { authApi } from "./api/auth"; -import { healthApi } from "./api/health"; +import { CloudAccessGate } from "./components/CloudAccessGate"; import { Dashboard } from "./pages/Dashboard"; import { Companies } from "./pages/Companies"; import { Agents } from "./pages/Agents"; @@ -12,10 +10,12 @@ import { AgentDetail } from "./pages/AgentDetail"; import { Projects } from "./pages/Projects"; import { ProjectDetail } from "./pages/ProjectDetail"; import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail"; +import { Workspaces } from "./pages/Workspaces"; import { Issues } from "./pages/Issues"; import { IssueDetail } from "./pages/IssueDetail"; import { Routines } from "./pages/Routines"; import { RoutineDetail } from "./pages/RoutineDetail"; +import { UserProfile } from "./pages/UserProfile"; import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail"; import { Goals } from "./pages/Goals"; import { GoalDetail } from "./pages/GoalDetail"; @@ -25,99 +25,34 @@ import { Costs } from "./pages/Costs"; import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; +import { CompanyAccess } from "./pages/CompanyAccess"; +import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; import { CompanyExport } from "./pages/CompanyExport"; import { CompanyImport } from "./pages/CompanyImport"; import { DesignGuide } from "./pages/DesignGuide"; import { InstanceGeneralSettings } from "./pages/InstanceGeneralSettings"; +import { InstanceAccess } from "./pages/InstanceAccess"; import { InstanceSettings } from "./pages/InstanceSettings"; import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings"; +import { ProfileSettings } from "./pages/ProfileSettings"; import { PluginManager } from "./pages/PluginManager"; import { PluginSettings } from "./pages/PluginSettings"; import { AdapterManager } from "./pages/AdapterManager"; import { PluginPage } from "./pages/PluginPage"; -import { IssueChatUxLab } from "./pages/IssueChatUxLab"; -import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; import { OrgChart } from "./pages/OrgChart"; import { NewAgent } from "./pages/NewAgent"; import { AuthPage } from "./pages/Auth"; import { BoardClaimPage } from "./pages/BoardClaim"; import { CliAuthPage } from "./pages/CliAuth"; import { InviteLandingPage } from "./pages/InviteLanding"; +import { JoinRequestQueue } from "./pages/JoinRequestQueue"; import { NotFoundPage } from "./pages/NotFound"; -import { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; import { loadLastInboxTab } from "./lib/inbox"; import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route"; -function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { - return ( -
    -
    -

    Instance setup required

    -

    - {hasActiveInvite - ? "No instance admin exists yet. A bootstrap invite is already active. Check your Taskcore startup logs for the first admin invite URL, or run this command to rotate it:" - : "No instance admin exists yet. Run this command in your Taskcore environment to generate the first admin invite URL:"} -

    -
    -          {`pnpm taskcore auth bootstrap-ceo`}
    -        
    -
    -
    - ); -} - -function CloudAccessGate() { - const location = useLocation(); - const healthQuery = useQuery({ - queryKey: queryKeys.health, - queryFn: () => healthApi.get(), - retry: false, - refetchInterval: (query) => { - const data = query.state.data as - | { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" } - | undefined; - return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending" - ? 2000 - : false; - }, - refetchIntervalInBackground: true, - }); - - const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated"; - const sessionQuery = useQuery({ - queryKey: queryKeys.auth.session, - queryFn: () => authApi.getSession(), - enabled: isAuthenticatedMode, - retry: false, - }); - - if (healthQuery.isLoading || (isAuthenticatedMode && sessionQuery.isLoading)) { - return
    Loading...
    ; - } - - if (healthQuery.error) { - return ( -
    - {healthQuery.error instanceof Error ? healthQuery.error.message : "Failed to load app state"} -
    - ); - } - - if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") { - return ; - } - - if (isAuthenticatedMode && !sessionQuery.data) { - const next = encodeURIComponent(`${location.pathname}${location.search}`); - return ; - } - - return ; -} - function boardRoutes() { return ( <> @@ -126,6 +61,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -151,6 +88,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -177,10 +115,10 @@ function boardRoutes() { } /> } /> } /> + } /> } /> + } /> } /> - } /> - } /> } /> } /> } /> @@ -323,7 +261,9 @@ export function App() { } /> }> } /> + } /> } /> + } /> } /> } /> } /> @@ -335,6 +275,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -351,12 +292,11 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> } /> - } /> - } /> }> {boardRoutes()} diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index be8caa0..1cf5b61 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -50,10 +50,10 @@ export function CodexLocalConfigFields({ isCreate ? values!.instructionsFilePath ?? "" : eff( - "adapterConfig", - "instructionsFilePath", - String(config.instructionsFilePath ?? ""), - ) + "adapterConfig", + "instructionsFilePath", + String(config.instructionsFilePath ?? ""), + ) } onCommit={(v) => isCreate @@ -75,10 +75,10 @@ export function CodexLocalConfigFields({ isCreate ? values!.dangerouslyBypassSandbox : eff( - "adapterConfig", - "dangerouslyBypassApprovalsAndSandbox", - bypassEnabled, - ) + "adapterConfig", + "dangerouslyBypassApprovalsAndSandbox", + bypassEnabled, + ) } onChange={(v) => isCreate diff --git a/ui/src/adapters/dynamic-loader.ts b/ui/src/adapters/dynamic-loader.ts index f79c386..026ce2d 100644 --- a/ui/src/adapters/dynamic-loader.ts +++ b/ui/src/adapters/dynamic-loader.ts @@ -1,122 +1,285 @@ /** - * Dynamic UI parser loading for external adapters. + * Dynamic UI parser loading for external adapters — sandboxed execution. * * When the Taskcore UI encounters an adapter type that doesn't have a * built-in parser (e.g., an external adapter loaded via the plugin system), * it fetches the parser JS from `/api/adapters/:type/ui-parser.js` and - * evaluates it to create a `parseStdoutLine` function. + * executes it **inside a dedicated Web Worker** so it cannot access the + * board UI's same-origin state (cookies, localStorage, DOM, authenticated + * fetch, etc.). * - * The parser module must export: - * - `parseStdoutLine(line: string, ts: string): TranscriptEntry[]` - * - optionally `createStdoutParser(): { parseLine, reset }` for stateful parsers + * The worker communicates via a narrow postMessage protocol: + * Main → Worker: { type: "init", source } + * Worker → Main: { type: "ready" } | { type: "error", message } + * Main → Worker: { type: "parse", id, line, ts } + * Worker → Main: { type: "result", id, entries } * - * This is the bridge between the server-side plugin system and the client-side - * UI rendering. Adapter developers ship a `dist/ui-parser.js` with zero - * runtime dependencies, and Taskcore's UI loads it on demand. + * Because the parse call is async (cross-thread postMessage), but the + * existing `parseStdoutLine` contract is synchronous, we cache completed + * worker results and ask the adapter registry to recompute transcripts when + * a new result arrives. + * + * **Synchronous fast-path**: After init, parse requests are sent to the + * worker which responds asynchronously. The `parseStdoutLine` wrapper + * returns cached results synchronously on the next transcript recomputation. + * In practice this adds ~1 frame of latency which is imperceptible. + * + * Security: see `sandboxed-parser-worker.ts` for the full lockdown. */ import type { TranscriptEntry } from "@taskcore/adapter-utils"; -import type { StatefulStdoutParser, StdoutLineParser, StdoutParserFactory } from "./types"; +import type { StdoutLineParser, StdoutParserFactory } from "./types"; +import { createSandboxedWorker } from "./sandboxed-parser-worker"; +import type { SandboxRequest, SandboxResponse } from "./sandboxed-parser-worker"; + +// ── Types ─────────────────────────────────────────────────────────────────── interface DynamicParserModule { parseStdoutLine: StdoutLineParser; createStdoutParser?: StdoutParserFactory; } -// Cache of dynamically loaded parsers by adapter type. -// Once loaded, the parser is reused for all runs of that adapter type. +interface SandboxedParser { + worker: Worker; + ready: boolean; + nextId: number; + pendingResolves: Map void>; +} + +// ── State ─────────────────────────────────────────────────────────────────── + +/** Cache of fully initialised sandboxed parsers by adapter type. */ +const sandboxedParsers = new Map(); + +/** Cache of the public DynamicParserModule wrappers. */ const dynamicParserCache = new Map(); -// Track which types we've already attempted to load (to avoid repeat 404s). +/** Track which types we've already attempted to load (to avoid repeat 404s). */ const failedLoads = new Set(); +/** In-flight init promises so concurrent callers share the same load. */ +const loadPromises = new Map>(); + +let resultNotifier: (() => void) | null = null; + +export function setDynamicParserResultNotifier(fn: (() => void) | null): void { + resultNotifier = fn; +} + +// ── Internal helpers ──────────────────────────────────────────────────────── + +function sendToWorker(sandbox: SandboxedParser, msg: SandboxRequest): void { + sandbox.worker.postMessage(msg); +} + +function nextRequestId(sandbox: SandboxedParser): number { + return sandbox.nextId++; +} + +function lineCacheKey(line: string, ts: string): string { + return `${ts}\u0000${line}`; +} + +function notifyResultReady(): void { + resultNotifier?.(); +} + +/** + * Parse a single line synchronously by delegating to the worker. + * Returns a Promise that resolves with the TranscriptEntry[] from the worker. + */ +function parseLineAsync(sandbox: SandboxedParser, line: string, ts: string): Promise { + return new Promise((resolve) => { + const id = nextRequestId(sandbox); + sandbox.pendingResolves.set(id, resolve); + sendToWorker(sandbox, { type: "parse", id, line, ts }); + }); +} + +function drainPendingRequests(sandbox: SandboxedParser): void { + for (const resolver of sandbox.pendingResolves.values()) { + resolver([]); + } + sandbox.pendingResolves.clear(); +} + /** - * Dynamically load a UI parser for an adapter type from the server API. + * Create a sandboxed worker, send the parser source, and wait for init. + */ +function initSandboxedWorker(source: string): Promise { + return new Promise((resolve, reject) => { + const worker = createSandboxedWorker(); + const sandbox: SandboxedParser = { + worker, + ready: false, + nextId: 1, + pendingResolves: new Map(), + }; + + // Timeout if the worker doesn't respond within 5s + const timeout = setTimeout(() => { + drainPendingRequests(sandbox); + worker.terminate(); + reject(new Error("Parser worker init timed out")); + }, 5000); + + worker.onmessage = (e: MessageEvent) => { + const msg = e.data; + + if (msg.type === "ready") { + clearTimeout(timeout); + sandbox.ready = true; + + // Switch to the steady-state message handler. + worker.onmessage = (ev: MessageEvent) => { + const resp = ev.data; + if (resp.type === "result") { + const resolver = sandbox.pendingResolves.get(resp.id); + if (resolver) { + sandbox.pendingResolves.delete(resp.id); + resolver(resp.entries as TranscriptEntry[]); + } + } else if (resp.type === "error") { + console.error("[adapter-ui-loader] Worker reported error:", resp.message); + drainPendingRequests(sandbox); + } + }; + + resolve(sandbox); + return; + } + + if (msg.type === "error") { + clearTimeout(timeout); + drainPendingRequests(sandbox); + worker.terminate(); + reject(new Error(msg.message)); + return; + } + }; + + worker.onerror = (ev) => { + clearTimeout(timeout); + drainPendingRequests(sandbox); + worker.terminate(); + reject(new Error(`Worker error: ${ev.message}`)); + }; + + // Send the parser source to the worker for evaluation. + sendToWorker(sandbox, { type: "init", source }); + }); +} + +/** + * Build a DynamicParserModule that delegates all calls to the sandboxed worker. * - * Fetches `/api/adapters/:type/ui-parser.js`, evaluates the module source - * in a scoped context, and extracts the `parseStdoutLine` export. + * The parseStdoutLine wrapper is **synchronous** to match the existing contract. + * Cache misses send a parse request to the worker and return `[]`; when the + * worker responds, the registry notification path recomputes transcripts and + * this wrapper returns the cached result synchronously. * - * @returns A StdoutLineParser function, or null if unavailable. + * In practice, because the existing codebase already handles the "bridge" + * pattern where parseStdoutLine returns [] until the dynamic parser loads, + * the same UX applies here: the first render may show raw lines, and a + * subsequent render shows the parsed entries. */ -export async function loadDynamicParser(adapterType: string): Promise { - // Return cached parser if already loaded - const cached = dynamicParserCache.get(adapterType); - if (cached) return cached; +function buildParserModule(sandbox: SandboxedParser): DynamicParserModule { + const parseCache = new Map(); + const pendingParseKeys = new Set(); - // Don't retry types that previously 404'd - if (failedLoads.has(adapterType)) return null; + const parseStdoutLine: StdoutLineParser = (line: string, ts: string) => { + const key = lineCacheKey(line, ts); + const cached = parseCache.get(key); + if (cached) return cached.slice(); - try { - const response = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/ui-parser.js`); - if (!response.ok) { - failedLoads.add(adapterType); - return null; + if (!pendingParseKeys.has(key)) { + pendingParseKeys.add(key); + parseLineAsync(sandbox, line, ts).then((entries) => { + pendingParseKeys.delete(key); + parseCache.set(key, entries); + notifyResultReady(); + }); } - const source = await response.text(); + return []; + }; - // Evaluate the module source using URL.createObjectURL + dynamic import(). - // This properly supports ESM modules with `export` statements. - // (new Function("exports", source) would fail with SyntaxError on `export` keywords.) - const blob = new Blob([source], { type: "application/javascript" }); - const blobUrl = URL.createObjectURL(blob); + return { parseStdoutLine }; +} - let parserModule: DynamicParserModule; +// ── Public API ────────────────────────────────────────────────────────────── +/** + * Dynamically load a UI parser for an adapter type from the server API, + * executing it inside a sandboxed Web Worker. + * + * @returns A DynamicParserModule, or null if unavailable. + */ +export async function loadDynamicParser(adapterType: string): Promise { + // Return cached parser if already loaded. + const cached = dynamicParserCache.get(adapterType); + if (cached) return cached; + + // Don't retry types that previously failed. + if (failedLoads.has(adapterType)) return null; + + // Coalesce concurrent loads. + const inflight = loadPromises.get(adapterType); + if (inflight) return inflight; + + const loadPromise = (async (): Promise => { try { - const mod = await import(/* @vite-ignore */ blobUrl); - - // Prefer the factory function (stateful parser) if available, - // fall back to the static parseStdoutLine function. - if (typeof mod.createStdoutParser === "function") { - const createStdoutParser = mod.createStdoutParser as StdoutParserFactory; - parserModule = { - createStdoutParser, - // Fallback for callers that only know about parseStdoutLine. - parseStdoutLine: - typeof mod.parseStdoutLine === "function" - ? (mod.parseStdoutLine as StdoutLineParser) - : ((line: string, ts: string) => { - const parser = createStdoutParser() as StatefulStdoutParser; - const entries = parser.parseLine(line, ts); - parser.reset(); - return entries; - }), - }; - } else if (typeof mod.parseStdoutLine === "function") { - parserModule = { - parseStdoutLine: mod.parseStdoutLine as StdoutLineParser, - }; - } else { - console.warn(`[adapter-ui-loader] Module for "${adapterType}" exports neither parseStdoutLine nor createStdoutParser`); + const response = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/ui-parser.js`); + if (!response.ok) { failedLoads.add(adapterType); return null; } + + const source = await response.text(); + + // Initialise the sandboxed worker with the parser source. + const sandbox = await initSandboxedWorker(source); + sandboxedParsers.set(adapterType, sandbox); + + const parserModule = buildParserModule(sandbox); + dynamicParserCache.set(adapterType, parserModule); + + console.info(`[adapter-ui-loader] Loaded sandboxed UI parser for "${adapterType}"`); + return parserModule; + } catch (err) { + console.warn(`[adapter-ui-loader] Failed to load UI parser for "${adapterType}":`, err); + failedLoads.add(adapterType); + return null; } finally { - URL.revokeObjectURL(blobUrl); + loadPromises.delete(adapterType); } + })(); - // Cache for reuse - dynamicParserCache.set(adapterType, parserModule); - console.info(`[adapter-ui-loader] Loaded dynamic UI parser for "${adapterType}"`); - return parserModule; - } catch (err) { - console.warn(`[adapter-ui-loader] Failed to load UI parser for "${adapterType}":`, err); - failedLoads.add(adapterType); - return null; - } + loadPromises.set(adapterType, loadPromise); + return loadPromise; } /** * Invalidate a cached dynamic parser, removing it from both the parser cache * and the failed-loads set so that the next load attempt will try again. + * Also terminates the sandboxed worker if one exists. */ export function invalidateDynamicParser(adapterType: string): boolean { const wasCached = dynamicParserCache.has(adapterType); dynamicParserCache.delete(adapterType); failedLoads.delete(adapterType); + loadPromises.delete(adapterType); + + // Terminate the worker to free resources. + const sandbox = sandboxedParsers.get(adapterType); + if (sandbox) { + drainPendingRequests(sandbox); + sandbox.worker.terminate(); + sandboxedParsers.delete(adapterType); + } + if (wasCached) { - console.info(`[adapter-ui-loader] Invalidated dynamic UI parser for "${adapterType}"`); + console.info(`[adapter-ui-loader] Invalidated sandboxed UI parser for "${adapterType}"`); } return wasCached; } diff --git a/ui/src/adapters/hermes-local/index.ts b/ui/src/adapters/hermes-local/index.ts index c037a74..a744917 100644 --- a/ui/src/adapters/hermes-local/index.ts +++ b/ui/src/adapters/hermes-local/index.ts @@ -1,12 +1,12 @@ import type { UIAdapterModule } from "../types"; import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui"; -import { SchemaConfigFields, buildSchemaAdapterConfig } from "../schema-config-fields"; import { buildHermesConfig } from "hermes-paperclip-adapter/ui"; +import { SchemaConfigFields } from "../schema-config-fields"; export const hermesLocalUIAdapter: UIAdapterModule = { type: "hermes_local", label: "Hermes Agent", parseStdoutLine: parseHermesStdoutLine, ConfigFields: SchemaConfigFields, - buildAdapterConfig: buildSchemaAdapterConfig, + buildAdapterConfig: buildHermesConfig, }; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index e418457..e8e24ea 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -9,7 +9,7 @@ import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { hermesLocalUIAdapter } from "./hermes-local"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; -import { loadDynamicParser, invalidateDynamicParser } from "./dynamic-loader"; +import { loadDynamicParser, invalidateDynamicParser, setDynamicParserResultNotifier } from "./dynamic-loader"; import { SchemaConfigFields, buildSchemaAdapterConfig } from "./schema-config-fields"; const uiAdapters: UIAdapterModule[] = []; @@ -45,6 +45,8 @@ function notifyAdapterChange(): void { for (const fn of adapterChangeListeners) fn(); } +setDynamicParserResultNotifier(notifyAdapterChange); + function registerBuiltInUIAdapters() { for (const adapter of [ claudeLocalUIAdapter, diff --git a/ui/src/adapters/sandboxed-parser-worker.test.ts b/ui/src/adapters/sandboxed-parser-worker.test.ts new file mode 100644 index 0000000..cc07186 --- /dev/null +++ b/ui/src/adapters/sandboxed-parser-worker.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { getWorkerBootstrapSource } from "./sandboxed-parser-worker"; + +describe("sandboxed parser worker bootstrap", () => { + it("disables child worker and object URL escape hatches", () => { + const source = getWorkerBootstrapSource(); + + expect(source).toContain("self.Worker = _undefined"); + expect(source).toContain("self.SharedWorker = _undefined"); + expect(source).toContain("self.Blob = _undefined"); + expect(source).toContain("self.RTCPeerConnection = _undefined"); + expect(source).toContain("self.RTCDataChannel = _undefined"); + expect(source).toContain('"createObjectURL"'); + expect(source).toContain('"revokeObjectURL"'); + }); + + it("evaluates parser source in strict mode", () => { + expect(getWorkerBootstrapSource()).toContain('\\"use strict\\";\\n{\\n" + msg.source'); + }); + + it("does not include the unused parse_batch protocol branch", () => { + expect(getWorkerBootstrapSource()).not.toContain("parse_batch"); + }); +}); diff --git a/ui/src/adapters/sandboxed-parser-worker.ts b/ui/src/adapters/sandboxed-parser-worker.ts new file mode 100644 index 0000000..fb4445b --- /dev/null +++ b/ui/src/adapters/sandboxed-parser-worker.ts @@ -0,0 +1,182 @@ +/** + * Sandboxed Worker bootstrap for external adapter UI parsers. + * + * Security boundary: parser code runs inside a dedicated Web Worker with + * network and DOM APIs explicitly disabled. Communication uses a narrow + * postMessage protocol (see {@link SandboxRequest} / {@link SandboxResponse}). + * + * The worker is created from an inline Blob URL so no extra file needs to + * be served. On initialisation the main thread sends the parser source; + * the bootstrap evaluates it in a scope where dangerous globals are shadowed + * by `undefined`, then responds to parse requests. + */ + +// ── Message protocol ──────────────────────────────────────────────────────── + +/** Messages sent from the main thread to the worker. */ +export type SandboxRequest = + | { type: "init"; source: string } + | { type: "parse"; id: number; line: string; ts: string }; + +/** Messages sent from the worker back to the main thread. */ +export type SandboxResponse = + | { type: "ready" } + | { type: "error"; message: string } + | { type: "result"; id: number; entries: unknown[] }; + +// ── Worker bootstrap source ───────────────────────────────────────────────── + +/** + * Inline JS that runs inside the Worker. It: + * 1. Shadows dangerous globals (`fetch`, `XMLHttpRequest`, `WebSocket`, + * `importScripts`, `EventSource`, `navigator.sendBeacon`, etc.) with + * no-ops or `undefined`. + * 2. Waits for an `init` message carrying the adapter's parser source. + * 3. Evaluates the source via `new Function()` and extracts exports. + * 4. Responds to `parse` messages with `TranscriptEntry[]` results. + */ +const WORKER_BOOTSTRAP = ` +"use strict"; + +// ── 1. Lock down dangerous globals ────────────────────────────────────────── +// Workers have no DOM, but they still have network and import APIs. + +const _undefined = void 0; + +// Network +self.fetch = _undefined; +self.XMLHttpRequest = _undefined; +self.WebSocket = _undefined; +self.EventSource = _undefined; +self.RTCPeerConnection = _undefined; +self.RTCDataChannel = _undefined; +self.Request = _undefined; +self.Response = _undefined; +self.Headers = _undefined; +self.Cache = _undefined; +self.CacheStorage = _undefined; +self.caches = _undefined; + +// Import / eval escape hatches +self.importScripts = _undefined; +self.Worker = _undefined; +self.SharedWorker = _undefined; +self.Blob = _undefined; +if (self.URL) { + try { Object.defineProperty(self.URL, "createObjectURL", { value: _undefined, writable: false, configurable: false }); } catch {} + try { Object.defineProperty(self.URL, "revokeObjectURL", { value: _undefined, writable: false, configurable: false }); } catch {} +} + +// Beacon / reporting +if (self.navigator) { + try { Object.defineProperty(self.navigator, "sendBeacon", { value: _undefined, writable: false, configurable: false }); } catch {} +} + +// Service worker / broadcast channel +self.BroadcastChannel = _undefined; + +// IndexedDB (prevents persistent state exfiltration) +self.indexedDB = _undefined; +self.IDBFactory = _undefined; + +// ── 2. Parser state ───────────────────────────────────────────────────────── + +let parseStdoutLine = null; +let createStdoutParser = null; +let fallbackParser = null; + +// ── 3. Message handler ────────────────────────────────────────────────────── + +self.onmessage = function (e) { + const msg = e.data; + + if (msg.type === "init") { + try { + // Evaluate the parser source in a constrained scope. + // We use a Function constructor to avoid giving the source access to + // our local variables. The only value we inject is a module-like + // \`exports\` object so both CJS-style and ESM-compiled code works. + // + // ESM sources compiled to IIFE typically assign to an \`exports\` param + // or use \`export\`. Since we can't use real ESM import() here (the + // source is a string, not a URL), we wrap it. + const exports = {}; + const module = { exports }; + + // Build a function that receives common CJS shims. + // \`self\` is shadowed to prevent the parser from un-deleting globals. + const factory = new Function( + "exports", "module", "self", "globalThis", + // Wrap in a block to prevent hoisted declarations from leaking. + "\\"use strict\\";\\n{\\n" + msg.source + "\\n}" + ); + factory(exports, module, _undefined, _undefined); + + // Resolve exports — try module.exports first (CJS), then named exports. + const resolved = module.exports && typeof module.exports === "object" && Object.keys(module.exports).length > 0 + ? module.exports + : exports; + + if (typeof resolved.parseStdoutLine === "function") { + parseStdoutLine = resolved.parseStdoutLine; + } + if (typeof resolved.createStdoutParser === "function") { + createStdoutParser = resolved.createStdoutParser; + } + if (!parseStdoutLine && createStdoutParser) { + fallbackParser = createStdoutParser(); + if (fallbackParser && typeof fallbackParser.parseLine === "function") { + parseStdoutLine = (line, ts) => fallbackParser.parseLine(line, ts); + } + } + + if (!parseStdoutLine) { + self.postMessage({ type: "error", message: "Parser module exports no usable parseStdoutLine or createStdoutParser" }); + return; + } + + self.postMessage({ type: "ready" }); + } catch (err) { + self.postMessage({ type: "error", message: "Parser init failed: " + (err && err.message || String(err)) }); + } + return; + } + + if (msg.type === "parse") { + try { + const entries = parseStdoutLine ? parseStdoutLine(msg.line, msg.ts) : []; + self.postMessage({ type: "result", id: msg.id, entries: entries || [] }); + } catch (err) { + self.postMessage({ type: "result", id: msg.id, entries: [] }); + } + return; + } + +}; +`; + +// ── Public API ─────────────────────────────────────────────────────────────── + +/** + * Return the inline Worker bootstrap source. + * Exported for testing (so test code can verify the lockdown behaviour). + */ +export function getWorkerBootstrapSource(): string { + return WORKER_BOOTSTRAP; +} + +/** + * Create a sandboxed Web Worker from the inline bootstrap. + * The caller must send an `init` message with the parser source before + * sending parse requests. + */ +export function createSandboxedWorker(): Worker { + const blob = new Blob([WORKER_BOOTSTRAP], { type: "application/javascript" }); + const url = URL.createObjectURL(blob); + try { + return new Worker(url); + } finally { + // Revoke after construction; the Worker has already captured the Blob URL source. + URL.revokeObjectURL(url); + } +} diff --git a/ui/src/adapters/schema-config-fields.tsx b/ui/src/adapters/schema-config-fields.tsx index d172644..c1a6c12 100644 --- a/ui/src/adapters/schema-config-fields.tsx +++ b/ui/src/adapters/schema-config-fields.tsx @@ -174,8 +174,9 @@ function ComboboxField({ {opts.map((opt) => ( - - - - -
    - {loading ? ( -
    - -

    Loading content...

    -
    - ) : error ? ( -
    - -

    Preview Unavailable

    -

    {error}

    - -
    - ) : isImage ? ( -
    - {artifact.title} -
    - ) : content !== null ? ( - -
    - {isMarkdown ? ( - {content} - ) : ( -
    -                    {content}
    -                  
    - )} -
    -
    - ) : ( -
    - -

    No Preview Available

    -

    - This file type cannot be previewed directly in the browser. -

    - -
    - )} -
    - - - ); -}; diff --git a/ui/src/components/BudgetIncidentCard.tsx b/ui/src/components/BudgetIncidentCard.tsx index dfa0833..e955d05 100644 --- a/ui/src/components/BudgetIncidentCard.tsx +++ b/ui/src/components/BudgetIncidentCard.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import type { BudgetIncident } from "@taskcore/shared"; import { AlertOctagon, ArrowUpRight, PauseCircle } from "lucide-react"; import { formatCents } from "../lib/utils"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -16,6 +17,14 @@ function parseDollarInput(value: string) { return Math.round(parsed * 100); } +function incidentStateLabel(incident: BudgetIncident) { + if (incident.status === "resolved") return "Resolved"; + if (incident.status === "dismissed") return "Dismissed"; + if (incident.approvalStatus === "revision_requested") return "Escalated"; + if (incident.approvalStatus === "pending") return "Pending approval"; + return "Open"; +} + export function BudgetIncidentCard({ incident, onRaiseAndResume, @@ -31,14 +40,20 @@ export function BudgetIncidentCard({ centsInputValue(Math.max(incident.amountObserved + 1000, incident.amountLimit)), ); const parsed = parseDollarInput(draftAmount); + const stateLabel = incidentStateLabel(incident); return (
    -
    - {incident.scopeType} hard stop +
    +
    + {incident.scopeType} hard stop +
    + + {stateLabel} +
    {incident.scopeName} diff --git a/ui/src/components/BudgetSidebarMarker.tsx b/ui/src/components/BudgetSidebarMarker.tsx index 43f10b9..07e5ddf 100644 --- a/ui/src/components/BudgetSidebarMarker.tsx +++ b/ui/src/components/BudgetSidebarMarker.tsx @@ -1,11 +1,33 @@ import { DollarSign } from "lucide-react"; -export function BudgetSidebarMarker({ title = "Paused by budget" }: { title?: string }) { +export type BudgetSidebarMarkerLevel = "healthy" | "warning" | "critical"; + +const levelClasses: Record = { + healthy: "bg-emerald-500/90 text-white", + warning: "bg-amber-500/95 text-amber-950", + critical: "bg-red-500/90 text-white", +}; + +const defaultTitles: Record = { + healthy: "Budget healthy", + warning: "Budget warning", + critical: "Paused by budget", +}; + +export function BudgetSidebarMarker({ + title, + level = "critical", +}: { + title?: string; + level?: BudgetSidebarMarkerLevel; +}) { + const accessibleTitle = title ?? defaultTitles[level]; + return ( diff --git a/ui/src/components/CloudAccessGate.tsx b/ui/src/components/CloudAccessGate.tsx new file mode 100644 index 0000000..3d683af --- /dev/null +++ b/ui/src/components/CloudAccessGate.tsx @@ -0,0 +1,114 @@ +import { Navigate, Outlet, useLocation } from "@/lib/router"; +import { useQuery } from "@tanstack/react-query"; +import { accessApi } from "@/api/access"; +import { authApi } from "@/api/auth"; +import { healthApi } from "@/api/health"; +import { queryKeys } from "@/lib/queryKeys"; + +function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { + return ( +
    +
    +

    Instance setup required

    +

    + {hasActiveInvite + ? "No instance admin exists yet. A bootstrap invite is already active. Check your Taskcore startup logs for the first admin invite URL, or run this command to rotate it:" + : "No instance admin exists yet. Run this command in your Taskcore environment to generate the first admin invite URL:"} +

    +
    +{`pnpm taskcore auth bootstrap-ceo`}
    +        
    +
    +
    + ); +} + +function NoBoardAccessPage() { + return ( +
    +
    +

    No company access

    +

    + This account is signed in, but it does not have an active company membership or instance-admin access on + this Taskcore instance. +

    +

    + Use a company invite or sign in with an account that already belongs to this org. +

    +
    +
    + ); +} + +export function CloudAccessGate() { + const location = useLocation(); + const healthQuery = useQuery({ + queryKey: queryKeys.health, + queryFn: () => healthApi.get(), + retry: false, + refetchInterval: (query) => { + const data = query.state.data as + | { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" } + | undefined; + return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending" + ? 2000 + : false; + }, + refetchIntervalInBackground: true, + }); + + const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated"; + const sessionQuery = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + enabled: isAuthenticatedMode, + retry: false, + }); + + const boardAccessQuery = useQuery({ + queryKey: queryKeys.access.currentBoardAccess, + queryFn: () => accessApi.getCurrentBoardAccess(), + enabled: isAuthenticatedMode && !!sessionQuery.data, + retry: false, + }); + + if ( + healthQuery.isLoading || + (isAuthenticatedMode && sessionQuery.isLoading) || + (isAuthenticatedMode && !!sessionQuery.data && boardAccessQuery.isLoading) + ) { + return
    Loading...
    ; + } + + if (healthQuery.error || boardAccessQuery.error) { + return ( +
    + {healthQuery.error instanceof Error + ? healthQuery.error.message + : boardAccessQuery.error instanceof Error + ? boardAccessQuery.error.message + : "Failed to load app state"} +
    + ); + } + + if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") { + return ; + } + + if (isAuthenticatedMode && !sessionQuery.data) { + const next = encodeURIComponent(`${location.pathname}${location.search}`); + return ; + } + + if ( + isAuthenticatedMode && + sessionQuery.data && + !boardAccessQuery.data?.isInstanceAdmin && + (boardAccessQuery.data?.companyIds.length ?? 0) === 0 + ) { + return ; + } + + return ; +} diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx index 7ef9ed7..f9f1f90 100644 --- a/ui/src/components/CommentThread.test.tsx +++ b/ui/src/components/CommentThread.test.tsx @@ -69,7 +69,7 @@ describe("CommentThread", () => { document.body.appendChild(container); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-11T12:00:00.000Z")); - writeTextMock = vi.fn(async () => { }); + writeTextMock = vi.fn(async () => {}); execCommandMock = vi.fn(() => true); Object.assign(navigator, { clipboard: { @@ -129,7 +129,7 @@ describe("CommentThread", () => { finishedAt: "2026-03-11T10:00:00.000Z", }]} agentMap={new Map([["agent-1", agent]])} - onAdd={async () => { }} + onAdd={async () => {}} /> , ); @@ -163,7 +163,7 @@ describe("CommentThread", () => { { }} + onAdd={async () => {}} /> , ); @@ -180,7 +180,7 @@ describe("CommentThread", () => { it("hides the reopen control and infers reopen for closed agent-assigned issues", async () => { const root = createRoot(container); - const onAdd = vi.fn(async () => { }); + const onAdd = vi.fn(async () => {}); act(() => { root.render( @@ -275,9 +275,9 @@ describe("CommentThread", () => { comments={[]} linkedApprovals={[approval]} agentMap={new Map([["agent-1", agent]])} - onAdd={async () => { }} - onApproveApproval={async () => { }} - onRejectApproval={async () => { }} + onAdd={async () => {}} + onApproveApproval={async () => {}} + onRejectApproval={async () => {}} /> , ); @@ -312,7 +312,7 @@ describe("CommentThread", () => { createdAt: new Date("2026-03-11T11:00:00.000Z"), updatedAt: new Date("2026-03-11T11:00:00.000Z"), }]} - onAdd={async () => { }} + onAdd={async () => {}} /> , ); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index b88db80..9c63b1d 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -9,8 +9,7 @@ import type { IssueComment, } from "@taskcore/shared"; import { Button } from "@/components/ui/button"; -import { ArrowRight, Check, Copy } from "lucide-react"; -import { TaskcoreIcon } from "./TaskcoreIcon"; +import { ArrowRight, Check, Copy, Taskcore } from "lucide-react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; @@ -136,8 +135,8 @@ function parseReassignment(target: string): CommentReassignment | null { } function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) { - const isClosed = issueStatus === "done" || issueStatus === "cancelled"; - return isClosed && assigneeValue.startsWith("agent:"); + const resumesToTodo = issueStatus === "done" || issueStatus === "cancelled" || issueStatus === "blocked"; + return resumesToTodo && assigneeValue.startsWith("agent:"); } function humanizeValue(value: string | null): string { @@ -321,12 +320,13 @@ function CommentCard({
    {comment.authorAgentId ? ( @@ -939,7 +939,7 @@ export function CommentThread({ disabled={attaching} title="Attach image" > - +
    )} diff --git a/ui/src/components/CompanyPatternIcon.tsx b/ui/src/components/CompanyPatternIcon.tsx index 6ea4078..6591f7d 100644 --- a/ui/src/components/CompanyPatternIcon.tsx +++ b/ui/src/components/CompanyPatternIcon.tsx @@ -13,6 +13,7 @@ interface CompanyPatternIconProps { logoUrl?: string | null; brandColor?: string | null; className?: string; + logoFit?: "cover" | "contain"; } function hashString(value: string): number { @@ -165,6 +166,7 @@ export function CompanyPatternIcon({ logoUrl, brandColor, className, + logoFit = "cover", }: CompanyPatternIconProps) { const initial = companyName.trim().charAt(0).toUpperCase() || "?"; const [imageError, setImageError] = useState(false); @@ -189,7 +191,10 @@ export function CompanyPatternIcon({ src={logo} alt={`${companyName} logo`} onError={() => setImageError(true)} - className="absolute inset-0 h-full w-full object-cover" + className={cn( + "absolute inset-0 h-full w-full", + logoFit === "contain" ? "object-contain" : "object-cover", + )} /> ) : patternDataUrl ? ( {/* Taskcore icon - aligned with top sections (implied line, no visible border) */}
    - +
    {/* Company list */} diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx new file mode 100644 index 0000000..39b39e7 --- /dev/null +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CompanySettingsSidebar } from "./CompanySettingsSidebar"; + +const sidebarNavItemMock = vi.hoisted(() => vi.fn()); +const mockSidebarBadgesApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ + children, + to, + onClick, + }: { + children: React.ReactNode; + to: string; + onClick?: () => void; + }) => ( + + ), +})); + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + selectedCompany: { id: "company-1", name: "Taskcore" }, + }), +})); + +vi.mock("@/context/SidebarContext", () => ({ + useSidebar: () => ({ + isMobile: false, + setSidebarOpen: vi.fn(), + }), +})); + +vi.mock("./SidebarNavItem", () => ({ + SidebarNavItem: (props: { + to: string; + label: string; + end?: boolean; + badge?: number; + }) => { + sidebarNavItemMock(props); + return
    {props.label}
    ; + }, +})); + +vi.mock("@/api/sidebarBadges", () => ({ + sidebarBadgesApi: mockSidebarBadgesApi, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("CompanySettingsSidebar", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockSidebarBadgesApi.get.mockResolvedValue({ + inbox: 0, + approvals: 0, + failedRuns: 0, + joinRequests: 2, + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("renders the company back link and the settings sections in the sidebar", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Taskcore"); + expect(container.textContent).toContain("Company Settings"); + expect(container.textContent).toContain("General"); + expect(container.textContent).toContain("Access"); + expect(container.textContent).toContain("Invites"); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings", + label: "General", + end: true, + }), + ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/access", + label: "Access", + badge: 2, + end: true, + }), + ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/invites", + label: "Invites", + end: true, + }), + ); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx new file mode 100644 index 0000000..6339cf1 --- /dev/null +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -0,0 +1,69 @@ +import { useQuery } from "@tanstack/react-query"; +import { ChevronLeft, MailPlus, Settings, Shield, SlidersHorizontal } from "lucide-react"; +import { sidebarBadgesApi } from "@/api/sidebarBadges"; +import { ApiError } from "@/api/client"; +import { Link } from "@/lib/router"; +import { queryKeys } from "@/lib/queryKeys"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { SidebarNavItem } from "./SidebarNavItem"; + +export function CompanySettingsSidebar() { + const { selectedCompany, selectedCompanyId } = useCompany(); + const { isMobile, setSidebarOpen } = useSidebar(); + const { data: badges } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.sidebarBadges(selectedCompanyId) + : ["sidebar-badges", "__disabled__"] as const, + queryFn: async () => { + try { + return await sidebarBadgesApi.get(selectedCompanyId!); + } catch (error) { + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + return null; + } + throw error; + } + }, + enabled: !!selectedCompanyId, + retry: false, + refetchInterval: 15_000, + }); + + return ( + + ); +} diff --git a/ui/src/components/CompanySwitcher.tsx b/ui/src/components/CompanySwitcher.tsx index aefe102..2d009db 100644 --- a/ui/src/components/CompanySwitcher.tsx +++ b/ui/src/components/CompanySwitcher.tsx @@ -10,6 +10,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; +import { useState } from "react"; function statusDotColor(status?: string): string { switch (status) { @@ -24,12 +25,20 @@ function statusDotColor(status?: string): string { } } -export function CompanySwitcher() { +interface CompanySwitcherProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function CompanySwitcher({ open: controlledOpen, onOpenChange }: CompanySwitcherProps = {}) { + const [internalOpen, setInternalOpen] = useState(false); const { companies, selectedCompany, setSelectedCompanyId } = useCompany(); const sidebarCompanies = companies.filter((company) => company.status !== "archived"); + const open = controlledOpen ?? internalOpen; + const setOpen = onOpenChange ?? setInternalOpen; return ( - + )} + {otherUserOptions + .filter((option) => { + if (!search.trim()) return true; + return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase()); + }) + .map((option) => ( + + ))} {sortedAgents .filter((agent) => { if (!search.trim()) return true; diff --git a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx index 8d3be49..d284d3c 100644 --- a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx +++ b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx @@ -88,27 +88,27 @@ export function ExecutionWorkspaceCloseDialog({ { if (!closeWorkspace.isPending) onOpenChange(nextOpen); }}> - + {actionLabel} - + Archive {workspaceName} and clean up any owned workspace artifacts. Taskcore keeps the workspace record and issue history, but removes it from active workspace views. {readinessQuery.isLoading ? ( -
    - +
    + Checking whether this workspace is safe to close...
    ) : readinessQuery.error ? ( -
    +
    {readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
    ) : readiness ? ( -
    -
    +
    +
    {readiness.state === "blocked" ? "Close is blocked" @@ -129,10 +129,10 @@ export function ExecutionWorkspaceCloseDialog({ {blockingIssues.length > 0 ? (
    -

    Blocking issues

    -
    +

    Blocking issues

    +
    {blockingIssues.map((issue) => ( -
    +
    {issue.identifier ?? issue.id} · {issue.title} @@ -147,10 +147,10 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.blockingReasons.length > 0 ? (
    -

    Blocking reasons

    -
      - {readiness.blockingReasons.map((reason, idx) => ( -
    • +

      Blocking reasons

      +
        + {readiness.blockingReasons.map((reason) => ( +
      • {reason}
      • ))} @@ -160,10 +160,10 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.warnings.length > 0 ? (
        -

        Warnings

        -
          - {readiness.warnings.map((warning, idx) => ( -
        • +

          Warnings

          +
            + {readiness.warnings.map((warning) => ( +
          • {warning}
          • ))} @@ -173,16 +173,16 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.git ? (
            -

            Git status

            -
            -
            -
            +

            Git status

            +
            +
            +
            Branch
            -
            {readiness.git.branchName ?? "Unknown"}
            +
            {readiness.git.branchName ?? "Unknown"}
            -
            +
            Base ref
            -
            {readiness.git.baseRef ?? "Not set"}
            +
            {readiness.git.baseRef ?? "Not set"}
            Merged into base
            @@ -209,10 +209,10 @@ export function ExecutionWorkspaceCloseDialog({ {otherLinkedIssues.length > 0 ? (
            -

            Other linked issues

            -
            +

            Other linked issues

            +
            {otherLinkedIssues.map((issue) => ( -
            +
            {issue.identifier ?? issue.id} · {issue.title} @@ -227,10 +227,10 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.runtimeServices.length > 0 ? (
            -

            Attached runtime services

            -
            +

            Attached runtime services

            +
            {readiness.runtimeServices.map((service) => ( -
            +
            {service.serviceName} {service.status} · {service.lifecycle} @@ -245,10 +245,10 @@ export function ExecutionWorkspaceCloseDialog({ ) : null}
            -

            Cleanup actions

            -
            +

            Cleanup actions

            +
            {readiness.plannedActions.map((action, index) => ( -
            +
            {action.label}
            {action.description}
            {action.command ? ( @@ -262,20 +262,20 @@ export function ExecutionWorkspaceCloseDialog({
            {currentStatus === "cleanup_failed" ? ( -
            +
            Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the workspace status if it succeeds.
            ) : null} {currentStatus === "archived" ? ( -
            +
            This workspace is already archived.
            ) : null} {readiness.git?.repoRoot ? ( -
            +
            Repo root: {readiness.git.repoRoot} {readiness.git.workspacePath ? ( <> diff --git a/ui/src/components/HermesIcon.tsx b/ui/src/components/HermesIcon.tsx index 91efd55..fb02623 100644 --- a/ui/src/components/HermesIcon.tsx +++ b/ui/src/components/HermesIcon.tsx @@ -1,43 +1,43 @@ -import { cn } from "../lib/utils"; - -interface HermesIconProps { - className?: string; -} - -/** - * Hermes caduceus icon — winged staff with two intertwined serpents. - * Replaces the generic Zap icon for the hermes_local adapter type. - * - * ⚕️ inspired but as the proper caduceus (Hermes' symbol): staff + two snakes + wings. - */ -export function HermesIcon({ className }: HermesIconProps) { - return ( - - {/* Central staff */} - - {/* Left serpent curves */} - - {/* Right serpent curves */} - - {/* Snake heads facing outward */} - - - {/* Wings at top of staff */} - - - {/* Wing feather details */} - - - {/* Staff sphere at top */} - - - ); -} +import { cn } from "../lib/utils"; + +interface HermesIconProps { + className?: string; +} + +/** + * Hermes caduceus icon — winged staff with two intertwined serpents. + * Replaces the generic Zap icon for the hermes_local adapter type. + * + * ⚕️ inspired but as the proper caduceus (Hermes' symbol): staff + two snakes + wings. + */ +export function HermesIcon({ className }: HermesIconProps) { + return ( + + {/* Central staff */} + + {/* Left serpent curves */} + + {/* Right serpent curves */} + + {/* Snake heads facing outward */} + + + {/* Wings at top of staff */} + + + {/* Wing feather details */} + + + {/* Staff sphere at top */} + + + ); +} diff --git a/ui/src/components/Identity.tsx b/ui/src/components/Identity.tsx index 6f9af85..b57c59a 100644 --- a/ui/src/components/Identity.tsx +++ b/ui/src/components/Identity.tsx @@ -28,8 +28,8 @@ export function Identity({ name, avatarUrl, initials, size = "default", classNam const displayInitials = initials ?? deriveInitials(name); return ( - - + + {avatarUrl && } {displayInitials} diff --git a/ui/src/components/InlineEditor.test.tsx b/ui/src/components/InlineEditor.test.tsx index 1f474f7..830d2ce 100644 --- a/ui/src/components/InlineEditor.test.tsx +++ b/ui/src/components/InlineEditor.test.tsx @@ -164,6 +164,62 @@ describe("InlineEditor", () => { }); outside.remove(); }); + + it("syncs a new multiline value while focused when the user has not edited locally", () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const textarea = container.querySelector('[data-testid="multiline-md-mock"]'); + expect(textarea).not.toBeNull(); + expect(textarea?.value).toBe(""); + + act(() => { + textarea!.focus(); + }); + + act(() => { + root.render(); + }); + + expect(textarea?.value).toBe("Loaded description"); + + act(() => { + root.unmount(); + }); + }); + + it("preserves focused multiline local edits when the prop value changes underneath them", () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const textarea = container.querySelector('[data-testid="multiline-md-mock"]'); + expect(textarea).not.toBeNull(); + + act(() => { + textarea!.focus(); + }); + act(() => { + setNativeTextareaValue(textarea!, "Local draft"); + }); + + act(() => { + root.render(); + }); + + expect(textarea?.value).toBe("Local draft"); + + act(() => { + root.unmount(); + }); + }); }); describe("queueContainedBlurCommit", () => { diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index 42f665f..e05a0ff 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -54,6 +54,7 @@ export function InlineEditor({ const [editing, setEditing] = useState(false); const [multilineFocused, setMultilineFocused] = useState(false); const [draft, setDraft] = useState(value); + const lastPropValueRef = useRef(value); const inputRef = useRef(null); const markdownRef = useRef(null); const autosaveDebounceRef = useRef | null>(null); @@ -66,8 +67,14 @@ export function InlineEditor({ } = useAutosaveIndicator(); useEffect(() => { - if (multiline && multilineFocused) return; - setDraft(value); + const previousValue = lastPropValueRef.current; + lastPropValueRef.current = value; + setDraft((currentDraft) => { + if (multiline && multilineFocused && currentDraft !== previousValue) { + return currentDraft; + } + return value; + }); }, [value, multiline, multilineFocused]); useEffect(() => { diff --git a/ui/src/components/InlineEntitySelector.tsx b/ui/src/components/InlineEntitySelector.tsx index 16d62a0..6bbcf64 100644 --- a/ui/src/components/InlineEntitySelector.tsx +++ b/ui/src/components/InlineEntitySelector.tsx @@ -1,6 +1,7 @@ import { forwardRef, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Check } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { orderItemsBySelectedAndRecent } from "../lib/recent-selections"; import { cn } from "../lib/utils"; export interface InlineEntityOption { @@ -21,6 +22,7 @@ interface InlineEntitySelectorProps { className?: string; renderTriggerValue?: (option: InlineEntityOption | null) => ReactNode; renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode; + recentOptionIds?: string[]; /** Skip the Portal so the popover stays in the DOM tree (fixes scroll inside Dialogs). */ disablePortal?: boolean; /** Open the popover when the trigger receives keyboard/programmatic focus. */ @@ -41,6 +43,7 @@ export const InlineEntitySelector = forwardRef( - () => [{ id: "", label: noneLabel, searchText: noneLabel }, ...options], - [noneLabel, options], - ); + const allOptions = useMemo(() => { + const baseOptions = [{ id: "", label: noneLabel, searchText: noneLabel }, ...options]; + return orderItemsBySelectedAndRecent(baseOptions, value, recentOptionIds); + }, [noneLabel, options, recentOptionIds, value]); const filteredOptions = useMemo(() => { const term = query.trim().toLowerCase(); diff --git a/ui/src/components/InstanceSidebar.tsx b/ui/src/components/InstanceSidebar.tsx index b39b959..6a1d0ad 100644 --- a/ui/src/components/InstanceSidebar.tsx +++ b/ui/src/components/InstanceSidebar.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react"; +import { Clock3, Cpu, FlaskConical, Puzzle, Settings, Shield, SlidersHorizontal, UserRoundPen } from "lucide-react"; import { NavLink } from "@/lib/router"; import { pluginsApi } from "@/api/plugins"; import { queryKeys } from "@/lib/queryKeys"; @@ -23,7 +23,9 @@ export function InstanceSidebar() {