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[];
|
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
@@ -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.",
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>");
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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