bopodev-api 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { and, eq } from "drizzle-orm";
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import { z } from "zod";
4
- import { AgentCreateRequestSchema } from "bopodev-contracts";
4
+ import { AgentCreateRequestSchema, TemplateManifestDefault, TemplateManifestSchema } from "bopodev-contracts";
5
5
  import type { BopoDb } from "bopodev-db";
6
6
  import {
7
7
  approvalRequests,
@@ -9,11 +9,16 @@ import {
9
9
  createGoal,
10
10
  createIssue,
11
11
  createProject,
12
+ createProjectWorkspace,
13
+ getCurrentTemplateVersion,
14
+ getTemplate,
12
15
  goals,
13
16
  listAgents,
14
17
  listIssues,
18
+ listProjectWorkspaces,
15
19
  listProjects,
16
20
  projects,
21
+ updateProjectWorkspace,
17
22
  updatePluginConfig
18
23
  } from "bopodev-db";
19
24
  import {
@@ -24,8 +29,14 @@ import {
24
29
  runtimeConfigToStateBlobPatch
25
30
  } from "../lib/agent-config";
26
31
  import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
27
- import { hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
32
+ import {
33
+ normalizeCompanyWorkspacePath,
34
+ resolveAgentFallbackWorkspacePath,
35
+ resolveProjectWorkspacePath
36
+ } from "../lib/instance-paths";
37
+ import { assertRuntimeCwdForCompany, hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
28
38
  import { appendDurableFact } from "./memory-file-service";
39
+ import { applyTemplateManifest } from "./template-apply-service";
29
40
 
30
41
  const approvalGatedActions = new Set([
31
42
  "hire_agent",
@@ -34,7 +45,8 @@ const approvalGatedActions = new Set([
34
45
  "pause_agent",
35
46
  "terminate_agent",
36
47
  "promote_memory_fact",
37
- "grant_plugin_capabilities"
48
+ "grant_plugin_capabilities",
49
+ "apply_template"
38
50
  ]);
39
51
 
40
52
  const hireAgentPayloadSchema = AgentCreateRequestSchema.extend({
@@ -75,6 +87,11 @@ const grantPluginCapabilitiesPayloadSchema = z.object({
75
87
  grantedCapabilities: z.array(z.string().min(1)).default([]),
76
88
  config: z.record(z.string(), z.unknown()).default({})
77
89
  });
90
+ const applyTemplatePayloadSchema = z.object({
91
+ templateId: z.string().min(1),
92
+ templateVersion: z.string().min(1),
93
+ variables: z.record(z.string(), z.unknown()).default({})
94
+ });
78
95
  const AGENT_STARTUP_PROJECT_NAME = "Agent Onboarding";
79
96
  const AGENT_STARTUP_TASK_MARKER = "[bopodev:onboarding:agent-startup:v1]";
80
97
 
@@ -121,7 +138,7 @@ export async function resolveApproval(
121
138
  let execution:
122
139
  | {
123
140
  applied: boolean;
124
- entityType?: "agent" | "goal" | "memory";
141
+ entityType?: "agent" | "goal" | "memory" | "template";
125
142
  entityId?: string;
126
143
  entity?: Record<string, unknown>;
127
144
  }
@@ -195,6 +212,13 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
195
212
  },
196
213
  defaultRuntimeCwd
197
214
  });
215
+ if (runtimeConfig.runtimeCwd) {
216
+ try {
217
+ runtimeConfig.runtimeCwd = assertRuntimeCwdForCompany(companyId, runtimeConfig.runtimeCwd, "runtimeCwd");
218
+ } catch (error) {
219
+ throw new GovernanceError(String(error));
220
+ }
221
+ }
198
222
  runtimeConfig.runtimeModel = await resolveOpencodeRuntimeModel(parsed.data.providerType, runtimeConfig);
199
223
  runtimeConfig.runtimeModel = resolveRuntimeModelForProvider(parsed.data.providerType, runtimeConfig.runtimeModel);
200
224
  if (providerRequiresNamedModel(parsed.data.providerType) && !hasText(runtimeConfig.runtimeModel)) {
@@ -339,7 +363,7 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
339
363
  });
340
364
  return {
341
365
  applied: true,
342
- entityType: "memory" as const,
366
+ entityType: "template" as const,
343
367
  entityId: parsed.data.pluginId,
344
368
  entity: {
345
369
  pluginId: parsed.data.pluginId,
@@ -349,6 +373,45 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
349
373
  }
350
374
  };
351
375
  }
376
+ if (action === "apply_template") {
377
+ const parsed = applyTemplatePayloadSchema.safeParse(payload);
378
+ if (!parsed.success) {
379
+ throw new GovernanceError("Approval payload for template apply is invalid.");
380
+ }
381
+ const template = await getTemplate(db, companyId, parsed.data.templateId);
382
+ if (!template) {
383
+ throw new GovernanceError("Template not found for apply request.");
384
+ }
385
+ const version =
386
+ (await getCurrentTemplateVersion(db, companyId, parsed.data.templateId)) ??
387
+ null;
388
+ if (!version) {
389
+ throw new GovernanceError("Template version not found for apply request.");
390
+ }
391
+ const manifest = parsePayload(version.manifestJson);
392
+ const parsedManifest = TemplateManifestSchema.safeParse(manifest);
393
+ const normalizedManifest = parsedManifest.success
394
+ ? parsedManifest.data
395
+ : TemplateManifestSchema.parse(TemplateManifestDefault);
396
+ const applied = await applyTemplateManifest(db, {
397
+ companyId,
398
+ templateId: template.id,
399
+ templateVersion: version.version,
400
+ templateVersionId: version.id,
401
+ manifest: normalizedManifest,
402
+ variables: parsed.data.variables
403
+ });
404
+ return {
405
+ applied: applied.applied,
406
+ entityType: "template" as const,
407
+ entityId: template.id,
408
+ entity: {
409
+ id: template.id,
410
+ installId: applied.installId ?? null,
411
+ summary: applied.summary
412
+ }
413
+ };
414
+ }
352
415
 
353
416
  throw new GovernanceError(`Unsupported approval action: ${action}`);
354
417
  }
@@ -370,6 +433,7 @@ async function ensureAgentStartupProject(db: BopoDb, companyId: string) {
370
433
  const projects = await listProjects(db, companyId);
371
434
  const existing = projects.find((project) => project.name === AGENT_STARTUP_PROJECT_NAME);
372
435
  if (existing) {
436
+ await ensureProjectPrimaryWorkspace(db, companyId, existing.id, AGENT_STARTUP_PROJECT_NAME);
373
437
  return existing.id;
374
438
  }
375
439
  const created = await createProject(db, {
@@ -377,9 +441,50 @@ async function ensureAgentStartupProject(db: BopoDb, companyId: string) {
377
441
  name: AGENT_STARTUP_PROJECT_NAME,
378
442
  description: "Operating baseline tasks for newly approved hires."
379
443
  });
444
+ if (!created) {
445
+ throw new Error("Failed to create startup project.");
446
+ }
447
+ await ensureProjectPrimaryWorkspace(db, companyId, created.id, AGENT_STARTUP_PROJECT_NAME);
380
448
  return created.id;
381
449
  }
382
450
 
451
+ async function ensureProjectPrimaryWorkspace(db: BopoDb, companyId: string, projectId: string, projectName: string) {
452
+ const existingWorkspaces = await listProjectWorkspaces(db, companyId, projectId);
453
+ const existingPrimary = existingWorkspaces.find((workspace) => workspace.isPrimary);
454
+ if (existingPrimary) {
455
+ if (existingPrimary.cwd) {
456
+ const normalized = normalizeCompanyWorkspacePath(companyId, existingPrimary.cwd);
457
+ await mkdir(normalized, { recursive: true });
458
+ }
459
+ return existingPrimary;
460
+ }
461
+ const defaultWorkspaceCwd = resolveProjectWorkspacePath(companyId, projectId);
462
+ await mkdir(defaultWorkspaceCwd, { recursive: true });
463
+ const fallbackWorkspace = existingWorkspaces[0];
464
+ if (fallbackWorkspace) {
465
+ const normalizedCwd = fallbackWorkspace.cwd?.trim()
466
+ ? normalizeCompanyWorkspacePath(companyId, fallbackWorkspace.cwd)
467
+ : defaultWorkspaceCwd;
468
+ if (normalizedCwd) {
469
+ await mkdir(normalizedCwd, { recursive: true });
470
+ }
471
+ return updateProjectWorkspace(db, {
472
+ companyId,
473
+ projectId,
474
+ id: fallbackWorkspace.id,
475
+ cwd: normalizedCwd,
476
+ isPrimary: true
477
+ });
478
+ }
479
+ return createProjectWorkspace(db, {
480
+ companyId,
481
+ projectId,
482
+ name: projectName,
483
+ cwd: defaultWorkspaceCwd,
484
+ isPrimary: true
485
+ });
486
+ }
487
+
383
488
  async function ensureAgentStartupIssue(
384
489
  db: BopoDb,
385
490
  companyId: string,
@@ -388,7 +493,7 @@ async function ensureAgentStartupIssue(
388
493
  role: string
389
494
  ) {
390
495
  const title = `Set up ${role} operating files`;
391
- const body = buildAgentStartupTaskBody(agentId);
496
+ const body = buildAgentStartupTaskBody(companyId, agentId);
392
497
  const existingIssues = await listIssues(db, companyId);
393
498
  const existing = existingIssues.find(
394
499
  (issue) =>
@@ -414,24 +519,26 @@ async function ensureAgentStartupIssue(
414
519
  return created.id;
415
520
  }
416
521
 
417
- function buildAgentStartupTaskBody(agentId: string) {
418
- const agentFolder = `agents/${agentId}`;
522
+ function buildAgentStartupTaskBody(companyId: string, agentId: string) {
523
+ const agentWorkspaceRoot = resolveAgentFallbackWorkspacePath(companyId, agentId);
524
+ const agentOperatingFolder = `${agentWorkspaceRoot}/operating`;
419
525
  return [
420
526
  AGENT_STARTUP_TASK_MARKER,
421
527
  "",
422
528
  `Create your operating baseline before starting feature delivery work.`,
423
529
  "",
424
- `1. Create the folder \`${agentFolder}/\` in the repository workspace.`,
530
+ `1. Create your operating folder at \`${agentOperatingFolder}/\` (system path, outside project workspaces).`,
425
531
  "2. Author these files with your own responsibilities and working style:",
426
- ` - \`${agentFolder}/AGENTS.md\``,
427
- ` - \`${agentFolder}/HEARTBEAT.md\``,
428
- ` - \`${agentFolder}/SOUL.md\``,
429
- ` - \`${agentFolder}/TOOLS.md\``,
430
- `3. Update your own agent runtime config via \`PUT /agents/:agentId\` and set \`runtimeConfig.bootstrapPrompt\` to reference \`${agentFolder}/AGENTS.md\` as your primary guide.`,
532
+ ` - \`${agentOperatingFolder}/AGENTS.md\``,
533
+ ` - \`${agentOperatingFolder}/HEARTBEAT.md\``,
534
+ ` - \`${agentOperatingFolder}/SOUL.md\``,
535
+ ` - \`${agentOperatingFolder}/TOOLS.md\``,
536
+ `3. Update your own agent runtime config via \`PUT /agents/:agentId\` and set \`runtimeConfig.bootstrapPrompt\` to reference \`${agentOperatingFolder}/AGENTS.md\` as your primary guide.`,
431
537
  "4. Post an issue comment summarizing completed setup artifacts.",
432
538
  "",
433
539
  "Safety checks:",
434
- "- Do not overwrite another agent's folder.",
540
+ "- Do not write operating/system files under any project workspace folder.",
541
+ "- Do not overwrite another agent's operating folder.",
435
542
  "- Keep content original to your role and scope."
436
543
  ].join("\n");
437
544
  }
@@ -25,8 +25,9 @@ import {
25
25
  } from "bopodev-db";
26
26
  import { appendAuditEvent, appendCost } from "bopodev-db";
27
27
  import { parseRuntimeConfigFromAgentRow } from "../lib/agent-config";
28
- import { resolveProjectWorkspacePath } from "../lib/instance-paths";
29
- import { getProjectWorkspaceMap, hasText, resolveAgentFallbackWorkspace } from "../lib/workspace-policy";
28
+ import { bootstrapRepositoryWorkspace, ensureIsolatedGitWorktree, GitRuntimeError } from "../lib/git-runtime";
29
+ import { isInsidePath, normalizeCompanyWorkspacePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
30
+ import { assertRuntimeCwdForCompany, getProjectWorkspaceContextMap, hasText, resolveAgentFallbackWorkspace } from "../lib/workspace-policy";
30
31
  import type { RealtimeHub } from "../realtime/hub";
31
32
  import { createHeartbeatRunsRealtimeEvent } from "../realtime/heartbeat-runs";
32
33
  import { publishOfficeOccupantForAgent } from "../realtime/office-space";
@@ -773,6 +774,16 @@ export async function runHeartbeatForAgent(
773
774
  externalAbortSignal: activeRunAbort.signal
774
775
  });
775
776
  executionSummary = execution.summary;
777
+ const normalizedUsage = execution.usage ?? {
778
+ inputTokens: Math.max(0, execution.tokenInput),
779
+ cachedInputTokens: 0,
780
+ outputTokens: Math.max(0, execution.tokenOutput),
781
+ ...(execution.usdCost > 0 ? { costUsd: execution.usdCost } : {}),
782
+ ...(execution.summary ? { summary: execution.summary } : {})
783
+ };
784
+ const effectiveTokenInput = normalizedUsage.inputTokens + normalizedUsage.cachedInputTokens;
785
+ const effectiveTokenOutput = normalizedUsage.outputTokens;
786
+ const effectiveRuntimeUsdCost = normalizedUsage.costUsd ?? (execution.usdCost > 0 ? execution.usdCost : 0);
776
787
  const afterAdapterHook = await runPluginHook(db, {
777
788
  hook: "afterAdapterExecute",
778
789
  context: {
@@ -806,8 +817,10 @@ export async function runHeartbeatForAgent(
806
817
  runtimeModelId: effectivePricingModelId ?? runtimeModelId,
807
818
  pricingProviderType: effectivePricingProviderType,
808
819
  pricingModelId: effectivePricingModelId,
809
- tokenInput: execution.tokenInput,
810
- tokenOutput: execution.tokenOutput,
820
+ tokenInput: effectiveTokenInput,
821
+ tokenOutput: effectiveTokenOutput,
822
+ runtimeUsdCost: effectiveRuntimeUsdCost,
823
+ failureType: readTraceString(execution.trace, "failureType"),
811
824
  issueId: primaryIssueId,
812
825
  projectId: primaryProjectId,
813
826
  agentId,
@@ -865,8 +878,8 @@ export async function runHeartbeatForAgent(
865
878
  if (
866
879
  execution.nextState ||
867
880
  executionUsdCost > 0 ||
868
- execution.tokenInput > 0 ||
869
- execution.tokenOutput > 0 ||
881
+ effectiveTokenInput > 0 ||
882
+ effectiveTokenOutput > 0 ||
870
883
  execution.status !== "skipped"
871
884
  ) {
872
885
  await db
@@ -875,7 +888,7 @@ export async function runHeartbeatForAgent(
875
888
  stateBlob: JSON.stringify(execution.nextState ?? state),
876
889
  runtimeModel: effectivePricingModelId ?? persistedRuntime.runtimeModel ?? null,
877
890
  usedBudgetUsd: sql`${agents.usedBudgetUsd} + ${executionUsdCost}`,
878
- tokenUsage: sql`${agents.tokenUsage} + ${execution.tokenInput + execution.tokenOutput}`,
891
+ tokenUsage: sql`${agents.tokenUsage} + ${effectiveTokenInput + effectiveTokenOutput}`,
879
892
  updatedAt: new Date()
880
893
  })
881
894
  .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
@@ -883,8 +896,8 @@ export async function runHeartbeatForAgent(
883
896
 
884
897
  const shouldAdvanceIssuesToReview = shouldPromoteIssuesToReview({
885
898
  summary: execution.summary,
886
- tokenInput: execution.tokenInput,
887
- tokenOutput: execution.tokenOutput,
899
+ tokenInput: effectiveTokenInput,
900
+ tokenOutput: effectiveTokenOutput,
888
901
  usdCost: executionUsdCost,
889
902
  trace: executionTrace,
890
903
  outcome: executionOutcome
@@ -929,8 +942,8 @@ export async function runHeartbeatForAgent(
929
942
  summary: execution.summary,
930
943
  outcome: executionOutcome,
931
944
  usage: {
932
- tokenInput: execution.tokenInput,
933
- tokenOutput: execution.tokenOutput,
945
+ tokenInput: effectiveTokenInput,
946
+ tokenOutput: effectiveTokenOutput,
934
947
  usdCost: executionUsdCost
935
948
  }
936
949
  }
@@ -1062,9 +1075,9 @@ export async function runHeartbeatForAgent(
1062
1075
  outcome: executionOutcome,
1063
1076
  issueIds,
1064
1077
  usage: {
1065
- tokenInput: execution.tokenInput,
1066
- tokenOutput: execution.tokenOutput,
1067
- usdCost: execution.usdCost,
1078
+ tokenInput: effectiveTokenInput,
1079
+ tokenOutput: effectiveTokenOutput,
1080
+ usdCost: executionUsdCost,
1068
1081
  source: readTraceString(execution.trace, "usageSource") ?? "unknown"
1069
1082
  },
1070
1083
  trace: execution.trace ?? null,
@@ -1401,7 +1414,10 @@ async function buildHeartbeatContext(
1401
1414
  .where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)))
1402
1415
  : [];
1403
1416
  const projectNameById = new Map(projectRows.map((row) => [row.id, row.name]));
1404
- const projectWorkspaceMap = await getProjectWorkspaceMap(db, companyId, projectIds);
1417
+ const projectWorkspaceContextMap = await getProjectWorkspaceContextMap(db, companyId, projectIds);
1418
+ const projectWorkspaceMap = new Map(
1419
+ Array.from(projectWorkspaceContextMap.entries()).map(([projectId, context]) => [projectId, context.cwd])
1420
+ );
1405
1421
  const issueIds = input.workItems.map((item) => item.id);
1406
1422
  const attachmentRows =
1407
1423
  issueIds.length > 0
@@ -1432,6 +1448,9 @@ async function buildHeartbeatContext(
1432
1448
  for (const row of attachmentRows) {
1433
1449
  const projectWorkspace = projectWorkspaceMap.get(row.projectId) ?? resolveProjectWorkspacePath(companyId, row.projectId);
1434
1450
  const absolutePath = resolve(projectWorkspace, row.relativePath);
1451
+ if (!isInsidePath(projectWorkspace, absolutePath)) {
1452
+ continue;
1453
+ }
1435
1454
  const existing = attachmentsByIssue.get(row.issueId) ?? [];
1436
1455
  existing.push({
1437
1456
  id: row.id,
@@ -1761,7 +1780,7 @@ async function resolveRuntimeWorkspaceForWorkItems(
1761
1780
  db: BopoDb,
1762
1781
  companyId: string,
1763
1782
  agentId: string,
1764
- workItems: Array<{ project_id: string }>,
1783
+ workItems: Array<{ id?: string; project_id: string }>,
1765
1784
  runtime:
1766
1785
  | {
1767
1786
  command?: string;
@@ -1785,22 +1804,76 @@ async function resolveRuntimeWorkspaceForWorkItems(
1785
1804
  const normalizedRuntimeCwd = runtime?.cwd?.trim();
1786
1805
  const warnings: string[] = [];
1787
1806
  const projectIds = Array.from(new Set(workItems.map((item) => item.project_id)));
1788
- const projectWorkspaceMap = await getProjectWorkspaceMap(db, companyId, projectIds);
1789
-
1790
- let selectedProjectWorkspace: string | null = null;
1807
+ const projectWorkspaceContextMap = await getProjectWorkspaceContextMap(db, companyId, projectIds);
1791
1808
  for (const projectId of projectIds) {
1792
- const projectWorkspace = projectWorkspaceMap.get(projectId) ?? null;
1793
- if (hasText(projectWorkspace)) {
1794
- selectedProjectWorkspace = projectWorkspace;
1795
- break;
1809
+ const projectContext = projectWorkspaceContextMap.get(projectId);
1810
+ if (!projectContext) {
1811
+ continue;
1812
+ }
1813
+ const mode = projectContext.policy?.mode ?? "project_primary";
1814
+ const baseWorkspaceCwd = hasText(projectContext.cwd)
1815
+ ? normalizeCompanyWorkspacePath(companyId, projectContext.cwd as string)
1816
+ : projectContext.repoUrl
1817
+ ? resolveProjectWorkspacePath(companyId, projectId)
1818
+ : null;
1819
+ if (mode === "agent_default" && hasText(normalizedRuntimeCwd)) {
1820
+ const boundedRuntimeCwd = assertRuntimeCwdForCompany(companyId, normalizedRuntimeCwd!, "runtime.cwd");
1821
+ return {
1822
+ source: "agent_runtime",
1823
+ warnings,
1824
+ runtime: {
1825
+ ...runtime,
1826
+ cwd: boundedRuntimeCwd
1827
+ }
1828
+ };
1829
+ }
1830
+ if (!baseWorkspaceCwd) {
1831
+ continue;
1832
+ }
1833
+ let selectedWorkspaceCwd = normalizeCompanyWorkspacePath(companyId, baseWorkspaceCwd);
1834
+ await mkdir(baseWorkspaceCwd, { recursive: true });
1835
+ try {
1836
+ if (hasText(projectContext.repoUrl)) {
1837
+ const bootstrap = await bootstrapRepositoryWorkspace({
1838
+ companyId,
1839
+ projectId,
1840
+ cwd: baseWorkspaceCwd,
1841
+ repoUrl: projectContext.repoUrl as string,
1842
+ repoRef: projectContext.repoRef,
1843
+ policy: projectContext.policy,
1844
+ runtimeEnv: runtime?.env
1845
+ });
1846
+ selectedWorkspaceCwd = normalizeCompanyWorkspacePath(companyId, bootstrap.cwd);
1847
+ }
1848
+ if (
1849
+ mode === "isolated" &&
1850
+ projectContext.policy?.strategy?.type === "git_worktree" &&
1851
+ resolveGitWorktreeIsolationEnabled()
1852
+ ) {
1853
+ const projectIssue = workItems.find((item) => item.project_id === projectId);
1854
+ const worktree = await ensureIsolatedGitWorktree({
1855
+ companyId,
1856
+ repoCwd: selectedWorkspaceCwd,
1857
+ projectId,
1858
+ agentId,
1859
+ issueId: projectIssue?.id ?? null,
1860
+ repoRef: projectContext.repoRef,
1861
+ policy: projectContext.policy
1862
+ });
1863
+ selectedWorkspaceCwd = normalizeCompanyWorkspacePath(companyId, worktree.cwd);
1864
+ } else if (mode === "isolated" && projectContext.policy?.strategy?.type === "git_worktree") {
1865
+ warnings.push(
1866
+ "Project execution workspace policy mode 'isolated' is configured with git_worktree, but BOPO_ENABLE_GIT_WORKTREE_ISOLATION is disabled. Falling back to primary project workspace."
1867
+ );
1868
+ }
1869
+ } catch (error) {
1870
+ const message = error instanceof GitRuntimeError ? error.message : String(error);
1871
+ warnings.push(`Workspace bootstrap failed for project '${projectId}': ${message}`);
1796
1872
  }
1797
- }
1798
1873
 
1799
- if (selectedProjectWorkspace) {
1800
- await mkdir(selectedProjectWorkspace, { recursive: true });
1801
- if (hasText(normalizedRuntimeCwd) && normalizedRuntimeCwd !== selectedProjectWorkspace) {
1874
+ if (hasText(normalizedRuntimeCwd) && normalizedRuntimeCwd !== selectedWorkspaceCwd) {
1802
1875
  warnings.push(
1803
- `Runtime cwd '${normalizedRuntimeCwd}' was overridden to project workspace '${selectedProjectWorkspace}' for assigned work.`
1876
+ `Runtime cwd '${normalizedRuntimeCwd}' was overridden to project workspace '${selectedWorkspaceCwd}' for assigned work.`
1804
1877
  );
1805
1878
  }
1806
1879
  return {
@@ -1808,27 +1881,28 @@ async function resolveRuntimeWorkspaceForWorkItems(
1808
1881
  warnings,
1809
1882
  runtime: {
1810
1883
  ...runtime,
1811
- cwd: selectedProjectWorkspace
1884
+ cwd: selectedWorkspaceCwd
1812
1885
  }
1813
1886
  };
1814
1887
  }
1815
1888
 
1816
1889
  if (projectIds.length > 0) {
1817
- warnings.push("Assigned project has no local workspace path configured. Falling back to agent workspace.");
1890
+ warnings.push("Assigned project has no primary workspace cwd/repo configured. Falling back to agent workspace.");
1818
1891
  }
1819
1892
 
1820
1893
  if (hasText(normalizedRuntimeCwd)) {
1894
+ const boundedRuntimeCwd = assertRuntimeCwdForCompany(companyId, normalizedRuntimeCwd!, "runtime.cwd");
1821
1895
  return {
1822
1896
  source: "agent_runtime",
1823
1897
  warnings,
1824
1898
  runtime: {
1825
1899
  ...runtime,
1826
- cwd: normalizedRuntimeCwd
1900
+ cwd: boundedRuntimeCwd
1827
1901
  }
1828
1902
  };
1829
1903
  }
1830
1904
 
1831
- const fallbackWorkspace = resolveAgentFallbackWorkspace(companyId, agentId);
1905
+ const fallbackWorkspace = normalizeCompanyWorkspacePath(companyId, resolveAgentFallbackWorkspace(companyId, agentId));
1832
1906
  await mkdir(fallbackWorkspace, { recursive: true });
1833
1907
  warnings.push(`Runtime cwd was not configured. Falling back to '${fallbackWorkspace}'.`);
1834
1908
  return {
@@ -1841,6 +1915,13 @@ async function resolveRuntimeWorkspaceForWorkItems(
1841
1915
  };
1842
1916
  }
1843
1917
 
1918
+ function resolveGitWorktreeIsolationEnabled() {
1919
+ const value = String(process.env.BOPO_ENABLE_GIT_WORKTREE_ISOLATION ?? "")
1920
+ .trim()
1921
+ .toLowerCase();
1922
+ return value === "1" || value === "true";
1923
+ }
1924
+
1844
1925
  function resolveStaleRunThresholdMs() {
1845
1926
  const parsed = Number(process.env.BOPO_HEARTBEAT_STALE_RUN_MS ?? 10 * 60 * 1000);
1846
1927
  if (!Number.isFinite(parsed) || parsed < 1_000) {
@@ -2430,6 +2511,8 @@ async function appendFinishedRunCostEntry(input: {
2430
2511
  pricingModelId?: string | null;
2431
2512
  tokenInput: number;
2432
2513
  tokenOutput: number;
2514
+ runtimeUsdCost?: number;
2515
+ failureType?: string | null;
2433
2516
  issueId?: string | null;
2434
2517
  projectId?: string | null;
2435
2518
  agentId?: string | null;
@@ -2446,24 +2529,41 @@ async function appendFinishedRunCostEntry(input: {
2446
2529
  });
2447
2530
 
2448
2531
  const shouldPersist = input.status === "ok" || input.status === "failed";
2449
- if (shouldPersist) {
2532
+ const runtimeUsdCost = Math.max(0, input.runtimeUsdCost ?? 0);
2533
+ const pricedUsdCost = Math.max(0, pricingDecision.usdCost);
2534
+ const shouldUseRuntimeUsdCost = pricedUsdCost <= 0 && runtimeUsdCost > 0;
2535
+ const baseUsdCost = shouldUseRuntimeUsdCost ? runtimeUsdCost : pricedUsdCost;
2536
+ const effectiveUsdCost =
2537
+ baseUsdCost > 0
2538
+ ? baseUsdCost
2539
+ : input.status === "failed" && input.failureType !== "spawn_error"
2540
+ ? 0.000001
2541
+ : 0;
2542
+ const effectivePricingSource = pricingDecision.pricingSource;
2543
+ const shouldPersistWithUsage =
2544
+ shouldPersist && (input.tokenInput > 0 || input.tokenOutput > 0 || effectiveUsdCost > 0);
2545
+ if (shouldPersistWithUsage) {
2450
2546
  await appendCost(input.db, {
2451
2547
  companyId: input.companyId,
2452
2548
  providerType: input.providerType,
2453
2549
  runtimeModelId: input.runtimeModelId,
2454
2550
  pricingProviderType: pricingDecision.pricingProviderType,
2455
2551
  pricingModelId: pricingDecision.pricingModelId,
2456
- pricingSource: pricingDecision.pricingSource,
2552
+ pricingSource: effectivePricingSource,
2457
2553
  tokenInput: input.tokenInput,
2458
2554
  tokenOutput: input.tokenOutput,
2459
- usdCost: pricingDecision.usdCost.toFixed(6),
2555
+ usdCost: effectiveUsdCost.toFixed(6),
2460
2556
  issueId: input.issueId ?? null,
2461
2557
  projectId: input.projectId ?? null,
2462
2558
  agentId: input.agentId ?? null
2463
2559
  });
2464
2560
  }
2465
2561
 
2466
- return pricingDecision;
2562
+ return {
2563
+ ...pricingDecision,
2564
+ pricingSource: effectivePricingSource,
2565
+ usdCost: effectiveUsdCost
2566
+ };
2467
2567
  }
2468
2568
 
2469
2569
  function isHeartbeatDue(cronExpression: string, lastRunAt: Date | null, now: Date) {
@@ -214,8 +214,8 @@ export async function ensureCompanyBuiltinPluginDefaults(db: BopoDb, companyId:
214
214
  const existing = await listCompanyPluginConfigs(db, companyId);
215
215
  const existingIds = new Set(existing.map((row) => row.pluginId));
216
216
  const defaults = [
217
- { pluginId: "trace-exporter", enabled: true, priority: 40 },
218
- { pluginId: "memory-enricher", enabled: true, priority: 60 },
217
+ { pluginId: "trace-exporter", enabled: false, priority: 40 },
218
+ { pluginId: "memory-enricher", enabled: false, priority: 60 },
219
219
  { pluginId: "queue-publisher", enabled: false, priority: 80 },
220
220
  { pluginId: "heartbeat-tagger", enabled: false, priority: 90 }
221
221
  ];