diff --git a/landing/components/release-links.generated.ts b/landing/components/release-links.generated.ts index ded3eb90..93a7227c 100644 --- a/landing/components/release-links.generated.ts +++ b/landing/components/release-links.generated.ts @@ -16,7 +16,7 @@ export type PlatformDownload = { assets: PlatformDownloadAsset[]; }; -export const releaseVersion = "0.4.180"; +export const releaseVersion = "0.4.182"; export const platformDownloads: PlatformDownload[] = [ { @@ -29,14 +29,14 @@ export const platformDownloads: PlatformDownload[] = [ architectures: ["arm64"], id: "macos-arm64-dmg", label: "Apple Silicon DMG", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.180-arm64.dmg", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.182-arm64.dmg", note: "arm64", }, { architectures: ["x64"], id: "macos-x64-dmg", label: "Intel Mac DMG", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.180.dmg", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.182.dmg", note: "x64", }, ], @@ -51,7 +51,7 @@ export const platformDownloads: PlatformDownload[] = [ architectures: ["arm64", "x64"], id: "windows-universal-installer", label: "Windows Universal Installer", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany.Setup.0.4.180.exe", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany.Setup.0.4.182.exe", note: "arm64 + x64", }, ], @@ -66,42 +66,42 @@ export const platformDownloads: PlatformDownload[] = [ architectures: ["x64"], id: "linux-x64-appimage", label: "Linux x64 AppImage", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.180.AppImage", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.182.AppImage", note: "x64", }, { architectures: ["x64"], id: "linux-x64-deb", label: "Linux x64 DEB", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.180_amd64.deb", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.182_amd64.deb", note: "x64", }, { architectures: ["x64"], id: "linux-x64-rpm", label: "Linux x64 RPM", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.180.x86_64.rpm", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.182.x86_64.rpm", note: "x64", }, { architectures: ["arm64"], id: "linux-arm64-appimage", label: "Linux ARM64 AppImage", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.180-arm64.AppImage", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.182-arm64.AppImage", note: "arm64", }, { architectures: ["arm64"], id: "linux-arm64-deb", label: "Linux ARM64 DEB", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.180_arm64.deb", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.182_arm64.deb", note: "arm64", }, { architectures: ["arm64"], id: "linux-arm64-rpm", label: "Linux ARM64 RPM", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.180.aarch64.rpm", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.182.aarch64.rpm", note: "arm64", }, ], diff --git a/package.json b/package.json index 79d0b01e..b2b4ca02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-company", - "version": "0.4.180", + "version": "0.4.182", "private": true, "type": "module", "description": "Cross-platform Electron desktop control plane for local-first AI agent companies.", diff --git a/src/main/agent-runtime/runtime-bridge.ts b/src/main/agent-runtime/runtime-bridge.ts index 0b4b4e3f..b134fa28 100644 --- a/src/main/agent-runtime/runtime-bridge.ts +++ b/src/main/agent-runtime/runtime-bridge.ts @@ -121,6 +121,34 @@ export class RuntimeBridge { this.getOrCreateStore(context.taskSessionId); } + private evictActiveRun(runId: string): ActiveRunContext | null { + const ctx = this.activeRuns.get(runId) ?? null; + this.activeRuns.delete(runId); + this.inboundCorrelations.deleteRun(runId); + if (ctx) this.sessionStores.delete(ctx.taskSessionId); + return ctx; + } + + private assertRunContextCurrent( + ctx: ActiveRunContext, + operation: string, + ): { ok: true } | { ok: false; error: string } { + const runState = this.deps.db.core.runs.getRunAuthState(ctx.runId); + if (!runState) { + this.evictActiveRun(ctx.runId); + const error = `${operation}: stale run ${ctx.runId} no longer exists.`; + this.deps.logger.warn(`[runtime-bridge] ${error}`); + return { ok: false, error }; + } + if (runState.companyId !== ctx.companyId || runState.agentId !== ctx.agentId) { + this.evictActiveRun(ctx.runId); + const error = `${operation}: stale run ${ctx.runId} context does not match the persisted run.`; + this.deps.logger.warn(`[runtime-bridge] ${error}`); + return { ok: false, error }; + } + return { ok: true }; + } + /** * Evicts the per-run `activeRuns` context, the per-session store cache, * and the inbound-correlation log. Callers must invoke after a @@ -136,12 +164,14 @@ export class RuntimeBridge { const ctx = this.activeRuns.get(runId); let result: RunResourceReleaseResult = { ok: true }; try { - if (ctx) result = this.reconcileArtifactsForRun(ctx); + if (ctx) { + const current = this.assertRunContextCurrent(ctx, "releaseRunResources"); + if (!current.ok) return { ok: true }; + result = this.reconcileArtifactsForRun(ctx); + } return result; } finally { - this.activeRuns.delete(runId); - this.inboundCorrelations.deleteRun(runId); - if (ctx) this.sessionStores.delete(ctx.taskSessionId); + this.evictActiveRun(runId); } } @@ -256,6 +286,10 @@ export class RuntimeBridge { if (envelope.event.type === "message_end") { const ctx = this.activeRuns.get(envelope.runId); + if (ctx) { + const current = this.assertRunContextCurrent(ctx, "agent-event"); + if (!current.ok) return; + } const messageRaw = envelope.event.message as | { role?: string; @@ -330,6 +364,8 @@ export class RuntimeBridge { if (!ctx) { return { ok: false, error: `invokeInternalTool: no active run ${runId}` }; } + const current = this.assertRunContextCurrent(ctx, "invokeInternalTool"); + if (!current.ok) return { ok: false, error: current.error }; const callbacks = this.internalToolCallbacks; if (!callbacks) { return { @@ -374,6 +410,13 @@ export class RuntimeBridge { workspacePath: string | null; workspaceMode: "exclusive" | "shared" | "none"; }; + const runState = this.deps.db.core.runs.getRunAuthState(runId); + if (!runState) { + return { ok: false, error: `prepareRun: stale run ${runId} no longer exists.` }; + } + if (runState.companyId !== payload.companyId || runState.agentId !== payload.agentId) { + return { ok: false, error: `prepareRun: run ${runId} context does not match the persisted run.` }; + } const baseline = captureBaselineIfRequired(payload.workspacePath, this.deps.logger); this.registerActiveRun({ runId, @@ -454,6 +497,8 @@ export class RuntimeBridge { if (!ctx) { return { ok: false, error: `beforeToolCall: no active run ${runId}` }; } + const current = this.assertRunContextCurrent(ctx, "beforeToolCall"); + if (!current.ok) return { ok: false, error: current.error }; const result = handleBeforeToolCall(this.deps, ctx, payload); return { ok: true, result }; } diff --git a/src/main/api-server/routes-tasks.ts b/src/main/api-server/routes-tasks.ts index 946f22a9..a00af3ca 100644 --- a/src/main/api-server/routes-tasks.ts +++ b/src/main/api-server/routes-tasks.ts @@ -5,6 +5,7 @@ import { IMPORTANT_DOC_TYPES } from "@shared/document-categories"; import { canAssignTask, canUpdateTask } from "../agent-permissions"; import { inferTaskType } from "../connector-matching"; import { validateTaskCreation } from "../task-quality-gates"; +import { resolveTaskWorkspaceBinding } from "../task-workspace-binding"; import { requireParam, type AgentApiServer } from "./server-core"; const taskUpdateSchema = z.object({ @@ -146,6 +147,21 @@ export function registerTaskRoutes(server: AgentApiServer) { reviewRequest = { reviewerAgentName: actorAgent.name, documents: docs }; } + const nextRequiresFilesystem = update.requiresFilesystem ?? task.requiresFilesystem; + const workspaceBinding = resolveTaskWorkspaceBinding(db, { + companyId: task.companyId, + requiresFilesystem: nextRequiresFilesystem, + explicitWorkspaceId: task.workspaceId, + projectId: task.projectId, + assigneeAgentId: newAssignee, + creatorAgentId: token.agentId, + currentRunId: token.runId ?? null, + }); + if (!workspaceBinding.ok) { + server.json(res, 422, { error: workspaceBinding.error }); + return; + } + const updateResult = db.core.tasks.updateTaskWithReviewSideEffects({ taskId: task.id, companyId: task.companyId, @@ -160,8 +176,8 @@ export function registerTaskRoutes(server: AgentApiServer) { goalId: task.goalId, parentId: task.parentId, assigneeAgentId: newAssignee, - workspaceId: task.workspaceId, - requiresFilesystem: update.requiresFilesystem ?? task.requiresFilesystem, + workspaceId: workspaceBinding.workspaceId, + requiresFilesystem: nextRequiresFilesystem, }, comment: update.comment, reviewRequest, @@ -297,6 +313,19 @@ export function registerTaskRoutes(server: AgentApiServer) { let id: string; try { + const workspaceBinding = resolveTaskWorkspaceBinding(db, { + companyId, + requiresFilesystem: input.requiresFilesystem, + explicitWorkspaceId: input.workspaceId ?? null, + projectId: input.projectId ?? null, + assigneeAgentId: input.assigneeAgentId ?? null, + creatorAgentId: token.agentId, + currentRunId: token.runId ?? null, + }); + if (!workspaceBinding.ok) { + server.json(res, 422, { error: workspaceBinding.error }); + return; + } id = db.core.tasks.saveTask({ companyId, title: input.title, @@ -307,7 +336,7 @@ export function registerTaskRoutes(server: AgentApiServer) { goalId: input.goalId ?? null, priority: input.priority, status: input.status, - workspaceId: input.workspaceId ?? null, + workspaceId: workspaceBinding.workspaceId, requiresFilesystem: input.requiresFilesystem, }); } catch (error) { diff --git a/src/main/automation/automation-action-handlers.ts b/src/main/automation/automation-action-handlers.ts index 47b173b3..df7dece6 100644 --- a/src/main/automation/automation-action-handlers.ts +++ b/src/main/automation/automation-action-handlers.ts @@ -1,6 +1,7 @@ import type { AutomationAction } from "@shared/types"; import { dispatchAgentMessage } from "../messaging/dispatch"; import { validateTaskCreation } from "../task-quality-gates"; +import { resolveTaskWorkspaceBinding } from "../task-workspace-binding"; import { createAutomationActionHandler, type AutomationActionHandler, @@ -106,12 +107,26 @@ function handleCreateTask({ deps, callbacks, ctx, config }: TypedAutomationActio return; } + const workspaceBinding = resolveTaskWorkspaceBinding(db, { + companyId: ctx.companyId, + requiresFilesystem: config.requiresFilesystem, + explicitWorkspaceId: config.workspaceId ?? null, + projectId: ctx.projectId ?? config.projectId ?? null, + assigneeAgentId, + creatorAgentId: ctx.agentId ?? null, + }); + if (!workspaceBinding.ok) { + logger.warn(`[automation] Skipping create_task - ${workspaceBinding.error}`); + return; + } + const taskId = db.core.tasks.saveTask({ companyId: ctx.companyId, projectId: ctx.projectId ?? config.projectId ?? null, title, description, assigneeAgentId, + workspaceId: workspaceBinding.workspaceId, priority, status: assigneeAgentId ? "todo" : "backlog", requiresFilesystem: config.requiresFilesystem, diff --git a/src/main/automation/automation-action-schemas.ts b/src/main/automation/automation-action-schemas.ts index d8da6d83..724ff8dc 100644 --- a/src/main/automation/automation-action-schemas.ts +++ b/src/main/automation/automation-action-schemas.ts @@ -24,6 +24,7 @@ export const createTaskConfigSchema = z.object({ priority: taskPrioritySchema.optional(), assigneeAgentId: z.string().nullable().optional(), projectId: z.string().optional(), + workspaceId: z.string().nullable().optional(), requiresFilesystem: z.boolean().default(false), }); diff --git a/src/main/database/migrations/v60-filesystem-task-workspaces.ts b/src/main/database/migrations/v60-filesystem-task-workspaces.ts new file mode 100644 index 00000000..08d7c25a --- /dev/null +++ b/src/main/database/migrations/v60-filesystem-task-workspaces.ts @@ -0,0 +1,130 @@ +import type { DatabaseSync } from "node:sqlite"; +import { nowIso, tableExists } from "./types"; + +export const FILESYSTEM_TASK_WORKSPACES_SCHEMA_VERSION = 60; +export const FILESYSTEM_TASK_WORKSPACES_SCHEMA_NAME = "filesystem_task_workspaces"; + +const ACTIVE_TASK_STATUSES = "'backlog', 'todo', 'in_progress', 'blocked'"; + +export function migrateFilesystemTaskWorkspacesV60(db: DatabaseSync): void { + backfillProjectWorkspaces(db); + backfillAssigneeWorkspaces(db); + backfillSingleCompanyWorkspaces(db); + db + .prepare("insert or replace into schema_migrations (version, name, applied_at) values (?, ?, ?)") + .run(FILESYSTEM_TASK_WORKSPACES_SCHEMA_VERSION, FILESYSTEM_TASK_WORKSPACES_SCHEMA_NAME, nowIso()); + assertFilesystemTaskWorkspacesV60(db); +} + +export function assertFilesystemTaskWorkspacesV60(db: DatabaseSync): void { + if (!tableExists(db, "tasks") || !tableExists(db, "workspaces")) { + throw new Error("Schema v60 drift: tasks/workspaces tables are missing."); + } + const unresolved = db + .prepare( + `select count(*) as count + from tasks t + where t.requires_filesystem = 1 + and t.workspace_id is null + and t.status in (${ACTIVE_TASK_STATUSES}) + and ( + select count(*) + from workspaces w + where w.company_id = t.company_id + ) = 1`, + ) + .get() as { count: number } | undefined; + if ((unresolved?.count ?? 0) > 0) { + throw new Error("Schema v60 drift: single-workspace companies still have unresolved filesystem tasks."); + } +} + +function backfillProjectWorkspaces(db: DatabaseSync): void { + if (!hasRequiredTables(db)) return; + db + .prepare( + `update tasks + set workspace_id = ( + select w.id + from workspaces w + where w.company_id = tasks.company_id + and w.project_id = tasks.project_id + order by w.is_primary desc, w.created_at asc + limit 1 + ), + updated_at = ? + where requires_filesystem = 1 + and workspace_id is null + and status in (${ACTIVE_TASK_STATUSES}) + and project_id is not null + and exists ( + select 1 + from workspaces w + where w.company_id = tasks.company_id + and w.project_id = tasks.project_id + )`, + ) + .run(nowIso()); +} + +function backfillAssigneeWorkspaces(db: DatabaseSync): void { + if (!hasRequiredTables(db) || !tableExists(db, "agents")) return; + db + .prepare( + `update tasks + set workspace_id = ( + select a.workspace_id + from agents a + join workspaces w + on w.id = a.workspace_id + and w.company_id = a.company_id + where a.id = tasks.assignee_agent_id + and a.company_id = tasks.company_id + limit 1 + ), + updated_at = ? + where requires_filesystem = 1 + and workspace_id is null + and status in (${ACTIVE_TASK_STATUSES}) + and assignee_agent_id is not null + and exists ( + select 1 + from agents a + join workspaces w + on w.id = a.workspace_id + and w.company_id = a.company_id + where a.id = tasks.assignee_agent_id + and a.company_id = tasks.company_id + )`, + ) + .run(nowIso()); +} + +function backfillSingleCompanyWorkspaces(db: DatabaseSync): void { + if (!hasRequiredTables(db)) return; + db + .prepare( + `update tasks + set workspace_id = ( + select w.id + from workspaces w + where w.company_id = tasks.company_id + order by w.is_primary desc, w.created_at asc + limit 1 + ), + updated_at = ? + where requires_filesystem = 1 + and workspace_id is null + and status in (${ACTIVE_TASK_STATUSES}) + and ( + select count(*) + from workspaces w + where w.company_id = tasks.company_id + ) = 1`, + ) + .run(nowIso()); +} + +function hasRequiredTables(db: DatabaseSync): boolean { + return tableExists(db, "tasks") && tableExists(db, "workspaces"); +} diff --git a/src/main/database/model-capabilities-crud.ts b/src/main/database/model-capabilities-crud.ts index c6382a93..c09584ed 100644 --- a/src/main/database/model-capabilities-crud.ts +++ b/src/main/database/model-capabilities-crud.ts @@ -92,12 +92,27 @@ export function buildModelCapabilityMethods( */ function seedModelCapabilities(rows: Array>): number { return transaction(() => { - const existing = readAll<{ cnt: number }>( - "SELECT COUNT(*) AS cnt FROM model_capabilities", - )[0]?.cnt ?? 0; - if (existing > 0) return 0; - for (const row of rows) upsertModelCapability(row); - return rows.length; + let inserted = 0; + const ts = nowIso(); + const stmt = getDb().prepare( + `INSERT OR IGNORE INTO model_capabilities ( + provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const row of rows) { + const result = stmt.run( + row.provider, + row.modelId, + row.reasoning, + row.coding, + row.writing, + row.tier, + row.costPerMtokOut, + ts, + ); + inserted += Number(result.changes ?? 0); + } + return inserted; }); } diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 67117aa5..db6ca50a 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -24,12 +24,17 @@ import { assertSocialCompanyScopeV59, migrateSocialCompanyScopeV59, } from "./migrations/v59-social-company-scope"; +import { + FILESYSTEM_TASK_WORKSPACES_SCHEMA_VERSION, + assertFilesystemTaskWorkspacesV60, + migrateFilesystemTaskWorkspacesV60, +} from "./migrations/v60-filesystem-task-workspaces"; import { type ReadAll, type Transaction, } from "./migrations/types"; -const LATEST_SCHEMA_VERSION = SOCIAL_COMPANY_SCOPE_SCHEMA_VERSION; +const LATEST_SCHEMA_VERSION = FILESYSTEM_TASK_WORKSPACES_SCHEMA_VERSION; export function initializeDatabase( db: DatabaseSync, @@ -50,6 +55,18 @@ export function initializeDatabase( assertTaskLeasesV57(db); assertApprovalPoliciesV58(db); assertSocialCompanyScopeV59(db); + assertFilesystemTaskWorkspacesV60(db); + }); + return; + } + + if (currentVersion === SOCIAL_COMPANY_SCOPE_SCHEMA_VERSION) { + tx(() => { + seedSettings(db); + assertTaskLeasesV57(db); + assertApprovalPoliciesV58(db); + assertSocialCompanyScopeV59(db); + migrateFilesystemTaskWorkspacesV60(db); }); return; } @@ -60,6 +77,7 @@ export function initializeDatabase( assertTaskLeasesV57(db); assertApprovalPoliciesV58(db); migrateSocialCompanyScopeV59(db); + migrateFilesystemTaskWorkspacesV60(db); }); return; } @@ -70,6 +88,7 @@ export function initializeDatabase( assertTaskLeasesV57(db); migrateApprovalPoliciesV58(db); migrateSocialCompanyScopeV59(db); + migrateFilesystemTaskWorkspacesV60(db); }); return; } @@ -83,6 +102,7 @@ export function initializeDatabase( migrateTaskLeasesV57(db); migrateApprovalPoliciesV58(db); migrateSocialCompanyScopeV59(db); + migrateFilesystemTaskWorkspacesV60(db); }); return; } @@ -97,6 +117,7 @@ export function initializeDatabase( migrateTaskLeasesV57(db); migrateApprovalPoliciesV58(db); migrateSocialCompanyScopeV59(db); + migrateFilesystemTaskWorkspacesV60(db); }); return; } @@ -109,6 +130,7 @@ export function initializeDatabase( migrateTaskLeasesV57(db); migrateApprovalPoliciesV58(db); migrateSocialCompanyScopeV59(db); + migrateFilesystemTaskWorkspacesV60(db); }); } diff --git a/src/main/ipc-core-task-graph-handlers.ts b/src/main/ipc-core-task-graph-handlers.ts index c11938e4..b60826b0 100644 --- a/src/main/ipc-core-task-graph-handlers.ts +++ b/src/main/ipc-core-task-graph-handlers.ts @@ -7,6 +7,7 @@ import { } from "@shared/contracts"; import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; import { validateTaskCreation, checkCapabilityMatch } from "./task-quality-gates"; +import { resolveTaskWorkspaceBinding } from "./task-workspace-binding"; export function registerCoreTaskGraphHandlers({ registerHandle, @@ -109,6 +110,7 @@ export function registerCoreTaskGraphHandlers({ } let previousStatus: string | null = null; let previousAssigneeAgentId: string | null = null; + let existingWorkspaceId: string | null = null; if (!isNew) { const taskId = parsed.id; if (!taskId) return fail("INVALID_TASK_STATE", "Task ID required for updates."); @@ -116,15 +118,30 @@ export function registerCoreTaskGraphHandlers({ const existingTask = db.core.entity.getTask(taskId); previousStatus = existingTask.status; previousAssigneeAgentId = existingTask.assigneeAgentId; + existingWorkspaceId = existingTask.workspaceId; } catch (err) { logger.warn(`[ipc/task-graph] getTask failed for ${taskId}; treating as new task:`, err instanceof Error ? err.message : String(err)); previousStatus = null; previousAssigneeAgentId = null; + existingWorkspaceId = null; } } let id: string; try { - id = db.core.tasks.saveTask(parsed); + const workspaceBinding = resolveTaskWorkspaceBinding(db, { + companyId: parsed.companyId, + requiresFilesystem: parsed.requiresFilesystem, + explicitWorkspaceId: parsed.workspaceId ?? (parsed.requiresFilesystem ? existingWorkspaceId : null), + projectId: parsed.projectId ?? null, + assigneeAgentId: parsed.assigneeAgentId ?? null, + }); + if (!workspaceBinding.ok) { + return fail("INVALID_TASK_STATE", workspaceBinding.error); + } + id = db.core.tasks.saveTask({ + ...parsed, + workspaceId: workspaceBinding.workspaceId, + }); } catch (error) { return fail("INVALID_TASK_STATE", error instanceof Error ? error.message : "Task could not be saved."); } diff --git a/src/main/model-capability-seed.ts b/src/main/model-capability-seed.ts index f6648309..5dc3e924 100644 --- a/src/main/model-capability-seed.ts +++ b/src/main/model-capability-seed.ts @@ -1,31 +1,14 @@ import type { ModelCapability } from "@shared/types"; +import { CONNECTORS } from "./connector-definitions"; +import { resolveModel, resolveModelCostFallback, resolveModels } from "./model-resolver"; -/** - * Initial capability matrix written into the DB on first boot when the - * table is empty. The user's five-axis ranking from the dispatch design - * discussion is compressed to three axes: - * - * reasoning = architecture/review ∧ math ∧ research - * coding = coding (unchanged) - * writing = language/copy (unchanged — the other two collapsed upward) - * - * Scores are 1..5 on each axis. Tier is the hard lower bound the dispatcher - * clears before considering `costPerMtokOut` — a tier-3 (`hard`) task never - * runs on a tier-1 (`trivial`) model even if the trivial model's domain - * score happens to clear the numeric threshold. - * - * Cost-per-Mtok-output figures are the public list price at the provider's - * own API as of 2026-04 (approximate; the table is user-editable after - * seeding so prices can be corrected in-app without a code change). - * - * Adding a new model: append a row here AND publish a migration or an IPC - * call that writes it through `upsertModelCapability` — the seed only runs - * when the table is empty, so it does not back-fill on upgrade. - */ +type ModelCapabilitySeedRow = Omit; +type CapabilityProfile = Pick; +type CatalogModel = { id: string; name: string }; -export const MODEL_CAPABILITY_SEED: Array> = [ - // ── Anthropic (Claude) ──────────────────────────────────────────────────── - // Claude Opus 4.7: strong across the board, top coding, top writing. +const DEFAULT_COST_PER_MTOK_OUT = 1; + +const CURATED_MODEL_CAPABILITY_SEED: ModelCapabilitySeedRow[] = [ { provider: "anthropic", modelId: "claude-opus-4-7", @@ -50,17 +33,9 @@ export const MODEL_CAPABILITY_SEED: Array> = reasoning: 3, coding: 4, writing: 4, - // Haiku sits at the standard tier. Its scores already clear the - // standard domain floor (>=3), so marking the tier any lower would - // exclude it from standard tasks in the dispatcher's tier-rank guard. - // The cost advantage (~5x cheaper than Sonnet) is the whole point. tier: "standard", costPerMtokOut: 5, }, - - // ── OpenAI (GPT) ────────────────────────────────────────────────────────── - // GPT family leads reasoning/architecture/math/research; coding trails - // Claude; writing trails Claude and Kimi. { provider: "openai", modelId: "gpt-5", @@ -85,15 +60,9 @@ export const MODEL_CAPABILITY_SEED: Array> = reasoning: 3, coding: 3, writing: 3, - // Same reasoning as Haiku: the model clears the standard domain - // floor, so it should live at the standard tier. Llama-70B below is - // the only genuinely trivial-tier seed row. tier: "standard", costPerMtokOut: 2, }, - - // ── Fireworks curated (open-weight) ─────────────────────────────────────── - // Kimi K2: strong writing and coding, near-Claude. Reasoning solid. { provider: "fireworks", modelId: "accounts/fireworks/models/kimi-k2-instruct", @@ -103,7 +72,6 @@ export const MODEL_CAPABILITY_SEED: Array> = tier: "standard", costPerMtokOut: 3, }, - // Kimi K2.6 Turbo via Fire Pass router — newer, faster, similar quality. { provider: "fireworks-firepass", modelId: "accounts/fireworks/routers/kimi-k2p6-turbo", @@ -113,7 +81,6 @@ export const MODEL_CAPABILITY_SEED: Array> = tier: "standard", costPerMtokOut: 3, }, - // GLM 4.5: mid-tier across reasoning and coding, decent writing. { provider: "fireworks", modelId: "accounts/fireworks/models/glm-4p5", @@ -123,7 +90,6 @@ export const MODEL_CAPABILITY_SEED: Array> = tier: "standard", costPerMtokOut: 2, }, - // Qwen3 Coder 480B: coding specialist. { provider: "fireworks", modelId: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct", @@ -133,7 +99,6 @@ export const MODEL_CAPABILITY_SEED: Array> = tier: "standard", costPerMtokOut: 3, }, - // DeepSeek V3.1: strong reasoning, capable coder, writing ok. { provider: "fireworks", modelId: "accounts/fireworks/models/deepseek-v3p1", @@ -143,7 +108,6 @@ export const MODEL_CAPABILITY_SEED: Array> = tier: "standard", costPerMtokOut: 2, }, - // Llama 3.3 70B: generalist trivial-tier fallback. { provider: "fireworks", modelId: "accounts/fireworks/models/llama-v3p3-70b-instruct", @@ -154,3 +118,170 @@ export const MODEL_CAPABILITY_SEED: Array> = costPerMtokOut: 1, }, ]; + +const CURATED_MODEL_CAPABILITY_BY_KEY = new Map( + CURATED_MODEL_CAPABILITY_SEED.map((row) => [capabilityKey(row.provider, row.modelId), row]), +); + +function capabilityKey(provider: string, modelId: string): string { + return `${provider}::${modelId}`; +} + +function profile(reasoning: number, coding: number, writing: number, tier: CapabilityProfile["tier"]): CapabilityProfile { + return { reasoning, coding, writing, tier }; +} + +function searchableText(provider: string, model: CatalogModel): string { + return `${provider} ${model.id} ${model.name}`.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +} + +function hasAny(text: string, terms: string[]): boolean { + return terms.some((term) => text.includes(term)); +} + +function hasWord(text: string, word: string): boolean { + return new RegExp(`\\b${word}\\b`).test(text); +} + +function selectCapabilityProfile(text: string): CapabilityProfile { + if (text.includes("claude") && text.includes("opus")) return profile(5, 5, 5, "hard"); + if (text.includes("claude") && text.includes("sonnet")) return profile(4, 5, 5, "standard"); + if (text.includes("claude") && text.includes("haiku")) return profile(3, 4, 4, "standard"); + + if (text.includes("gpt 5") && text.includes("codex")) return profile(5, 5, 3, "hard"); + if (hasAny(text, ["gpt 5", "gpt 4", "gpt 4o", "o3", "o4"])) return profile(5, 4, 3, "hard"); + if (text.includes("gpt oss 120b")) return profile(4, 4, 3, "standard"); + if (text.includes("gpt oss")) return profile(3, 3, 3, "standard"); + + if (text.includes("gemini") && hasAny(text, ["pro", "ultra"])) return profile(5, 4, 4, "hard"); + if (text.includes("gemini")) return profile(3, 3, 3, "standard"); + + if (text.includes("grok 4") || text.includes("grok 3")) return profile(5, 4, 4, "hard"); + if (text.includes("grok")) return profile(4, 3, 4, "standard"); + + if (hasAny(text, ["glm 5", "zai glm 5"])) return profile(5, 4, 4, "hard"); + if (hasAny(text, ["glm 4 7", "glm 4 6", "zai glm 4 7"])) return profile(4, 4, 4, "standard"); + if (hasAny(text, ["glm 4 5", "glm 4p5"])) return profile(3, 3, 4, "standard"); + + if (text.includes("kimi") && text.includes("thinking")) return profile(5, 4, 5, "hard"); + if (text.includes("kimi")) return profile(4, 4, 5, "standard"); + + if (text.includes("deepseek") && hasAny(text, ["r1", "reasoner"])) return profile(5, 4, 3, "hard"); + if (hasAny(text, ["deepseek", "deepresearch", "qwq", "thinking"])) return profile(4, 4, 3, "standard"); + + if (text.includes("coder") || text.includes("codestral") || text.includes("devstral")) return profile(3, 5, 3, "standard"); + if (text.includes("qwen")) return profile(4, 4, 3, "standard"); + + if (text.includes("minimax") || text.includes("mimo")) return profile(3, 4, 4, "standard"); + if (hasAny(text, ["mistral large", "mistral medium", "magistral"])) return profile(4, 4, 4, "standard"); + if (text.includes("mistral") || text.includes("ministral")) return profile(3, 3, 3, "standard"); + + if (text.includes("nova premier") || text.includes("nova pro")) return profile(4, 3, 3, "standard"); + if (text.includes("nova")) return profile(2, 2, 2, "trivial"); + + if (text.includes("llama 4") || text.includes("llama 70b") || text.includes("llama v3p3 70b")) { + return profile(3, 3, 3, "standard"); + } + if (text.includes("llama")) return profile(2, 2, 2, "trivial"); + + return profile(3, 3, 3, "standard"); +} + +function applySizeAdjustment(selected: CapabilityProfile, text: string): CapabilityProfile { + if (hasWord(text, "nano") || hasWord(text, "micro") || hasWord(text, "3b") || hasWord(text, "7b") || hasWord(text, "8b")) { + return profile(2, 2, 2, "trivial"); + } + + if ( + hasWord(text, "mini") || + hasWord(text, "lite") || + hasWord(text, "flash") || + hasWord(text, "flashx") || + hasWord(text, "air") || + hasWord(text, "small") || + hasWord(text, "instant") || + text.includes("compound mini") + ) { + return profile( + Math.min(selected.reasoning, 3), + Math.min(selected.coding, 3), + Math.min(selected.writing, 3), + selected.tier === "trivial" ? "trivial" : "standard", + ); + } + + return selected; +} + +function inferCapabilityProfile(provider: string, model: CatalogModel): CapabilityProfile { + const text = searchableText(provider, model); + return applySizeAdjustment(selectCapabilityProfile(text), text); +} + +function normalizeCost(value: number): number { + if (!Number.isFinite(value) || value < 0) return DEFAULT_COST_PER_MTOK_OUT; + return Number(value.toFixed(4)); +} + +function resolveCatalogCostPerMtokOut(provider: string, modelId: string): number { + const resolved = resolveModel(provider, modelId); + if (!resolved) return DEFAULT_COST_PER_MTOK_OUT; + const priced = resolveModelCostFallback(resolved, provider); + return normalizeCost(priced.cost.output); +} + +export function deriveModelCapabilitySeed(provider: string, model: CatalogModel): ModelCapabilitySeedRow { + const curated = CURATED_MODEL_CAPABILITY_BY_KEY.get(capabilityKey(provider, model.id)); + const selectedProfile = curated ?? inferCapabilityProfile(provider, model); + return { + provider, + modelId: model.id, + reasoning: selectedProfile.reasoning, + coding: selectedProfile.coding, + writing: selectedProfile.writing, + tier: selectedProfile.tier, + costPerMtokOut: resolveCatalogCostPerMtokOut(provider, model.id), + }; +} + +function listStaticCatalogModels(): Array<{ provider: string; model: CatalogModel }> { + const modelsByKey = new Map(); + + for (const connector of CONNECTORS) { + for (const model of resolveModels(connector.provider)) { + modelsByKey.set(capabilityKey(connector.provider, model.id), { + provider: connector.provider, + model: { id: model.id, name: model.name }, + }); + } + + if (connector.auth.type !== "local" && connector.defaultModel.trim()) { + modelsByKey.set(capabilityKey(connector.provider, connector.defaultModel), { + provider: connector.provider, + model: { id: connector.defaultModel, name: connector.defaultModel }, + }); + } + } + + return Array.from(modelsByKey.values()); +} + +export function buildModelCapabilitySeed(): ModelCapabilitySeedRow[] { + const rowsByKey = new Map(); + + for (const row of CURATED_MODEL_CAPABILITY_SEED) { + rowsByKey.set(capabilityKey(row.provider, row.modelId), row); + } + + for (const entry of listStaticCatalogModels()) { + rowsByKey.set(capabilityKey(entry.provider, entry.model.id), deriveModelCapabilitySeed(entry.provider, entry.model)); + } + + return Array.from(rowsByKey.values()).sort((a, b) => { + const providerOrder = a.provider.localeCompare(b.provider); + if (providerOrder !== 0) return providerOrder; + return a.modelId.localeCompare(b.modelId); + }); +} + +export const MODEL_CAPABILITY_SEED: ModelCapabilitySeedRow[] = buildModelCapabilitySeed(); diff --git a/src/main/onboarding-service.ts b/src/main/onboarding-service.ts index 781c2dc4..52d3f357 100644 --- a/src/main/onboarding-service.ts +++ b/src/main/onboarding-service.ts @@ -127,6 +127,22 @@ export function createOnboardingService({ }); } + function bindWorkspaceToProject(workspaceId: string | null, projectId: string) { + if (!workspaceId) return; + const workspace = db.core.workspaces.getWorkspace(workspaceId); + if (!workspace || workspace.projectId === projectId) return; + db.core.workspaces.saveWorkspace({ + id: workspace.id, + companyId: workspace.companyId, + name: workspace.name, + localPath: workspace.localPath, + repoUrl: workspace.repoUrl, + repoRef: workspace.repoRef, + projectId, + isPrimary: workspace.isPrimary, + }); + } + function createInitialExecutionLoop(companyId: string, workspaceId: string | null): InitialExecutionLoopResult { const companyRows = db.queryAll<{ id: string; name: string; description: string }>( "select id, name, description from companies where id = ? limit 1", @@ -171,6 +187,7 @@ export function createOnboardingService({ targetDate: null, status: "in_progress", }); + bindWorkspaceToProject(workspaceId, projectId); const kickoffLocale = getLocale(); const kickoffTaskTitle = activeGoal diff --git a/src/main/orchestration/heartbeat.ts b/src/main/orchestration/heartbeat.ts index fb2475e9..6c40d822 100644 --- a/src/main/orchestration/heartbeat.ts +++ b/src/main/orchestration/heartbeat.ts @@ -147,7 +147,7 @@ export function createHeartbeat( } const pick = pickModelForAgent(agent, { getConnector: (id) => db.core.connectors.getConnector(id), - getDisabledModels: (id) => db.core.settings.getDisabledModels(agent.companyId, id), + getDisabledModels: (companyId, connectorId) => db.core.settings.getDisabledModels(companyId, connectorId), listModelCapabilities: () => db.core.modelCapabilities.listModelCapabilities(), readConnectors: () => db.core.connectors.readConnectors(), }); diff --git a/src/main/orchestration/task-runner.ts b/src/main/orchestration/task-runner.ts index 16f9d6fd..a3351c17 100644 --- a/src/main/orchestration/task-runner.ts +++ b/src/main/orchestration/task-runner.ts @@ -59,7 +59,7 @@ export function createTaskRunner( } const pick = pickModelForAgent(agent, { getConnector: (id) => db.core.connectors.getConnector(id), - getDisabledModels: (id) => db.core.settings.getDisabledModels(agent.companyId, id), + getDisabledModels: (companyId, connectorId) => db.core.settings.getDisabledModels(companyId, connectorId), listModelCapabilities: () => db.core.modelCapabilities.listModelCapabilities(), readConnectors: () => db.core.connectors.readConnectors(), }); diff --git a/src/main/orchestration/turn-context.ts b/src/main/orchestration/turn-context.ts index 88ea6464..8fe769e8 100644 --- a/src/main/orchestration/turn-context.ts +++ b/src/main/orchestration/turn-context.ts @@ -351,7 +351,7 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps })), companyWorkspaces: snapshot.workspaces .filter((workspace) => workspace.companyId === companyId) - .map((workspace) => ({ name: workspace.name, localPath: workspace.localPath })), + .map((workspace) => ({ id: workspace.id, name: workspace.name, localPath: workspace.localPath })), companyDocuments: snapshot.documents.filter((d) => d.companyId === companyId), }) : null; diff --git a/src/main/prompt-builder/api-docs/tasks.en.md b/src/main/prompt-builder/api-docs/tasks.en.md index f216dd8e..dd06140d 100644 --- a/src/main/prompt-builder/api-docs/tasks.en.md +++ b/src/main/prompt-builder/api-docs/tasks.en.md @@ -16,11 +16,13 @@ Body: { "parentId": "{parentIssueId — for subtasks}", "projectId": "{projectId — for traceability}", "goalId": "{goalId — for strategic alignment}", + "workspaceId": "{workspaceId — required for filesystem work when the task cannot inherit one}", "requiresFilesystem": true, "priority": "critical|high|medium|low", "status": "todo" } Always set parentId when creating subtasks. Always set projectId and goalId when available. +Set workspaceId for filesystem tasks when a workspace id is listed in the Company Capability Registry. Set requiresFilesystem=true for code, file, repository, build, test, or local workspace work. Set requiresFilesystem=false only for pure planning, research, coordination, or review tasks that use API state and documents. Example: Create a task assigned to a report: diff --git a/src/main/prompt-builder/capability-registry.ts b/src/main/prompt-builder/capability-registry.ts index ed5e0c7b..c20cf7bb 100644 --- a/src/main/prompt-builder/capability-registry.ts +++ b/src/main/prompt-builder/capability-registry.ts @@ -15,7 +15,7 @@ export function buildCapabilityRegistrySection(ctx: Pick 0) { const workspaces = ctx.companyWorkspaces .slice(0, 6) - .map((workspace) => `- ${workspace.name}: ${workspace.localPath}`); + .map((workspace) => `- ${workspace.name} (\`${workspace.id}\`): ${workspace.localPath}`); parts.push(`## Workspaces\n${workspaces.join("\n")}`); } diff --git a/src/main/prompt-builder/index.ts b/src/main/prompt-builder/index.ts index a1f67ae3..94861ee9 100644 --- a/src/main/prompt-builder/index.ts +++ b/src/main/prompt-builder/index.ts @@ -44,7 +44,7 @@ export interface PromptContext { /** Company-wide skill catalog for capability-aware delegation. */ companySkillCatalog?: Array<{ name: string; slug: string; description: string | null }>; /** Company workspaces visible to the current run. */ - companyWorkspaces?: Array<{ name: string; localPath: string }>; + companyWorkspaces?: Array<{ id: string; name: string; localPath: string }>; } export function buildTaskPrompt(ctx: PromptContext): string { diff --git a/src/main/task-workspace-binding.ts b/src/main/task-workspace-binding.ts new file mode 100644 index 00000000..73c1b500 --- /dev/null +++ b/src/main/task-workspace-binding.ts @@ -0,0 +1,103 @@ +import type { AppDatabase } from "./database"; + +type WorkspaceBindingSource = + | "explicit" + | "project" + | "assignee" + | "run" + | "creator" + | "single_company" + | "none"; + +export type TaskWorkspaceBindingResult = + | { ok: true; workspaceId: string | null; source: WorkspaceBindingSource } + | { ok: false; error: string }; + +export interface TaskWorkspaceBindingInput { + companyId: string; + requiresFilesystem: boolean; + explicitWorkspaceId?: string | null; + projectId?: string | null; + assigneeAgentId?: string | null; + creatorAgentId?: string | null; + currentRunId?: string | null; +} + +export function resolveTaskWorkspaceBinding( + db: AppDatabase, + input: TaskWorkspaceBindingInput, +): TaskWorkspaceBindingResult { + if (input.explicitWorkspaceId) { + const explicit = getCompanyWorkspace(db, input.companyId, input.explicitWorkspaceId); + if (!explicit) { + return { ok: false, error: "Selected workspace does not belong to this company." }; + } + return { ok: true, workspaceId: explicit.id, source: "explicit" }; + } + + if (!input.requiresFilesystem) { + return { + ok: true, + workspaceId: null, + source: "none", + }; + } + + const project = db.core.workspaces.getProjectPrimaryWorkspace(input.projectId ?? null, input.companyId); + if (project) return { ok: true, workspaceId: project.id, source: "project" }; + + const assignee = getAgentWorkspace(db, input.companyId, input.assigneeAgentId ?? null); + if (assignee) return { ok: true, workspaceId: assignee.id, source: "assignee" }; + + const run = getRunWorkspace(db, input.companyId, input.currentRunId ?? null); + if (run) return { ok: true, workspaceId: run.id, source: "run" }; + + const creator = getAgentWorkspace(db, input.companyId, input.creatorAgentId ?? null); + if (creator) return { ok: true, workspaceId: creator.id, source: "creator" }; + + const singleCompanyWorkspace = getSingleCompanyWorkspace(db, input.companyId); + if (singleCompanyWorkspace) { + return { ok: true, workspaceId: singleCompanyWorkspace.id, source: "single_company" }; + } + + return { + ok: false, + error: "Filesystem tasks require a workspace. Provide workspaceId, bind the project to a workspace, or bind the assignee/creator agent to a workspace.", + }; +} + +function getCompanyWorkspace(db: AppDatabase, companyId: string, workspaceId: string | null): { id: string } | null { + if (!workspaceId) return null; + const workspace = db.core.workspaces.getWorkspace(workspaceId); + return workspace && workspace.companyId === companyId ? workspace : null; +} + +function getAgentWorkspace(db: AppDatabase, companyId: string, agentId: string | null): { id: string } | null { + if (!agentId) return null; + try { + const agent = db.core.entity.getAgent(agentId); + if (agent.companyId !== companyId) return null; + return getCompanyWorkspace(db, companyId, agent.workspaceId); + } catch { + return null; + } +} + +function getRunWorkspace(db: AppDatabase, companyId: string, runId: string | null): { id: string } | null { + if (!runId) return null; + try { + const run = db.core.runs.getRun(runId); + if (run.companyId !== companyId) return null; + return getCompanyWorkspace(db, companyId, run.workspaceId); + } catch { + return null; + } +} + +function getSingleCompanyWorkspace(db: AppDatabase, companyId: string): { id: string } | null { + const workspaces = db.projection.snapshot + .listSnapshot({ companyId }) + .workspaces + .filter((workspace) => workspace.companyId === companyId); + return workspaces.length === 1 ? workspaces[0]! : null; +} diff --git a/tests/integration/artifact-projection-ack.spec.ts b/tests/integration/artifact-projection-ack.spec.ts index c99c8388..fe175083 100644 --- a/tests/integration/artifact-projection-ack.spec.ts +++ b/tests/integration/artifact-projection-ack.spec.ts @@ -66,14 +66,15 @@ async function createHarness() { workspaceId: null, connectorId: "openai-codex", }); + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; const bridge = new RuntimeBridge({ db, emitEvent: vi.fn(), - logger: { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, + logger, credentialResolver: { resolve: vi.fn(async () => ({ apiKey: "test-api-key", extraEnv: {} })), } as unknown as CredentialResolver, @@ -105,6 +106,7 @@ async function createHarness() { agentId, taskId, runId: run.id, + logger, }; } @@ -143,4 +145,43 @@ describe("artifact projection durable ack", () => { harness.db.close(); } }); + + it("evicts deleted run contexts without writing artifact projection events", async () => { + const harness = await createHarness(); + try { + harness.db.core.company.deleteCompany(harness.companyId); + + const result = harness.bridge.releaseRunResources(harness.runId); + + expect(result).toEqual({ ok: true }); + } finally { + harness.db.close(); + } + }); + + it("rejects destructive tool hooks from deleted run contexts before approval lookup", async () => { + const harness = await createHarness(); + try { + harness.db.core.company.deleteCompany(harness.companyId); + + const response = await harness.bridge.handleRpc({ + rpcId: "before-tool-call", + method: "beforeToolCall", + runId: harness.runId, + payload: { + toolName: "bash", + toolCallId: "tool-1", + args: {}, + }, + }); + + expect(response).toEqual({ + ok: false, + error: expect.stringContaining("stale run"), + }); + expect(harness.logger.error).not.toHaveBeenCalled(); + } finally { + harness.db.close(); + } + }); }); diff --git a/tests/integration/connector-model-enforcement.spec.ts b/tests/integration/connector-model-enforcement.spec.ts index 3473a289..3267c1a2 100644 --- a/tests/integration/connector-model-enforcement.spec.ts +++ b/tests/integration/connector-model-enforcement.spec.ts @@ -6,6 +6,7 @@ import { AppDatabase } from "../../src/main/database"; import { createBudgetEnforcementService } from "../../src/main/budget-enforcement-service"; import { createRunDispatch } from "../../src/main/orchestration/run-dispatch"; import { createTaskRunner } from "../../src/main/orchestration/task-runner"; +import { resolveModels } from "../../src/main/model-resolver"; const tempDirs: string[] = []; @@ -64,6 +65,93 @@ function createOrchestrationHarness(db: AppDatabase) { } describe("connector model enforcement", () => { + it("auto picker excludes disabled connector models before run eligibility validation", async () => { + const profileDir = await createTempDir("agentcompany-auto-model-pool-"); + const db = new AppDatabase(profileDir); + db.init(); + + db.core.connectors.updateConnectorHealth({ + id: "openai-codex", + status: "ready", + authState: "authenticated", + version: "test", + lastError: null, + }); + db.core.connectors.updateConnectorHealth({ + id: "zai", + status: "ready", + authState: "authenticated", + version: "test", + lastError: null, + }); + + const companyId = db.core.company.saveCompany({ + name: "Auto Model Pool Co", + description: "Routes around disabled connector model pools", + status: "active", + }); + const workspaceId = db.core.workspaces.saveWorkspace({ + companyId, + name: "Primary", + localPath: profileDir, + repoUrl: "", + repoRef: "", + isPrimary: true, + }); + const ceoId = db.core.agents.saveAgent({ + companyId, + name: "CEO", + role: "CEO", + title: "Chief Executive Officer", + status: "idle", + reportsTo: null, + connectorId: "zai", + workspaceId, + model: "glm-5.1", + capabilities: "Lead the company", + budgetMonthlyUsd: 250, + }); + const architectId = db.core.agents.saveAgent({ + companyId, + name: "Architect", + role: "Architect", + title: "Principal Architect", + status: "idle", + reportsTo: ceoId, + connectorId: "openai-codex", + workspaceId, + model: "gpt-5.4", + modelStrategy: "auto", + capabilities: "Plan technical architecture", + budgetMonthlyUsd: 250, + }); + const taskId = db.core.tasks.saveTask({ + companyId, + title: "Plan architecture", + description: "Needs a hard reasoning model.", + assigneeAgentId: architectId, + workspaceId, + priority: "high", + status: "todo", + }); + + db.core.settings.setDisabledModels( + companyId, + "openai-codex", + resolveModels("openai-codex").map((model) => ({ id: model.id, label: model.name })), + ); + + const { deps, taskRunner } = createOrchestrationHarness(db); + const result = taskRunner.queueTaskRun(taskId, "manual", "board"); + + expect(result.ok).toBe(true); + expect(deps.enqueueWorkerRun).toHaveBeenCalledTimes(1); + expect(deps.enqueueWorkerRun.mock.calls[0]?.[0]?.connectorId).toBe("zai"); + expect(deps.enqueueWorkerRun.mock.calls[0]?.[0]?.model).toMatch(/^glm-/); + + db.close(); + }); + it("blocks queueTaskRun when an agent keeps a disabled explicit model", async () => { const profileDir = await createTempDir("agentcompany-model-enforcement-"); const db = new AppDatabase(profileDir); diff --git a/tests/integration/skill-aware-assignment.spec.ts b/tests/integration/skill-aware-assignment.spec.ts index bc98dd53..248f471a 100644 --- a/tests/integration/skill-aware-assignment.spec.ts +++ b/tests/integration/skill-aware-assignment.spec.ts @@ -200,7 +200,7 @@ describe("skill-aware capability registry", () => { })), companyWorkspaces: snapshot.workspaces .filter((workspace) => workspace.companyId === companyId) - .map((workspace) => ({ name: workspace.name, localPath: workspace.localPath })), + .map((workspace) => ({ id: workspace.id, name: workspace.name, localPath: workspace.localPath })), companyDocuments: [], skills: [ { @@ -214,7 +214,7 @@ describe("skill-aware capability registry", () => { expect(prompt).toContain("# Company Capability Registry"); expect(prompt).toContain("React UI (`react-ui`)"); - expect(prompt).toContain("Primary:"); + expect(prompt).toMatch(/Primary \(`[0-9a-f-]+`\):/); expect(prompt).toContain("Ready Connectors"); expect(prompt).toContain(""); expect(prompt).toContain("react-standards"); diff --git a/tests/unit/capability-registry.test.ts b/tests/unit/capability-registry.test.ts index 8b4b5f4c..7e0df1a1 100644 --- a/tests/unit/capability-registry.test.ts +++ b/tests/unit/capability-registry.test.ts @@ -8,7 +8,7 @@ describe("buildCapabilityRegistrySection", () => { { name: "React Standards", slug: "react-standards", description: "Use when building React UI" }, ], companyWorkspaces: [ - { name: "Primary", localPath: "/tmp/workspace" }, + { id: "workspace-1", name: "Primary", localPath: "/tmp/workspace" }, ], allConnectors: [ { id: "openai-codex", label: "Codex", status: "ready", enabledModels: ["gpt-5.4"] }, @@ -37,7 +37,7 @@ describe("buildCapabilityRegistrySection", () => { expect(section).toContain("# Company Capability Registry"); expect(section).toContain("React Standards (`react-standards`)"); - expect(section).toContain("Primary: /tmp/workspace"); + expect(section).toContain("Primary (`workspace-1`): /tmp/workspace"); expect(section).toContain("Codex (`openai-codex`) — models: gpt-5.4"); expect(section).toContain("architecture_decision"); }); diff --git a/tests/unit/database.test.ts b/tests/unit/database.test.ts index c449b2ac..6b44df64 100644 --- a/tests/unit/database.test.ts +++ b/tests/unit/database.test.ts @@ -482,6 +482,7 @@ describe("AppDatabase", () => { { version: 57, name: "task_leases_runtime_ownership" }, { version: 58, name: "approval_policy_table" }, { version: 59, name: "social_company_scope" }, + { version: 60, name: "filesystem_task_workspaces" }, ]); db.close(); @@ -496,6 +497,7 @@ describe("AppDatabase", () => { { version: 57, name: "task_leases_runtime_ownership" }, { version: 58, name: "approval_policy_table" }, { version: 59, name: "social_company_scope" }, + { version: 60, name: "filesystem_task_workspaces" }, ]); }); diff --git a/tests/unit/legacy-baseline-migration.test.ts b/tests/unit/legacy-baseline-migration.test.ts index 7461ce04..e0accd21 100644 --- a/tests/unit/legacy-baseline-migration.test.ts +++ b/tests/unit/legacy-baseline-migration.test.ts @@ -176,6 +176,7 @@ describe("legacy to v56 baseline migration", () => { { version: 57, name: "task_leases_runtime_ownership" }, { version: 58, name: "approval_policy_table" }, { version: 59, name: "social_company_scope" }, + { version: 60, name: "filesystem_task_workspaces" }, ]); expect(appDb.queryAll<{ companyId: string }>("select company_id as companyId from secrets where id = 'secret-1'")[0]) .toEqual({ companyId: "company-1" }); @@ -210,6 +211,7 @@ describe("legacy to v56 baseline migration", () => { { version: 57, name: "task_leases_runtime_ownership" }, { version: 58, name: "approval_policy_table" }, { version: 59, name: "social_company_scope" }, + { version: 60, name: "filesystem_task_workspaces" }, ]); expect(appDb.queryAll("select id from budget_policies where scope_type = 'agent' and scope_id = 'agent-1'")) .toHaveLength(1); @@ -235,6 +237,7 @@ describe("legacy to v56 baseline migration", () => { { version: 57, name: "task_leases_runtime_ownership" }, { version: 58, name: "approval_policy_table" }, { version: 59, name: "social_company_scope" }, + { version: 60, name: "filesystem_task_workspaces" }, ]); } finally { appDb.close(); @@ -394,6 +397,7 @@ describe("legacy to v56 baseline migration", () => { { version: 57, name: "task_leases_runtime_ownership" }, { version: 58, name: "approval_policy_table" }, { version: 59, name: "social_company_scope" }, + { version: 60, name: "filesystem_task_workspaces" }, ]); } finally { appDb.close(); diff --git a/tests/unit/model-capability-seed.test.ts b/tests/unit/model-capability-seed.test.ts new file mode 100644 index 00000000..0780a963 --- /dev/null +++ b/tests/unit/model-capability-seed.test.ts @@ -0,0 +1,101 @@ +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdtemp, rm } from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConnectorId } from "@shared/types"; + +vi.mock("electron", () => ({ + safeStorage: { + isEncryptionAvailable: () => false, + getSelectedStorageBackend: () => "basic_text", + encryptString: (value: string) => Buffer.from(value, "utf8"), + decryptString: (value: Buffer) => value.toString("utf8"), + }, +})); + +import { AppDatabase } from "../../src/main/database"; +import { CONNECTORS } from "../../src/main/connector-definitions"; +import { resolveModels } from "../../src/main/model-resolver"; +import { pickFromCandidates } from "../../src/main/model-dispatch"; + +describe("model capability seed", () => { + let tempDir: string; + let db: AppDatabase; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "agentcompany-model-capabilities-")); + db = new AppDatabase(tempDir); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("keeps catalog-seeded rows current on existing profiles without overwriting user edits", () => { + db.core.modelCapabilities.upsertModelCapability({ + provider: "zai", + modelId: "glm-4.6", + reasoning: 2, + coding: 2, + writing: 2, + tier: "trivial", + costPerMtokOut: 99, + }); + db.core.modelCapabilities.deleteModelCapability("zai", "glm-5.1"); + + db.close(); + db = new AppDatabase(tempDir); + db.init(); + + expect(db.core.modelCapabilities.getModelCapability("zai", "glm-5.1")).toMatchObject({ + provider: "zai", + modelId: "glm-5.1", + reasoning: 5, + coding: 4, + writing: 4, + tier: "hard", + }); + expect(db.core.modelCapabilities.getModelCapability("zai", "glm-4.6")).toMatchObject({ + reasoning: 2, + coding: 2, + writing: 2, + tier: "trivial", + costPerMtokOut: 99, + }); + }); + + it("seeds a capability row for every static connector catalog model", () => { + const seeded = new Set( + db.core.modelCapabilities + .listModelCapabilities() + .map((entry) => `${entry.provider}::${entry.modelId}`), + ); + const expected = new Set(); + + for (const connector of CONNECTORS) { + for (const model of resolveModels(connector.provider)) { + expected.add(`${connector.provider}::${model.id}`); + } + if (connector.auth.type !== "local" && connector.defaultModel.trim()) { + expected.add(`${connector.provider}::${connector.defaultModel}`); + } + } + + expect(expected.size).toBeGreaterThan(700); + for (const key of expected) { + expect(seeded.has(key), key).toBe(true); + } + }); + + it("uses the same catalog seed path for hard reasoning picks", () => { + const winner = pickFromCandidates( + { domain: "reasoning", requiredTier: "hard" }, + [{ provider: "zai", connectorId: "zai" as ConnectorId, modelId: "glm-5.1" }], + db.core.modelCapabilities.listModelCapabilities(), + ); + + expect(winner?.modelId).toBe("glm-5.1"); + }); +}); diff --git a/tests/unit/requires-filesystem-migration.test.ts b/tests/unit/requires-filesystem-migration.test.ts index 4403d510..ca6dcb12 100644 --- a/tests/unit/requires-filesystem-migration.test.ts +++ b/tests/unit/requires-filesystem-migration.test.ts @@ -18,6 +18,7 @@ vi.mock("electron", () => ({ })); import { AppDatabase } from "../../src/main/database"; +import { migrateFilesystemTaskWorkspacesV60 } from "../../src/main/database/migrations/v60-filesystem-task-workspaces"; const tempDirs: string[] = []; @@ -116,16 +117,74 @@ describe("legacy to v56 baseline migration", () => { { id: "task-inflight", requiresFilesystem: 1 }, ]); const migrations = appDb.queryAll<{ version: number; name: string }>( - "select version, name from schema_migrations where version in (56, 57, 58, 59) order by version", + "select version, name from schema_migrations where version in (56, 57, 58, 59, 60) order by version", ); expect(migrations).toEqual([ { version: 56, name: "baseline_v56" }, { version: 57, name: "task_leases_runtime_ownership" }, { version: 58, name: "approval_policy_table" }, { version: 59, name: "social_company_scope" }, + { version: 60, name: "filesystem_task_workspaces" }, ]); } finally { appDb.close(); } }); + + it("backfills active filesystem tasks in single-workspace companies", async () => { + const profileDir = await createTempDir("agentcompany-fs-workspace-v60-"); + const rawDb = new DatabaseSync(join(profileDir, "profile.sqlite")); + const ts = "2026-05-15T00:00:00.000Z"; + try { + rawDb.exec(` + create table schema_migrations (version integer primary key, name text not null, applied_at text not null); + create table workspaces ( + id text primary key, + company_id text not null, + project_id text, + is_primary integer not null, + created_at text not null + ); + create table agents ( + id text primary key, + company_id text not null, + workspace_id text + ); + create table tasks ( + id text primary key, + company_id text not null, + project_id text, + assignee_agent_id text, + workspace_id text, + status text not null, + requires_filesystem integer not null, + updated_at text not null + ); + `); + rawDb + .prepare("insert into workspaces (id, company_id, project_id, is_primary, created_at) values (?, ?, ?, ?, ?)") + .run("workspace-1", "company-1", null, 1, ts); + rawDb + .prepare("insert into agents (id, company_id, workspace_id) values (?, ?, ?)") + .run("agent-1", "company-1", null); + rawDb + .prepare("insert into tasks (id, company_id, project_id, assignee_agent_id, workspace_id, status, requires_filesystem, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)") + .run("task-stuck", "company-1", "project-1", "agent-1", null, "todo", 1, ts); + rawDb + .prepare("insert into tasks (id, company_id, project_id, assignee_agent_id, workspace_id, status, requires_filesystem, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)") + .run("task-cognitive", "company-1", "project-1", "agent-1", null, "todo", 0, ts); + + migrateFilesystemTaskWorkspacesV60(rawDb); + + const rows = rawDb + .prepare("select id, workspace_id as workspaceId from tasks order by id") + .all() as Array<{ id: string; workspaceId: string | null }>; + expect(rows).toEqual([ + { id: "task-cognitive", workspaceId: null }, + { id: "task-stuck", workspaceId: "workspace-1" }, + ]); + } finally { + rawDb.close(); + } + }); }); diff --git a/tests/unit/task-workspace-binding.test.ts b/tests/unit/task-workspace-binding.test.ts new file mode 100644 index 00000000..0b29e986 --- /dev/null +++ b/tests/unit/task-workspace-binding.test.ts @@ -0,0 +1,108 @@ +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdtemp, rm } from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + safeStorage: { + isEncryptionAvailable: () => false, + getSelectedStorageBackend: () => "basic_text", + encryptString: (value: string) => Buffer.from(value, "utf8"), + decryptString: (value: Buffer) => value.toString("utf8"), + }, +})); + +import { AppDatabase } from "../../src/main/database"; +import { resolveTaskWorkspaceBinding } from "../../src/main/task-workspace-binding"; + +describe("resolveTaskWorkspaceBinding", () => { + let tempDir: string; + let db: AppDatabase; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "agentcompany-task-workspace-binding-")); + db = new AppDatabase(tempDir); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("binds filesystem tasks to the creator agent workspace", () => { + const { companyId, workspaceId, agentId } = seedCompany(db); + const result = resolveTaskWorkspaceBinding(db, { + companyId, + requiresFilesystem: true, + creatorAgentId: agentId, + }); + + expect(result).toEqual({ ok: true, workspaceId, source: "creator" }); + }); + + it("binds filesystem tasks to the only company workspace", () => { + const { companyId, workspaceId } = seedCompany(db, { agentWorkspace: false }); + const result = resolveTaskWorkspaceBinding(db, { + companyId, + requiresFilesystem: true, + }); + + expect(result).toEqual({ ok: true, workspaceId, source: "single_company" }); + }); + + it("rejects filesystem tasks when the workspace is ambiguous", () => { + const { companyId } = seedCompany(db, { agentWorkspace: false }); + db.core.workspaces.saveWorkspace({ + companyId, + name: "Second", + localPath: "/tmp/second", + repoUrl: "", + repoRef: "", + isPrimary: false, + }); + + const result = resolveTaskWorkspaceBinding(db, { + companyId, + requiresFilesystem: true, + }); + + expect(result.ok).toBe(false); + }); +}); + +function seedCompany(db: AppDatabase, options: { agentWorkspace?: boolean } = {}) { + const companyId = db.core.company.saveCompany({ + name: "Workspace Binding Co", + description: "", + status: "active", + }); + db.core.connectors.saveConnector({ + id: "openai-codex", + model: "gpt-5", + envBindingText: "", + notes: "", + }); + const workspaceId = db.core.workspaces.saveWorkspace({ + companyId, + name: "Default", + localPath: "/tmp/default", + repoUrl: "", + repoRef: "", + isPrimary: true, + }); + const agentId = db.core.agents.saveAgent({ + companyId, + name: "CEO", + role: "CEO", + title: "CEO", + status: "active", + reportsTo: null, + connectorId: "openai-codex", + workspaceId: options.agentWorkspace === false ? null : workspaceId, + model: null, + capabilities: "Leadership", + budgetMonthlyUsd: 100, + }); + return { companyId, workspaceId, agentId }; +}