bopodev-api 0.1.7 → 0.1.9
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.
- package/package.json +7 -4
- package/src/lib/agent-config.ts +255 -0
- package/src/lib/instance-paths.ts +88 -0
- package/src/lib/workspace-policy.ts +75 -0
- package/src/middleware/request-actor.ts +26 -5
- package/src/realtime/office-space.ts +7 -0
- package/src/routes/agents.ts +335 -66
- package/src/routes/heartbeats.ts +21 -2
- package/src/routes/issues.ts +122 -4
- package/src/routes/projects.ts +60 -3
- package/src/scripts/backfill-project-workspaces.ts +118 -0
- package/src/scripts/onboard-seed.ts +314 -0
- package/src/server.ts +45 -2
- package/src/services/governance-service.ts +144 -18
- package/src/services/heartbeat-service.ts +616 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bopodev-api",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -10,14 +10,15 @@
|
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"cors": "^2.8.5",
|
|
12
12
|
"cron-parser": "^5.3.1",
|
|
13
|
+
"dotenv": "^17.0.1",
|
|
13
14
|
"drizzle-orm": "^0.44.5",
|
|
14
15
|
"express": "^5.1.0",
|
|
15
16
|
"nanoid": "^5.1.5",
|
|
16
17
|
"ws": "^8.19.0",
|
|
17
18
|
"zod": "^4.1.5",
|
|
18
|
-
"bopodev-agent-sdk": "0.1.
|
|
19
|
-
"bopodev-
|
|
20
|
-
"bopodev-
|
|
19
|
+
"bopodev-agent-sdk": "0.1.9",
|
|
20
|
+
"bopodev-contracts": "0.1.9",
|
|
21
|
+
"bopodev-db": "0.1.9"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
24
|
"@types/cors": "^2.8.19",
|
|
@@ -29,6 +30,8 @@
|
|
|
29
30
|
"dev": "PORT=${API_PORT:-4020} tsx watch src/server.ts",
|
|
30
31
|
"start": "PORT=${API_PORT:-4020} tsx src/server.ts",
|
|
31
32
|
"db:init": "tsx src/scripts/db-init.ts",
|
|
33
|
+
"onboard:seed": "tsx src/scripts/onboard-seed.ts",
|
|
34
|
+
"workspaces:backfill": "tsx src/scripts/backfill-project-workspaces.ts",
|
|
32
35
|
"build": "tsc -p tsconfig.json",
|
|
33
36
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
34
37
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { AgentRuntimeConfigSchema, type AgentRuntimeConfig, type ThinkingEffort } from "bopodev-contracts";
|
|
2
|
+
|
|
3
|
+
export type LegacyRuntimeFields = {
|
|
4
|
+
runtimeCommand?: string;
|
|
5
|
+
runtimeArgs?: string[];
|
|
6
|
+
runtimeCwd?: string;
|
|
7
|
+
runtimeTimeoutMs?: number;
|
|
8
|
+
runtimeModel?: string;
|
|
9
|
+
runtimeThinkingEffort?: ThinkingEffort;
|
|
10
|
+
bootstrapPrompt?: string;
|
|
11
|
+
runtimeTimeoutSec?: number;
|
|
12
|
+
interruptGraceSec?: number;
|
|
13
|
+
runPolicy?: {
|
|
14
|
+
sandboxMode?: "workspace_write" | "full_access";
|
|
15
|
+
allowWebSearch?: boolean;
|
|
16
|
+
};
|
|
17
|
+
runtimeEnv?: Record<string, string>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type NormalizedRuntimeConfig = {
|
|
21
|
+
runtimeCommand?: string;
|
|
22
|
+
runtimeArgs: string[];
|
|
23
|
+
runtimeCwd?: string;
|
|
24
|
+
runtimeEnv: Record<string, string>;
|
|
25
|
+
runtimeModel?: string;
|
|
26
|
+
runtimeThinkingEffort: ThinkingEffort;
|
|
27
|
+
bootstrapPrompt?: string;
|
|
28
|
+
runtimeTimeoutSec: number;
|
|
29
|
+
interruptGraceSec: number;
|
|
30
|
+
runPolicy: {
|
|
31
|
+
sandboxMode: "workspace_write" | "full_access";
|
|
32
|
+
allowWebSearch: boolean;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function requiresRuntimeCwd(providerType: string) {
|
|
37
|
+
return providerType === "codex" || providerType === "claude_code" || providerType === "shell";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function normalizeRuntimeConfig(input: {
|
|
41
|
+
runtimeConfig?: Partial<AgentRuntimeConfig>;
|
|
42
|
+
legacy?: LegacyRuntimeFields;
|
|
43
|
+
defaultRuntimeCwd?: string;
|
|
44
|
+
}): NormalizedRuntimeConfig {
|
|
45
|
+
const runtimeConfig = input.runtimeConfig ?? {};
|
|
46
|
+
const legacy = input.legacy ?? {};
|
|
47
|
+
const merged: Record<string, unknown> = { ...runtimeConfig };
|
|
48
|
+
|
|
49
|
+
if (legacy.runtimeCommand !== undefined) {
|
|
50
|
+
merged.runtimeCommand = legacy.runtimeCommand;
|
|
51
|
+
}
|
|
52
|
+
if (legacy.runtimeArgs !== undefined) {
|
|
53
|
+
merged.runtimeArgs = legacy.runtimeArgs;
|
|
54
|
+
}
|
|
55
|
+
if (legacy.runtimeCwd !== undefined) {
|
|
56
|
+
merged.runtimeCwd = legacy.runtimeCwd;
|
|
57
|
+
}
|
|
58
|
+
if (legacy.runtimeModel !== undefined) {
|
|
59
|
+
merged.runtimeModel = legacy.runtimeModel;
|
|
60
|
+
}
|
|
61
|
+
if (legacy.runtimeThinkingEffort !== undefined) {
|
|
62
|
+
merged.runtimeThinkingEffort = legacy.runtimeThinkingEffort;
|
|
63
|
+
}
|
|
64
|
+
if (legacy.bootstrapPrompt !== undefined) {
|
|
65
|
+
merged.bootstrapPrompt = legacy.bootstrapPrompt;
|
|
66
|
+
}
|
|
67
|
+
if (legacy.interruptGraceSec !== undefined) {
|
|
68
|
+
merged.interruptGraceSec = legacy.interruptGraceSec;
|
|
69
|
+
}
|
|
70
|
+
if (legacy.runtimeEnv !== undefined) {
|
|
71
|
+
merged.runtimeEnv = legacy.runtimeEnv;
|
|
72
|
+
}
|
|
73
|
+
if (legacy.runPolicy !== undefined) {
|
|
74
|
+
merged.runPolicy = legacy.runPolicy;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parsed = AgentRuntimeConfigSchema.partial().parse({
|
|
78
|
+
...merged,
|
|
79
|
+
runtimeTimeoutSec:
|
|
80
|
+
runtimeConfig.runtimeTimeoutSec ??
|
|
81
|
+
legacy.runtimeTimeoutSec ??
|
|
82
|
+
toSeconds(legacy.runtimeTimeoutMs) ??
|
|
83
|
+
undefined
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
runtimeCommand: parsed.runtimeCommand?.trim() || undefined,
|
|
87
|
+
runtimeArgs: parsed.runtimeArgs ?? [],
|
|
88
|
+
runtimeCwd: parsed.runtimeCwd?.trim() || input.defaultRuntimeCwd || undefined,
|
|
89
|
+
runtimeEnv: parsed.runtimeEnv ?? {},
|
|
90
|
+
runtimeModel: parsed.runtimeModel?.trim() || undefined,
|
|
91
|
+
runtimeThinkingEffort: parsed.runtimeThinkingEffort ?? "auto",
|
|
92
|
+
bootstrapPrompt: parsed.bootstrapPrompt?.trim() || undefined,
|
|
93
|
+
runtimeTimeoutSec: Math.max(0, parsed.runtimeTimeoutSec ?? 0),
|
|
94
|
+
interruptGraceSec: Math.max(0, parsed.interruptGraceSec ?? 15),
|
|
95
|
+
runPolicy: {
|
|
96
|
+
sandboxMode: parsed.runPolicy?.sandboxMode ?? "workspace_write",
|
|
97
|
+
allowWebSearch: parsed.runPolicy?.allowWebSearch ?? false
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseRuntimeConfigFromAgentRow(agent: Record<string, unknown>): NormalizedRuntimeConfig {
|
|
103
|
+
const fallback = parseRuntimeFromStateBlob(agent.stateBlob);
|
|
104
|
+
const runtimeArgs = parseStringArray(agent.runtimeArgsJson) ?? fallback.args ?? [];
|
|
105
|
+
const runtimeEnv = parseStringRecord(agent.runtimeEnvJson) ?? fallback.env ?? {};
|
|
106
|
+
const runPolicy = parseRunPolicy(agent.runPolicyJson);
|
|
107
|
+
const timeoutSecFromColumn = toNumber(agent.runtimeTimeoutSec);
|
|
108
|
+
const timeoutSec =
|
|
109
|
+
timeoutSecFromColumn && timeoutSecFromColumn > 0
|
|
110
|
+
? timeoutSecFromColumn
|
|
111
|
+
: (toSeconds(fallback.timeoutMs) ?? 0);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
runtimeCommand: toText(agent.runtimeCommand) ?? fallback.command,
|
|
115
|
+
runtimeArgs,
|
|
116
|
+
runtimeCwd: toText(agent.runtimeCwd) ?? fallback.cwd,
|
|
117
|
+
runtimeEnv,
|
|
118
|
+
runtimeModel: toText(agent.runtimeModel),
|
|
119
|
+
runtimeThinkingEffort: parseThinkingEffort(agent.runtimeThinkingEffort),
|
|
120
|
+
bootstrapPrompt: toText(agent.bootstrapPrompt),
|
|
121
|
+
runtimeTimeoutSec: Math.max(0, timeoutSec),
|
|
122
|
+
interruptGraceSec: Math.max(0, toNumber(agent.interruptGraceSec) ?? 15),
|
|
123
|
+
runPolicy
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function runtimeConfigToDb(runtime: NormalizedRuntimeConfig) {
|
|
128
|
+
return {
|
|
129
|
+
runtimeCommand: runtime.runtimeCommand ?? null,
|
|
130
|
+
runtimeArgsJson: JSON.stringify(runtime.runtimeArgs),
|
|
131
|
+
runtimeCwd: runtime.runtimeCwd ?? null,
|
|
132
|
+
runtimeEnvJson: JSON.stringify(runtime.runtimeEnv),
|
|
133
|
+
runtimeModel: runtime.runtimeModel ?? null,
|
|
134
|
+
runtimeThinkingEffort: runtime.runtimeThinkingEffort,
|
|
135
|
+
bootstrapPrompt: runtime.bootstrapPrompt ?? null,
|
|
136
|
+
runtimeTimeoutSec: runtime.runtimeTimeoutSec,
|
|
137
|
+
interruptGraceSec: runtime.interruptGraceSec,
|
|
138
|
+
runPolicyJson: JSON.stringify(runtime.runPolicy)
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function runtimeConfigToStateBlobPatch(runtime: NormalizedRuntimeConfig) {
|
|
143
|
+
return {
|
|
144
|
+
runtime: {
|
|
145
|
+
command: runtime.runtimeCommand,
|
|
146
|
+
args: runtime.runtimeArgs,
|
|
147
|
+
cwd: runtime.runtimeCwd,
|
|
148
|
+
env: runtime.runtimeEnv,
|
|
149
|
+
timeoutMs: runtime.runtimeTimeoutSec > 0 ? runtime.runtimeTimeoutSec * 1000 : undefined
|
|
150
|
+
},
|
|
151
|
+
promptTemplate: runtime.bootstrapPrompt
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseRuntimeFromStateBlob(raw: unknown) {
|
|
156
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
157
|
+
return {} as {
|
|
158
|
+
command?: string;
|
|
159
|
+
args?: string[];
|
|
160
|
+
cwd?: string;
|
|
161
|
+
env?: Record<string, string>;
|
|
162
|
+
timeoutMs?: number;
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(raw) as {
|
|
167
|
+
runtime?: {
|
|
168
|
+
command?: unknown;
|
|
169
|
+
args?: unknown;
|
|
170
|
+
cwd?: unknown;
|
|
171
|
+
env?: unknown;
|
|
172
|
+
timeoutMs?: unknown;
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
const runtime = parsed.runtime ?? {};
|
|
176
|
+
return {
|
|
177
|
+
command: typeof runtime.command === "string" ? runtime.command : undefined,
|
|
178
|
+
args: Array.isArray(runtime.args) ? runtime.args.map((entry) => String(entry)) : undefined,
|
|
179
|
+
cwd: typeof runtime.cwd === "string" ? runtime.cwd : undefined,
|
|
180
|
+
env: toRecord(runtime.env),
|
|
181
|
+
timeoutMs: toNumber(runtime.timeoutMs)
|
|
182
|
+
};
|
|
183
|
+
} catch {
|
|
184
|
+
return {};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseStringArray(raw: unknown) {
|
|
189
|
+
if (typeof raw !== "string") {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
194
|
+
return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : null;
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseStringRecord(raw: unknown) {
|
|
201
|
+
if (typeof raw !== "string") {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
return toRecord(JSON.parse(raw));
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseRunPolicy(raw: unknown) {
|
|
212
|
+
const parsed = parseStringRecord(raw) as { sandboxMode?: unknown; allowWebSearch?: unknown } | null;
|
|
213
|
+
return {
|
|
214
|
+
sandboxMode: parsed?.sandboxMode === "full_access" ? "full_access" : "workspace_write",
|
|
215
|
+
allowWebSearch: Boolean(parsed?.allowWebSearch)
|
|
216
|
+
} as const;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseThinkingEffort(raw: unknown): ThinkingEffort {
|
|
220
|
+
return raw === "low" || raw === "medium" || raw === "high" ? raw : "auto";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function toRecord(value: unknown) {
|
|
224
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
return Object.fromEntries(
|
|
228
|
+
Object.entries(value).filter(([, item]) => typeof item === "string")
|
|
229
|
+
) as Record<string, string>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function toText(value: unknown) {
|
|
233
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function toNumber(value: unknown) {
|
|
237
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
if (typeof value === "string") {
|
|
241
|
+
const parsed = Number(value);
|
|
242
|
+
if (Number.isFinite(parsed)) {
|
|
243
|
+
return parsed;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function toSeconds(milliseconds: unknown) {
|
|
250
|
+
const parsedMs = toNumber(milliseconds);
|
|
251
|
+
if (parsedMs === undefined) {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
return Math.max(0, Math.floor(parsedMs / 1000));
|
|
255
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { homedir, tmpdir } from "node:os";
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_INSTANCE_ID = "default";
|
|
5
|
+
const SAFE_PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
|
|
6
|
+
|
|
7
|
+
function expandHomePrefix(value: string) {
|
|
8
|
+
if (value === "~") {
|
|
9
|
+
return homedir();
|
|
10
|
+
}
|
|
11
|
+
if (value.startsWith("~/")) {
|
|
12
|
+
return resolve(homedir(), value.slice(2));
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizePath(raw: string) {
|
|
18
|
+
return resolve(expandHomePrefix(raw.trim()));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveBopoHomeDir() {
|
|
22
|
+
const configured = process.env.BOPO_HOME?.trim();
|
|
23
|
+
if (configured) {
|
|
24
|
+
return normalizePath(configured);
|
|
25
|
+
}
|
|
26
|
+
return resolve(homedir(), ".bopodev");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveBopoInstanceId() {
|
|
30
|
+
const configured = process.env.BOPO_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
|
|
31
|
+
if (!SAFE_PATH_SEGMENT_RE.test(configured)) {
|
|
32
|
+
throw new Error(`Invalid BOPO_INSTANCE_ID '${configured}'.`);
|
|
33
|
+
}
|
|
34
|
+
return configured;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveBopoInstanceRoot() {
|
|
38
|
+
const configuredRoot = process.env.BOPO_INSTANCE_ROOT?.trim();
|
|
39
|
+
if (configuredRoot) {
|
|
40
|
+
return normalizePath(configuredRoot);
|
|
41
|
+
}
|
|
42
|
+
if (process.env.NODE_ENV === "test") {
|
|
43
|
+
return join(tmpdir(), "bopodev-instances", resolveBopoInstanceId());
|
|
44
|
+
}
|
|
45
|
+
return join(resolveBopoHomeDir(), "instances", resolveBopoInstanceId());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveProjectWorkspacePath(companyId: string, projectId: string) {
|
|
49
|
+
assertPathSegment(companyId, "companyId");
|
|
50
|
+
assertPathSegment(projectId, "projectId");
|
|
51
|
+
return join(resolveBopoInstanceRoot(), "workspaces", companyId, "projects", projectId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveCompanyProjectsWorkspacePath(companyId: string) {
|
|
55
|
+
assertPathSegment(companyId, "companyId");
|
|
56
|
+
return join(resolveBopoInstanceRoot(), "workspaces", companyId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function resolveAgentFallbackWorkspacePath(companyId: string, agentId: string) {
|
|
60
|
+
assertPathSegment(companyId, "companyId");
|
|
61
|
+
assertPathSegment(agentId, "agentId");
|
|
62
|
+
return join(resolveBopoInstanceRoot(), "workspaces", companyId, "agents", agentId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveStorageRoot() {
|
|
66
|
+
return join(resolveBopoInstanceRoot(), "data", "storage");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function normalizeAbsolutePath(value: string) {
|
|
70
|
+
return normalizePath(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isInsidePath(parent: string, child: string) {
|
|
74
|
+
const parentResolved = resolve(parent);
|
|
75
|
+
const childResolved = resolve(child);
|
|
76
|
+
if (!isAbsolute(parentResolved) || !isAbsolute(childResolved)) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const rel = relative(parentResolved, childResolved);
|
|
80
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function assertPathSegment(value: string, label: string) {
|
|
84
|
+
const trimmed = value.trim();
|
|
85
|
+
if (!SAFE_PATH_SEGMENT_RE.test(trimmed)) {
|
|
86
|
+
throw new Error(`Invalid ${label} for workspace path '${value}'.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
2
|
+
import type { BopoDb } from "bopodev-db";
|
|
3
|
+
import { projects } from "bopodev-db";
|
|
4
|
+
import {
|
|
5
|
+
isInsidePath,
|
|
6
|
+
normalizeAbsolutePath,
|
|
7
|
+
resolveAgentFallbackWorkspacePath,
|
|
8
|
+
resolveCompanyProjectsWorkspacePath
|
|
9
|
+
} from "./instance-paths";
|
|
10
|
+
|
|
11
|
+
export function hasText(value: string | null | undefined) {
|
|
12
|
+
return Boolean(value && value.trim().length > 0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseRuntimeCwd(stateBlob: string | null | undefined) {
|
|
16
|
+
if (!stateBlob) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(stateBlob) as { runtime?: { cwd?: unknown } };
|
|
21
|
+
const cwd = parsed.runtime?.cwd;
|
|
22
|
+
return typeof cwd === "string" ? cwd : null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function inferSingleWorkspaceLocalPath(db: BopoDb, companyId: string) {
|
|
29
|
+
const rows = await db
|
|
30
|
+
.select({ workspaceLocalPath: projects.workspaceLocalPath })
|
|
31
|
+
.from(projects)
|
|
32
|
+
.where(eq(projects.companyId, companyId));
|
|
33
|
+
const paths = Array.from(
|
|
34
|
+
new Set(
|
|
35
|
+
rows
|
|
36
|
+
.map((row) => row.workspaceLocalPath?.trim() ?? "")
|
|
37
|
+
.filter((value) => value.length > 0)
|
|
38
|
+
)
|
|
39
|
+
);
|
|
40
|
+
const singlePath = paths.length === 1 ? paths[0] : null;
|
|
41
|
+
return singlePath ? normalizeAbsolutePath(singlePath) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function resolveDefaultRuntimeCwdForCompany(db: BopoDb, companyId: string) {
|
|
45
|
+
const inferredSingleWorkspace = await inferSingleWorkspaceLocalPath(db, companyId);
|
|
46
|
+
if (inferredSingleWorkspace) {
|
|
47
|
+
return inferredSingleWorkspace;
|
|
48
|
+
}
|
|
49
|
+
return resolveCompanyProjectsWorkspacePath(companyId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function getProjectWorkspaceMap(db: BopoDb, companyId: string, projectIds: string[]) {
|
|
53
|
+
if (projectIds.length === 0) {
|
|
54
|
+
return new Map<string, string | null>();
|
|
55
|
+
}
|
|
56
|
+
const rows = await db
|
|
57
|
+
.select({ id: projects.id, workspaceLocalPath: projects.workspaceLocalPath })
|
|
58
|
+
.from(projects)
|
|
59
|
+
.where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)));
|
|
60
|
+
return new Map(rows.map((row) => [row.id, row.workspaceLocalPath ? normalizeAbsolutePath(row.workspaceLocalPath) : null]));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function ensureRuntimeInsideWorkspace(projectWorkspacePath: string, runtimeCwd: string) {
|
|
64
|
+
return isInsidePath(normalizeAbsolutePath(projectWorkspacePath), normalizeAbsolutePath(runtimeCwd));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ensureRuntimeWorkspaceCompatible(projectWorkspacePath: string, runtimeCwd: string) {
|
|
68
|
+
const projectPath = normalizeAbsolutePath(projectWorkspacePath);
|
|
69
|
+
const runtimePath = normalizeAbsolutePath(runtimeCwd);
|
|
70
|
+
return isInsidePath(projectPath, runtimePath) || isInsidePath(runtimePath, projectPath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resolveAgentFallbackWorkspace(companyId: string, agentId: string) {
|
|
74
|
+
return resolveAgentFallbackWorkspacePath(companyId, agentId);
|
|
75
|
+
}
|
|
@@ -20,13 +20,28 @@ declare global {
|
|
|
20
20
|
|
|
21
21
|
export function attachRequestActor(req: Request, _res: Response, next: NextFunction) {
|
|
22
22
|
const actorTypeHeader = req.header("x-actor-type")?.trim().toLowerCase();
|
|
23
|
-
const
|
|
24
|
-
|
|
23
|
+
const actorTypeFromHeader =
|
|
24
|
+
actorTypeHeader === "agent" || actorTypeHeader === "member" || actorTypeHeader === "board"
|
|
25
|
+
? actorTypeHeader
|
|
26
|
+
: null;
|
|
27
|
+
const actorIdHeader = req.header("x-actor-id")?.trim();
|
|
25
28
|
const companyIdsHeader = req.header("x-actor-companies")?.trim();
|
|
26
29
|
const permissionsHeader = req.header("x-actor-permissions")?.trim();
|
|
27
30
|
|
|
28
31
|
const companyIds = parseCommaList(companyIdsHeader);
|
|
29
32
|
const permissions = parseCommaList(permissionsHeader) ?? [];
|
|
33
|
+
const hasActorHeaders = Boolean(actorTypeFromHeader || actorIdHeader || companyIds || permissions.length > 0);
|
|
34
|
+
const allowLocalBoardFallback = process.env.NODE_ENV !== "production" && process.env.BOPO_ALLOW_LOCAL_BOARD_FALLBACK !== "0";
|
|
35
|
+
const actorType = hasActorHeaders
|
|
36
|
+
? actorTypeFromHeader ?? "member"
|
|
37
|
+
: allowLocalBoardFallback
|
|
38
|
+
? "board"
|
|
39
|
+
: "member";
|
|
40
|
+
const actorId = hasActorHeaders
|
|
41
|
+
? actorIdHeader || "unknown-actor"
|
|
42
|
+
: allowLocalBoardFallback
|
|
43
|
+
? "local-board"
|
|
44
|
+
: "anonymous-member";
|
|
30
45
|
|
|
31
46
|
req.actor = {
|
|
32
47
|
type: actorType,
|
|
@@ -47,7 +62,7 @@ export function requirePermission(permission: string) {
|
|
|
47
62
|
}
|
|
48
63
|
|
|
49
64
|
export function requireBoardRole(req: Request, res: Response, next: NextFunction) {
|
|
50
|
-
if (
|
|
65
|
+
if (req.actor?.type !== "board") {
|
|
51
66
|
return sendError(res, "Board role required.", 403);
|
|
52
67
|
}
|
|
53
68
|
next();
|
|
@@ -55,7 +70,10 @@ export function requireBoardRole(req: Request, res: Response, next: NextFunction
|
|
|
55
70
|
|
|
56
71
|
export function canAccessCompany(req: Request, companyId: string) {
|
|
57
72
|
const actor = req.actor;
|
|
58
|
-
if (!actor
|
|
73
|
+
if (!actor) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (actor.type === "board") {
|
|
59
77
|
return true;
|
|
60
78
|
}
|
|
61
79
|
return actor.companyIds?.includes(companyId) ?? false;
|
|
@@ -63,7 +81,10 @@ export function canAccessCompany(req: Request, companyId: string) {
|
|
|
63
81
|
|
|
64
82
|
function hasPermission(req: Request, permission: string) {
|
|
65
83
|
const actor = req.actor;
|
|
66
|
-
if (!actor
|
|
84
|
+
if (!actor) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (actor.type === "board") {
|
|
67
88
|
return true;
|
|
68
89
|
}
|
|
69
90
|
return actor.permissions.includes(permission);
|
|
@@ -135,6 +135,7 @@ async function loadOfficeOccupantForAgent(db: BopoDb, companyId: string, agentId
|
|
|
135
135
|
id: agents.id,
|
|
136
136
|
companyId: agents.companyId,
|
|
137
137
|
name: agents.name,
|
|
138
|
+
avatarSeed: agents.avatarSeed,
|
|
138
139
|
role: agents.role,
|
|
139
140
|
status: agents.status,
|
|
140
141
|
providerType: agents.providerType,
|
|
@@ -217,6 +218,7 @@ function deriveAgentOccupant(
|
|
|
217
218
|
id: string;
|
|
218
219
|
companyId: string;
|
|
219
220
|
name: string;
|
|
221
|
+
avatarSeed?: string | null;
|
|
220
222
|
role: string;
|
|
221
223
|
status: string;
|
|
222
224
|
providerType: string;
|
|
@@ -294,6 +296,7 @@ function deriveAgentOccupant(
|
|
|
294
296
|
status: "waiting_for_approval",
|
|
295
297
|
taskLabel: `${formatActionLabel(pendingApproval.action)} approval`,
|
|
296
298
|
providerType: normalizeProviderType(agent.providerType),
|
|
299
|
+
avatarSeed: agent.avatarSeed ?? null,
|
|
297
300
|
focusEntityType: "approval",
|
|
298
301
|
focusEntityId: pendingApproval.id,
|
|
299
302
|
updatedAt: pendingApproval.createdAt.toISOString()
|
|
@@ -313,6 +316,7 @@ function deriveAgentOccupant(
|
|
|
313
316
|
status: "working",
|
|
314
317
|
taskLabel: claimedIssues[0]?.title ?? "Checking in on work",
|
|
315
318
|
providerType: normalizeProviderType(agent.providerType),
|
|
319
|
+
avatarSeed: agent.avatarSeed ?? null,
|
|
316
320
|
focusEntityType: claimedIssues[0] ? "issue" : "agent",
|
|
317
321
|
focusEntityId: claimedIssues[0]?.id ?? agent.id,
|
|
318
322
|
updatedAt: activeRun.startedAt.toISOString()
|
|
@@ -332,6 +336,7 @@ function deriveAgentOccupant(
|
|
|
332
336
|
status: "paused",
|
|
333
337
|
taskLabel: "Paused",
|
|
334
338
|
providerType: normalizeProviderType(agent.providerType),
|
|
339
|
+
avatarSeed: agent.avatarSeed ?? null,
|
|
335
340
|
focusEntityType: "agent",
|
|
336
341
|
focusEntityId: agent.id,
|
|
337
342
|
updatedAt: agent.updatedAt.toISOString()
|
|
@@ -350,6 +355,7 @@ function deriveAgentOccupant(
|
|
|
350
355
|
status: "idle",
|
|
351
356
|
taskLabel: nextAssignedIssue ? `Up next: ${nextAssignedIssue.title}` : "Waiting for work",
|
|
352
357
|
providerType: normalizeProviderType(agent.providerType),
|
|
358
|
+
avatarSeed: agent.avatarSeed ?? null,
|
|
353
359
|
focusEntityType: nextAssignedIssue ? "issue" : "agent",
|
|
354
360
|
focusEntityId: nextAssignedIssue?.id ?? agent.id,
|
|
355
361
|
updatedAt: nextAssignedIssue?.updatedAt.toISOString() ?? agent.updatedAt.toISOString()
|
|
@@ -386,6 +392,7 @@ function deriveHireCandidateOccupant(approval: {
|
|
|
386
392
|
status: "waiting_for_approval",
|
|
387
393
|
taskLabel: "Awaiting hire approval",
|
|
388
394
|
providerType,
|
|
395
|
+
avatarSeed: null,
|
|
389
396
|
focusEntityType: "approval",
|
|
390
397
|
focusEntityId: approval.id,
|
|
391
398
|
updatedAt: approval.createdAt.toISOString()
|