This commit is contained in:
ZacharyZhang-NY committed 2026-05-15 19:40:20 -04:00
1 parent 2ab001b18a
commit 810e2d5967
28 files changed
+1015 -85

No files matched your search

+10 -10
View File
@@ -16,7 +16,7 @@ export type PlatformDownload = {
assets: PlatformDownloadAsset[]; assets: PlatformDownloadAsset[];
}; };
export const releaseVersion = "0.4.180"; export const releaseVersion = "0.4.182";
export const platformDownloads: PlatformDownload[] = [ export const platformDownloads: PlatformDownload[] = [
{ {
@@ -29,14 +29,14 @@ export const platformDownloads: PlatformDownload[] = [
architectures: ["arm64"], architectures: ["arm64"],
id: "macos-arm64-dmg", id: "macos-arm64-dmg",
label: "Apple Silicon 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", note: "arm64",
}, },
{ {
architectures: ["x64"], architectures: ["x64"],
id: "macos-x64-dmg", id: "macos-x64-dmg",
label: "Intel Mac 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", note: "x64",
}, },
], ],
@@ -51,7 +51,7 @@ export const platformDownloads: PlatformDownload[] = [
architectures: ["arm64", "x64"], architectures: ["arm64", "x64"],
id: "windows-universal-installer", id: "windows-universal-installer",
label: "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", note: "arm64 + x64",
}, },
], ],
@@ -66,42 +66,42 @@ export const platformDownloads: PlatformDownload[] = [
architectures: ["x64"], architectures: ["x64"],
id: "linux-x64-appimage", id: "linux-x64-appimage",
label: "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", note: "x64",
}, },
{ {
architectures: ["x64"], architectures: ["x64"],
id: "linux-x64-deb", id: "linux-x64-deb",
label: "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", note: "x64",
}, },
{ {
architectures: ["x64"], architectures: ["x64"],
id: "linux-x64-rpm", id: "linux-x64-rpm",
label: "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", note: "x64",
}, },
{ {
architectures: ["arm64"], architectures: ["arm64"],
id: "linux-arm64-appimage", id: "linux-arm64-appimage",
label: "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", note: "arm64",
}, },
{ {
architectures: ["arm64"], architectures: ["arm64"],
id: "linux-arm64-deb", id: "linux-arm64-deb",
label: "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", note: "arm64",
}, },
{ {
architectures: ["arm64"], architectures: ["arm64"],
id: "linux-arm64-rpm", id: "linux-arm64-rpm",
label: "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", note: "arm64",
}, },
], ],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agent-company", "name": "agent-company",
"version": "0.4.180", "version": "0.4.182",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.", "description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
+49 -4
View File
@@ -121,6 +121,34 @@ export class RuntimeBridge {
this.getOrCreateStore(context.taskSessionId); 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, * Evicts the per-run `activeRuns` context, the per-session store cache,
* and the inbound-correlation log. Callers must invoke after a * and the inbound-correlation log. Callers must invoke after a
@@ -136,12 +164,14 @@ export class RuntimeBridge {
const ctx = this.activeRuns.get(runId); const ctx = this.activeRuns.get(runId);
let result: RunResourceReleaseResult = { ok: true }; let result: RunResourceReleaseResult = { ok: true };
try { 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; return result;
} finally { } finally {
this.activeRuns.delete(runId); this.evictActiveRun(runId);
this.inboundCorrelations.deleteRun(runId);
if (ctx) this.sessionStores.delete(ctx.taskSessionId);
} }
} }
@@ -256,6 +286,10 @@ export class RuntimeBridge {
if (envelope.event.type === "message_end") { if (envelope.event.type === "message_end") {
const ctx = this.activeRuns.get(envelope.runId); 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 const messageRaw = envelope.event.message as
| { | {
role?: string; role?: string;
@@ -330,6 +364,8 @@ export class RuntimeBridge {
if (!ctx) { if (!ctx) {
return { ok: false, error: `invokeInternalTool: no active run ${runId}` }; 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; const callbacks = this.internalToolCallbacks;
if (!callbacks) { if (!callbacks) {
return { return {
@@ -374,6 +410,13 @@ export class RuntimeBridge {
workspacePath: string | null; workspacePath: string | null;
workspaceMode: "exclusive" | "shared" | "none"; 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); const baseline = captureBaselineIfRequired(payload.workspacePath, this.deps.logger);
this.registerActiveRun({ this.registerActiveRun({
runId, runId,
@@ -454,6 +497,8 @@ export class RuntimeBridge {
if (!ctx) { if (!ctx) {
return { ok: false, error: `beforeToolCall: no active run ${runId}` }; 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); const result = handleBeforeToolCall(this.deps, ctx, payload);
return { ok: true, result }; return { ok: true, result };
} }
+32 -3
View File
@@ -5,6 +5,7 @@ import { IMPORTANT_DOC_TYPES } from "@shared/document-categories";
import { canAssignTask, canUpdateTask } from "../agent-permissions"; import { canAssignTask, canUpdateTask } from "../agent-permissions";
import { inferTaskType } from "../connector-matching"; import { inferTaskType } from "../connector-matching";
import { validateTaskCreation } from "../task-quality-gates"; import { validateTaskCreation } from "../task-quality-gates";
import { resolveTaskWorkspaceBinding } from "../task-workspace-binding";
import { requireParam, type AgentApiServer } from "./server-core"; import { requireParam, type AgentApiServer } from "./server-core";
const taskUpdateSchema = z.object({ const taskUpdateSchema = z.object({
@@ -146,6 +147,21 @@ export function registerTaskRoutes(server: AgentApiServer) {
reviewRequest = { reviewerAgentName: actorAgent.name, documents: docs }; 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({ const updateResult = db.core.tasks.updateTaskWithReviewSideEffects({
taskId: task.id, taskId: task.id,
companyId: task.companyId, companyId: task.companyId,
@@ -160,8 +176,8 @@ export function registerTaskRoutes(server: AgentApiServer) {
goalId: task.goalId, goalId: task.goalId,
parentId: task.parentId, parentId: task.parentId,
assigneeAgentId: newAssignee, assigneeAgentId: newAssignee,
workspaceId: task.workspaceId, workspaceId: workspaceBinding.workspaceId,
requiresFilesystem: update.requiresFilesystem ?? task.requiresFilesystem, requiresFilesystem: nextRequiresFilesystem,
}, },
comment: update.comment, comment: update.comment,
reviewRequest, reviewRequest,
@@ -297,6 +313,19 @@ export function registerTaskRoutes(server: AgentApiServer) {
let id: string; let id: string;
try { 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({ id = db.core.tasks.saveTask({
companyId, companyId,
title: input.title, title: input.title,
@@ -307,7 +336,7 @@ export function registerTaskRoutes(server: AgentApiServer) {
goalId: input.goalId ?? null, goalId: input.goalId ?? null,
priority: input.priority, priority: input.priority,
status: input.status, status: input.status,
workspaceId: input.workspaceId ?? null, workspaceId: workspaceBinding.workspaceId,
requiresFilesystem: input.requiresFilesystem, requiresFilesystem: input.requiresFilesystem,
}); });
} catch (error) { } catch (error) {
@@ -1,6 +1,7 @@
import type { AutomationAction } from "@shared/types"; import type { AutomationAction } from "@shared/types";
import { dispatchAgentMessage } from "../messaging/dispatch"; import { dispatchAgentMessage } from "../messaging/dispatch";
import { validateTaskCreation } from "../task-quality-gates"; import { validateTaskCreation } from "../task-quality-gates";
import { resolveTaskWorkspaceBinding } from "../task-workspace-binding";
import { import {
createAutomationActionHandler, createAutomationActionHandler,
type AutomationActionHandler, type AutomationActionHandler,
@@ -106,12 +107,26 @@ function handleCreateTask({ deps, callbacks, ctx, config }: TypedAutomationActio
return; 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({ const taskId = db.core.tasks.saveTask({
companyId: ctx.companyId, companyId: ctx.companyId,
projectId: ctx.projectId ?? config.projectId ?? null, projectId: ctx.projectId ?? config.projectId ?? null,
title, title,
description, description,
assigneeAgentId, assigneeAgentId,
workspaceId: workspaceBinding.workspaceId,
priority, priority,
status: assigneeAgentId ? "todo" : "backlog", status: assigneeAgentId ? "todo" : "backlog",
requiresFilesystem: config.requiresFilesystem, requiresFilesystem: config.requiresFilesystem,
@@ -24,6 +24,7 @@ export const createTaskConfigSchema = z.object({
priority: taskPrioritySchema.optional(), priority: taskPrioritySchema.optional(),
assigneeAgentId: z.string().nullable().optional(), assigneeAgentId: z.string().nullable().optional(),
projectId: z.string().optional(), projectId: z.string().optional(),
workspaceId: z.string().nullable().optional(),
requiresFilesystem: z.boolean().default(false), requiresFilesystem: z.boolean().default(false),
}); });
@@ -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");
}
+21 -6
View File
@@ -92,12 +92,27 @@ export function buildModelCapabilityMethods(
*/ */
function seedModelCapabilities(rows: Array<Omit<ModelCapability, "updatedAt">>): number { function seedModelCapabilities(rows: Array<Omit<ModelCapability, "updatedAt">>): number {
return transaction(() => { return transaction(() => {
const existing = readAll<{ cnt: number }>( let inserted = 0;
"SELECT COUNT(*) AS cnt FROM model_capabilities", const ts = nowIso();
)[0]?.cnt ?? 0; const stmt = getDb().prepare(
if (existing > 0) return 0; `INSERT OR IGNORE INTO model_capabilities (
for (const row of rows) upsertModelCapability(row); provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at
return rows.length; ) 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;
}); });
} }
+23 -1
View File
@@ -24,12 +24,17 @@ import {
assertSocialCompanyScopeV59, assertSocialCompanyScopeV59,
migrateSocialCompanyScopeV59, migrateSocialCompanyScopeV59,
} from "./migrations/v59-social-company-scope"; } from "./migrations/v59-social-company-scope";
import {
FILESYSTEM_TASK_WORKSPACES_SCHEMA_VERSION,
assertFilesystemTaskWorkspacesV60,
migrateFilesystemTaskWorkspacesV60,
} from "./migrations/v60-filesystem-task-workspaces";
import { import {
type ReadAll, type ReadAll,
type Transaction, type Transaction,
} from "./migrations/types"; } from "./migrations/types";
const LATEST_SCHEMA_VERSION = SOCIAL_COMPANY_SCOPE_SCHEMA_VERSION; const LATEST_SCHEMA_VERSION = FILESYSTEM_TASK_WORKSPACES_SCHEMA_VERSION;
export function initializeDatabase( export function initializeDatabase(
db: DatabaseSync, db: DatabaseSync,
@@ -50,6 +55,18 @@ export function initializeDatabase(
assertTaskLeasesV57(db); assertTaskLeasesV57(db);
assertApprovalPoliciesV58(db); assertApprovalPoliciesV58(db);
assertSocialCompanyScopeV59(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; return;
} }
@@ -60,6 +77,7 @@ export function initializeDatabase(
assertTaskLeasesV57(db); assertTaskLeasesV57(db);
assertApprovalPoliciesV58(db); assertApprovalPoliciesV58(db);
migrateSocialCompanyScopeV59(db); migrateSocialCompanyScopeV59(db);
migrateFilesystemTaskWorkspacesV60(db);
}); });
return; return;
} }
@@ -70,6 +88,7 @@ export function initializeDatabase(
assertTaskLeasesV57(db); assertTaskLeasesV57(db);
migrateApprovalPoliciesV58(db); migrateApprovalPoliciesV58(db);
migrateSocialCompanyScopeV59(db); migrateSocialCompanyScopeV59(db);
migrateFilesystemTaskWorkspacesV60(db);
}); });
return; return;
} }
@@ -83,6 +102,7 @@ export function initializeDatabase(
migrateTaskLeasesV57(db); migrateTaskLeasesV57(db);
migrateApprovalPoliciesV58(db); migrateApprovalPoliciesV58(db);
migrateSocialCompanyScopeV59(db); migrateSocialCompanyScopeV59(db);
migrateFilesystemTaskWorkspacesV60(db);
}); });
return; return;
} }
@@ -97,6 +117,7 @@ export function initializeDatabase(
migrateTaskLeasesV57(db); migrateTaskLeasesV57(db);
migrateApprovalPoliciesV58(db); migrateApprovalPoliciesV58(db);
migrateSocialCompanyScopeV59(db); migrateSocialCompanyScopeV59(db);
migrateFilesystemTaskWorkspacesV60(db);
}); });
return; return;
} }
@@ -109,6 +130,7 @@ export function initializeDatabase(
migrateTaskLeasesV57(db); migrateTaskLeasesV57(db);
migrateApprovalPoliciesV58(db); migrateApprovalPoliciesV58(db);
migrateSocialCompanyScopeV59(db); migrateSocialCompanyScopeV59(db);
migrateFilesystemTaskWorkspacesV60(db);
}); });
} }
+18 -1
View File
@@ -7,6 +7,7 @@ import {
} from "@shared/contracts"; } from "@shared/contracts";
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common"; import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
import { validateTaskCreation, checkCapabilityMatch } from "./task-quality-gates"; import { validateTaskCreation, checkCapabilityMatch } from "./task-quality-gates";
import { resolveTaskWorkspaceBinding } from "./task-workspace-binding";
export function registerCoreTaskGraphHandlers({ export function registerCoreTaskGraphHandlers({
registerHandle, registerHandle,
@@ -109,6 +110,7 @@ export function registerCoreTaskGraphHandlers({
} }
let previousStatus: string | null = null; let previousStatus: string | null = null;
let previousAssigneeAgentId: string | null = null; let previousAssigneeAgentId: string | null = null;
let existingWorkspaceId: string | null = null;
if (!isNew) { if (!isNew) {
const taskId = parsed.id; const taskId = parsed.id;
if (!taskId) return fail("INVALID_TASK_STATE", "Task ID required for updates."); 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); const existingTask = db.core.entity.getTask(taskId);
previousStatus = existingTask.status; previousStatus = existingTask.status;
previousAssigneeAgentId = existingTask.assigneeAgentId; previousAssigneeAgentId = existingTask.assigneeAgentId;
existingWorkspaceId = existingTask.workspaceId;
} catch (err) { } catch (err) {
logger.warn(`[ipc/task-graph] getTask failed for ${taskId}; treating as new task:`, err instanceof Error ? err.message : String(err)); logger.warn(`[ipc/task-graph] getTask failed for ${taskId}; treating as new task:`, err instanceof Error ? err.message : String(err));
previousStatus = null; previousStatus = null;
previousAssigneeAgentId = null; previousAssigneeAgentId = null;
existingWorkspaceId = null;
} }
} }
let id: string; let id: string;
try { 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) { } catch (error) {
return fail("INVALID_TASK_STATE", error instanceof Error ? error.message : "Task could not be saved."); return fail("INVALID_TASK_STATE", error instanceof Error ? error.message : "Task could not be saved.");
} }
+175 -44
View File
@@ -1,31 +1,14 @@
import type { ModelCapability } from "@shared/types"; import type { ModelCapability } from "@shared/types";
import { CONNECTORS } from "./connector-definitions";
import { resolveModel, resolveModelCostFallback, resolveModels } from "./model-resolver";
/** type ModelCapabilitySeedRow = Omit<ModelCapability, "updatedAt">;
* Initial capability matrix written into the DB on first boot when the type CapabilityProfile = Pick<ModelCapabilitySeedRow, "reasoning" | "coding" | "writing" | "tier">;
* table is empty. The user's five-axis ranking from the dispatch design type CatalogModel = { id: string; name: string };
* 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.
*/
export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> = [ const DEFAULT_COST_PER_MTOK_OUT = 1;
// ── Anthropic (Claude) ────────────────────────────────────────────────────
// Claude Opus 4.7: strong across the board, top coding, top writing. const CURATED_MODEL_CAPABILITY_SEED: ModelCapabilitySeedRow[] = [
{ {
provider: "anthropic", provider: "anthropic",
modelId: "claude-opus-4-7", modelId: "claude-opus-4-7",
@@ -50,17 +33,9 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
reasoning: 3, reasoning: 3,
coding: 4, coding: 4,
writing: 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", tier: "standard",
costPerMtokOut: 5, costPerMtokOut: 5,
}, },
// ── OpenAI (GPT) ──────────────────────────────────────────────────────────
// GPT family leads reasoning/architecture/math/research; coding trails
// Claude; writing trails Claude and Kimi.
{ {
provider: "openai", provider: "openai",
modelId: "gpt-5", modelId: "gpt-5",
@@ -85,15 +60,9 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
reasoning: 3, reasoning: 3,
coding: 3, coding: 3,
writing: 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", tier: "standard",
costPerMtokOut: 2, costPerMtokOut: 2,
}, },
// ── Fireworks curated (open-weight) ───────────────────────────────────────
// Kimi K2: strong writing and coding, near-Claude. Reasoning solid.
{ {
provider: "fireworks", provider: "fireworks",
modelId: "accounts/fireworks/models/kimi-k2-instruct", modelId: "accounts/fireworks/models/kimi-k2-instruct",
@@ -103,7 +72,6 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
tier: "standard", tier: "standard",
costPerMtokOut: 3, costPerMtokOut: 3,
}, },
// Kimi K2.6 Turbo via Fire Pass router — newer, faster, similar quality.
{ {
provider: "fireworks-firepass", provider: "fireworks-firepass",
modelId: "accounts/fireworks/routers/kimi-k2p6-turbo", modelId: "accounts/fireworks/routers/kimi-k2p6-turbo",
@@ -113,7 +81,6 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
tier: "standard", tier: "standard",
costPerMtokOut: 3, costPerMtokOut: 3,
}, },
// GLM 4.5: mid-tier across reasoning and coding, decent writing.
{ {
provider: "fireworks", provider: "fireworks",
modelId: "accounts/fireworks/models/glm-4p5", modelId: "accounts/fireworks/models/glm-4p5",
@@ -123,7 +90,6 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
tier: "standard", tier: "standard",
costPerMtokOut: 2, costPerMtokOut: 2,
}, },
// Qwen3 Coder 480B: coding specialist.
{ {
provider: "fireworks", provider: "fireworks",
modelId: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct", modelId: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
@@ -133,7 +99,6 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
tier: "standard", tier: "standard",
costPerMtokOut: 3, costPerMtokOut: 3,
}, },
// DeepSeek V3.1: strong reasoning, capable coder, writing ok.
{ {
provider: "fireworks", provider: "fireworks",
modelId: "accounts/fireworks/models/deepseek-v3p1", modelId: "accounts/fireworks/models/deepseek-v3p1",
@@ -143,7 +108,6 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
tier: "standard", tier: "standard",
costPerMtokOut: 2, costPerMtokOut: 2,
}, },
// Llama 3.3 70B: generalist trivial-tier fallback.
{ {
provider: "fireworks", provider: "fireworks",
modelId: "accounts/fireworks/models/llama-v3p3-70b-instruct", modelId: "accounts/fireworks/models/llama-v3p3-70b-instruct",
@@ -154,3 +118,170 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
costPerMtokOut: 1, 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<string, { provider: string; model: CatalogModel }>();
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<string, ModelCapabilitySeedRow>();
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();
+17
View File
@@ -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 { function createInitialExecutionLoop(companyId: string, workspaceId: string | null): InitialExecutionLoopResult {
const companyRows = db.queryAll<{ id: string; name: string; description: string }>( const companyRows = db.queryAll<{ id: string; name: string; description: string }>(
"select id, name, description from companies where id = ? limit 1", "select id, name, description from companies where id = ? limit 1",
@@ -171,6 +187,7 @@ export function createOnboardingService({
targetDate: null, targetDate: null,
status: "in_progress", status: "in_progress",
}); });
bindWorkspaceToProject(workspaceId, projectId);
const kickoffLocale = getLocale(); const kickoffLocale = getLocale();
const kickoffTaskTitle = activeGoal const kickoffTaskTitle = activeGoal
+1 -1
View File
@@ -147,7 +147,7 @@ export function createHeartbeat(
} }
const pick = pickModelForAgent(agent, { const pick = pickModelForAgent(agent, {
getConnector: (id) => db.core.connectors.getConnector(id), 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(), listModelCapabilities: () => db.core.modelCapabilities.listModelCapabilities(),
readConnectors: () => db.core.connectors.readConnectors(), readConnectors: () => db.core.connectors.readConnectors(),
}); });
+1 -1
View File
@@ -59,7 +59,7 @@ export function createTaskRunner(
} }
const pick = pickModelForAgent(agent, { const pick = pickModelForAgent(agent, {
getConnector: (id) => db.core.connectors.getConnector(id), 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(), listModelCapabilities: () => db.core.modelCapabilities.listModelCapabilities(),
readConnectors: () => db.core.connectors.readConnectors(), readConnectors: () => db.core.connectors.readConnectors(),
}); });
+1 -1
View File
@@ -351,7 +351,7 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
})), })),
companyWorkspaces: snapshot.workspaces companyWorkspaces: snapshot.workspaces
.filter((workspace) => workspace.companyId === companyId) .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), companyDocuments: snapshot.documents.filter((d) => d.companyId === companyId),
}) : null; }) : null;
@@ -16,11 +16,13 @@ Body: {
"parentId": "{parentIssueId — for subtasks}", "parentId": "{parentIssueId — for subtasks}",
"projectId": "{projectId — for traceability}", "projectId": "{projectId — for traceability}",
"goalId": "{goalId — for strategic alignment}", "goalId": "{goalId — for strategic alignment}",
"workspaceId": "{workspaceId — required for filesystem work when the task cannot inherit one}",
"requiresFilesystem": true, "requiresFilesystem": true,
"priority": "critical|high|medium|low", "priority": "critical|high|medium|low",
"status": "todo" "status": "todo"
} }
Always set parentId when creating subtasks. Always set projectId and goalId when available. 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=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. 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: Example: Create a task assigned to a report:
@@ -15,7 +15,7 @@ export function buildCapabilityRegistrySection(ctx: Pick<PromptContext,
if (ctx.companyWorkspaces && ctx.companyWorkspaces.length > 0) { if (ctx.companyWorkspaces && ctx.companyWorkspaces.length > 0) {
const workspaces = ctx.companyWorkspaces const workspaces = ctx.companyWorkspaces
.slice(0, 6) .slice(0, 6)
.map((workspace) => `- ${workspace.name}: ${workspace.localPath}`); .map((workspace) => `- ${workspace.name} (\`${workspace.id}\`): ${workspace.localPath}`);
parts.push(`## Workspaces\n${workspaces.join("\n")}`); parts.push(`## Workspaces\n${workspaces.join("\n")}`);
} }
+1 -1
View File
@@ -44,7 +44,7 @@ export interface PromptContext {
/** Company-wide skill catalog for capability-aware delegation. */ /** Company-wide skill catalog for capability-aware delegation. */
companySkillCatalog?: Array<{ name: string; slug: string; description: string | null }>; companySkillCatalog?: Array<{ name: string; slug: string; description: string | null }>;
/** Company workspaces visible to the current run. */ /** 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 { export function buildTaskPrompt(ctx: PromptContext): string {
+103
View File
@@ -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;
}
@@ -66,14 +66,15 @@ async function createHarness() {
workspaceId: null, workspaceId: null,
connectorId: "openai-codex", connectorId: "openai-codex",
}); });
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const bridge = new RuntimeBridge({ const bridge = new RuntimeBridge({
db, db,
emitEvent: vi.fn(), emitEvent: vi.fn(),
logger: { logger,
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
credentialResolver: { credentialResolver: {
resolve: vi.fn(async () => ({ apiKey: "test-api-key", extraEnv: {} })), resolve: vi.fn(async () => ({ apiKey: "test-api-key", extraEnv: {} })),
} as unknown as CredentialResolver, } as unknown as CredentialResolver,
@@ -105,6 +106,7 @@ async function createHarness() {
agentId, agentId,
taskId, taskId,
runId: run.id, runId: run.id,
logger,
}; };
} }
@@ -143,4 +145,43 @@ describe("artifact projection durable ack", () => {
harness.db.close(); 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();
}
});
}); });
@@ -6,6 +6,7 @@ import { AppDatabase } from "../../src/main/database";
import { createBudgetEnforcementService } from "../../src/main/budget-enforcement-service"; import { createBudgetEnforcementService } from "../../src/main/budget-enforcement-service";
import { createRunDispatch } from "../../src/main/orchestration/run-dispatch"; import { createRunDispatch } from "../../src/main/orchestration/run-dispatch";
import { createTaskRunner } from "../../src/main/orchestration/task-runner"; import { createTaskRunner } from "../../src/main/orchestration/task-runner";
import { resolveModels } from "../../src/main/model-resolver";
const tempDirs: string[] = []; const tempDirs: string[] = [];
@@ -64,6 +65,93 @@ function createOrchestrationHarness(db: AppDatabase) {
} }
describe("connector model enforcement", () => { 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 () => { it("blocks queueTaskRun when an agent keeps a disabled explicit model", async () => {
const profileDir = await createTempDir("agentcompany-model-enforcement-"); const profileDir = await createTempDir("agentcompany-model-enforcement-");
const db = new AppDatabase(profileDir); const db = new AppDatabase(profileDir);
@@ -200,7 +200,7 @@ describe("skill-aware capability registry", () => {
})), })),
companyWorkspaces: snapshot.workspaces companyWorkspaces: snapshot.workspaces
.filter((workspace) => workspace.companyId === companyId) .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: [], companyDocuments: [],
skills: [ skills: [
{ {
@@ -214,7 +214,7 @@ describe("skill-aware capability registry", () => {
expect(prompt).toContain("# Company Capability Registry"); expect(prompt).toContain("# Company Capability Registry");
expect(prompt).toContain("React UI (`react-ui`)"); 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("Ready Connectors");
expect(prompt).toContain("<available_skills>"); expect(prompt).toContain("<available_skills>");
expect(prompt).toContain("<name>react-standards</name>"); expect(prompt).toContain("<name>react-standards</name>");
+2 -2
View File
@@ -8,7 +8,7 @@ describe("buildCapabilityRegistrySection", () => {
{ name: "React Standards", slug: "react-standards", description: "Use when building React UI" }, { name: "React Standards", slug: "react-standards", description: "Use when building React UI" },
], ],
companyWorkspaces: [ companyWorkspaces: [
{ name: "Primary", localPath: "/tmp/workspace" }, { id: "workspace-1", name: "Primary", localPath: "/tmp/workspace" },
], ],
allConnectors: [ allConnectors: [
{ id: "openai-codex", label: "Codex", status: "ready", enabledModels: ["gpt-5.4"] }, { 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("# Company Capability Registry");
expect(section).toContain("React Standards (`react-standards`)"); 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("Codex (`openai-codex`) — models: gpt-5.4");
expect(section).toContain("architecture_decision"); expect(section).toContain("architecture_decision");
}); });
+2
View File
@@ -482,6 +482,7 @@ describe("AppDatabase", () => {
{ version: 57, name: "task_leases_runtime_ownership" }, { version: 57, name: "task_leases_runtime_ownership" },
{ version: 58, name: "approval_policy_table" }, { version: 58, name: "approval_policy_table" },
{ version: 59, name: "social_company_scope" }, { version: 59, name: "social_company_scope" },
{ version: 60, name: "filesystem_task_workspaces" },
]); ]);
db.close(); db.close();
@@ -496,6 +497,7 @@ describe("AppDatabase", () => {
{ version: 57, name: "task_leases_runtime_ownership" }, { version: 57, name: "task_leases_runtime_ownership" },
{ version: 58, name: "approval_policy_table" }, { version: 58, name: "approval_policy_table" },
{ version: 59, name: "social_company_scope" }, { version: 59, name: "social_company_scope" },
{ version: 60, name: "filesystem_task_workspaces" },
]); ]);
}); });
@@ -176,6 +176,7 @@ describe("legacy to v56 baseline migration", () => {
{ version: 57, name: "task_leases_runtime_ownership" }, { version: 57, name: "task_leases_runtime_ownership" },
{ version: 58, name: "approval_policy_table" }, { version: 58, name: "approval_policy_table" },
{ version: 59, name: "social_company_scope" }, { 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]) expect(appDb.queryAll<{ companyId: string }>("select company_id as companyId from secrets where id = 'secret-1'")[0])
.toEqual({ companyId: "company-1" }); .toEqual({ companyId: "company-1" });
@@ -210,6 +211,7 @@ describe("legacy to v56 baseline migration", () => {
{ version: 57, name: "task_leases_runtime_ownership" }, { version: 57, name: "task_leases_runtime_ownership" },
{ version: 58, name: "approval_policy_table" }, { version: 58, name: "approval_policy_table" },
{ version: 59, name: "social_company_scope" }, { 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'")) expect(appDb.queryAll("select id from budget_policies where scope_type = 'agent' and scope_id = 'agent-1'"))
.toHaveLength(1); .toHaveLength(1);
@@ -235,6 +237,7 @@ describe("legacy to v56 baseline migration", () => {
{ version: 57, name: "task_leases_runtime_ownership" }, { version: 57, name: "task_leases_runtime_ownership" },
{ version: 58, name: "approval_policy_table" }, { version: 58, name: "approval_policy_table" },
{ version: 59, name: "social_company_scope" }, { version: 59, name: "social_company_scope" },
{ version: 60, name: "filesystem_task_workspaces" },
]); ]);
} finally { } finally {
appDb.close(); appDb.close();
@@ -394,6 +397,7 @@ describe("legacy to v56 baseline migration", () => {
{ version: 57, name: "task_leases_runtime_ownership" }, { version: 57, name: "task_leases_runtime_ownership" },
{ version: 58, name: "approval_policy_table" }, { version: 58, name: "approval_policy_table" },
{ version: 59, name: "social_company_scope" }, { version: 59, name: "social_company_scope" },
{ version: 60, name: "filesystem_task_workspaces" },
]); ]);
} finally { } finally {
appDb.close(); appDb.close();
+101
View File
@@ -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<string>();
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");
});
});
@@ -18,6 +18,7 @@ vi.mock("electron", () => ({
})); }));
import { AppDatabase } from "../../src/main/database"; import { AppDatabase } from "../../src/main/database";
import { migrateFilesystemTaskWorkspacesV60 } from "../../src/main/database/migrations/v60-filesystem-task-workspaces";
const tempDirs: string[] = []; const tempDirs: string[] = [];
@@ -116,16 +117,74 @@ describe("legacy to v56 baseline migration", () => {
{ id: "task-inflight", requiresFilesystem: 1 }, { id: "task-inflight", requiresFilesystem: 1 },
]); ]);
const migrations = appDb.queryAll<{ version: number; name: string }>( 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([ expect(migrations).toEqual([
{ version: 56, name: "baseline_v56" }, { version: 56, name: "baseline_v56" },
{ version: 57, name: "task_leases_runtime_ownership" }, { version: 57, name: "task_leases_runtime_ownership" },
{ version: 58, name: "approval_policy_table" }, { version: 58, name: "approval_policy_table" },
{ version: 59, name: "social_company_scope" }, { version: 59, name: "social_company_scope" },
{ version: 60, name: "filesystem_task_workspaces" },
]); ]);
} finally { } finally {
appDb.close(); 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();
}
});
}); });
+108
View File
@@ -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 };
}