update
This commit is contained in:
1 parent
2ab001b18a
commit
810e2d5967
28 files changed
+1015
-85
No files matched your search
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
+1
-1
@@ -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.",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -92,12 +92,27 @@ export function buildModelCapabilityMethods(
|
||||
*/
|
||||
function seedModelCapabilities(rows: Array<Omit<ModelCapability, "updatedAt">>): 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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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<ModelCapability, "updatedAt">;
|
||||
type CapabilityProfile = Pick<ModelCapabilitySeedRow, "reasoning" | "coding" | "writing" | "tier">;
|
||||
type CatalogModel = { id: string; name: string };
|
||||
|
||||
export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> = [
|
||||
// ── 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<Omit<ModelCapability, "updatedAt">> =
|
||||
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<Omit<ModelCapability, "updatedAt">> =
|
||||
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<Omit<ModelCapability, "updatedAt">> =
|
||||
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<Omit<ModelCapability, "updatedAt">> =
|
||||
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<Omit<ModelCapability, "updatedAt">> =
|
||||
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<Omit<ModelCapability, "updatedAt">> =
|
||||
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<Omit<ModelCapability, "updatedAt">> =
|
||||
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<Omit<ModelCapability, "updatedAt">> =
|
||||
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();
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -15,7 +15,7 @@ export function buildCapabilityRegistrySection(ctx: Pick<PromptContext,
|
||||
if (ctx.companyWorkspaces && ctx.companyWorkspaces.length > 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")}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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("<available_skills>");
|
||||
expect(prompt).toContain("<name>react-standards</name>");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 { 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user