bopodev-api 0.1.27 → 0.1.29
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 +4 -4
- package/src/app.ts +17 -70
- package/src/lib/drainable-work.ts +36 -0
- package/src/lib/run-artifact-paths.ts +8 -0
- package/src/lib/workspace-policy.ts +1 -2
- package/src/middleware/cors-config.ts +36 -0
- package/src/middleware/request-actor.ts +10 -16
- package/src/middleware/request-id.ts +9 -0
- package/src/middleware/request-logging.ts +24 -0
- package/src/realtime/office-space.ts +3 -1
- package/src/routes/agents.ts +3 -9
- package/src/routes/companies.ts +18 -1
- package/src/routes/goals.ts +7 -13
- package/src/routes/governance.ts +2 -5
- package/src/routes/heartbeats.ts +8 -27
- package/src/routes/issues.ts +66 -121
- package/src/routes/observability.ts +6 -1
- package/src/routes/plugins.ts +5 -17
- package/src/routes/projects.ts +7 -25
- package/src/routes/templates.ts +6 -21
- package/src/scripts/onboard-seed.ts +5 -7
- package/src/server.ts +35 -276
- package/src/services/attention-service.ts +4 -1
- package/src/services/budget-service.ts +1 -2
- package/src/services/comment-recipient-dispatch-service.ts +39 -2
- package/src/services/company-export-service.ts +63 -0
- package/src/services/governance-service.ts +6 -2
- package/src/services/heartbeat-queue-service.ts +34 -3
- package/src/services/heartbeat-service/active-runs.ts +15 -0
- package/src/services/heartbeat-service/budget-override.ts +46 -0
- package/src/services/heartbeat-service/claims.ts +61 -0
- package/src/services/heartbeat-service/cron.ts +58 -0
- package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
- package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
- package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +217 -635
- package/src/services/heartbeat-service/index.ts +5 -0
- package/src/services/heartbeat-service/stop.ts +90 -0
- package/src/services/heartbeat-service/sweep.ts +145 -0
- package/src/services/heartbeat-service/types.ts +65 -0
- package/src/services/memory-file-service.ts +10 -2
- package/src/shutdown/graceful-shutdown.ts +77 -0
- package/src/startup/database.ts +41 -0
- package/src/startup/deployment-validation.ts +37 -0
- package/src/startup/env.ts +17 -0
- package/src/startup/runtime-health.ts +128 -0
- package/src/startup/scheduler-config.ts +39 -0
- package/src/types/express.d.ts +13 -0
- package/src/types/request-actor.ts +6 -0
- package/src/validation/issue-routes.ts +79 -0
- package/src/worker/scheduler.ts +20 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bopodev-api",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.29",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
"nanoid": "^5.1.5",
|
|
18
18
|
"ws": "^8.19.0",
|
|
19
19
|
"zod": "^4.1.5",
|
|
20
|
-
"bopodev-contracts": "0.1.
|
|
21
|
-
"bopodev-agent-sdk": "0.1.
|
|
22
|
-
"bopodev-db": "0.1.
|
|
20
|
+
"bopodev-contracts": "0.1.29",
|
|
21
|
+
"bopodev-agent-sdk": "0.1.29",
|
|
22
|
+
"bopodev-db": "0.1.29"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/cors": "^2.8.19",
|
package/src/app.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import cors from "cors";
|
|
2
1
|
import express from "express";
|
|
3
2
|
import type { NextFunction, Request, Response } from "express";
|
|
4
|
-
import {
|
|
5
|
-
import { RepositoryValidationError } from "bopodev-db";
|
|
6
|
-
import { nanoid } from "nanoid";
|
|
3
|
+
import { pingDatabase, RepositoryValidationError } from "bopodev-db";
|
|
7
4
|
import type { AppContext } from "./context";
|
|
8
5
|
import { createAgentsRouter } from "./routes/agents";
|
|
9
6
|
import { createAuthRouter } from "./routes/auth";
|
|
@@ -18,6 +15,9 @@ import { createProjectsRouter } from "./routes/projects";
|
|
|
18
15
|
import { createPluginsRouter } from "./routes/plugins";
|
|
19
16
|
import { createTemplatesRouter } from "./routes/templates";
|
|
20
17
|
import { sendError } from "./http";
|
|
18
|
+
import { createCorsMiddleware } from "./middleware/cors-config";
|
|
19
|
+
import { attachCrudRequestLogging } from "./middleware/request-logging";
|
|
20
|
+
import { attachRequestId } from "./middleware/request-id";
|
|
21
21
|
import { attachRequestActor } from "./middleware/request-actor";
|
|
22
22
|
import { resolveAllowedOrigins, resolveDeploymentMode } from "./security/deployment-mode";
|
|
23
23
|
|
|
@@ -25,75 +25,20 @@ export function createApp(ctx: AppContext) {
|
|
|
25
25
|
const app = express();
|
|
26
26
|
const deploymentMode = ctx.deploymentMode ?? resolveDeploymentMode();
|
|
27
27
|
const allowedOrigins = ctx.allowedOrigins ?? resolveAllowedOrigins(deploymentMode);
|
|
28
|
-
app.use(
|
|
29
|
-
cors({
|
|
30
|
-
origin(origin, callback) {
|
|
31
|
-
if (!origin) {
|
|
32
|
-
callback(null, true);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
if (
|
|
36
|
-
deploymentMode === "local" &&
|
|
37
|
-
(origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:"))
|
|
38
|
-
) {
|
|
39
|
-
callback(null, true);
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
if (allowedOrigins.includes(origin)) {
|
|
43
|
-
callback(null, true);
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
callback(new Error(`CORS origin denied: ${origin}`));
|
|
47
|
-
},
|
|
48
|
-
credentials: true,
|
|
49
|
-
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
50
|
-
allowedHeaders: [
|
|
51
|
-
"content-type",
|
|
52
|
-
"x-company-id",
|
|
53
|
-
"authorization",
|
|
54
|
-
"x-client-trace-id",
|
|
55
|
-
"x-bopo-actor-token",
|
|
56
|
-
"x-request-id",
|
|
57
|
-
"x-bopodev-run-id"
|
|
58
|
-
]
|
|
59
|
-
})
|
|
60
|
-
);
|
|
28
|
+
app.use(createCorsMiddleware(deploymentMode, allowedOrigins));
|
|
61
29
|
app.use(express.json());
|
|
62
30
|
app.use(attachRequestActor);
|
|
63
|
-
|
|
64
|
-
app.use((req, res, next) => {
|
|
65
|
-
const requestId = req.header("x-request-id")?.trim() || nanoid(14);
|
|
66
|
-
req.requestId = requestId;
|
|
67
|
-
res.setHeader("x-request-id", requestId);
|
|
68
|
-
next();
|
|
69
|
-
});
|
|
31
|
+
app.use(attachRequestId);
|
|
70
32
|
const logApiRequests = process.env.BOPO_LOG_API_REQUESTS !== "0";
|
|
71
33
|
if (logApiRequests) {
|
|
72
|
-
app.use(
|
|
73
|
-
if (req.path === "/health") {
|
|
74
|
-
next();
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
const method = req.method.toUpperCase();
|
|
78
|
-
if (!isCrudMethod(method)) {
|
|
79
|
-
next();
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
const startedAt = Date.now();
|
|
83
|
-
res.on("finish", () => {
|
|
84
|
-
const elapsedMs = Date.now() - startedAt;
|
|
85
|
-
const timestamp = new Date().toTimeString().slice(0, 8);
|
|
86
|
-
process.stderr.write(`[${timestamp}] INFO: ${method} ${req.originalUrl} ${res.statusCode} ${elapsedMs}ms\n`);
|
|
87
|
-
});
|
|
88
|
-
next();
|
|
89
|
-
});
|
|
34
|
+
app.use(attachCrudRequestLogging);
|
|
90
35
|
}
|
|
91
36
|
|
|
92
37
|
app.get("/health", async (_req, res) => {
|
|
93
38
|
let dbReady = false;
|
|
94
39
|
let dbError: string | undefined;
|
|
95
40
|
try {
|
|
96
|
-
await ctx.db
|
|
41
|
+
await pingDatabase(ctx.db);
|
|
97
42
|
dbReady = true;
|
|
98
43
|
} catch (error) {
|
|
99
44
|
dbError = String(error);
|
|
@@ -127,18 +72,20 @@ export function createApp(ctx: AppContext) {
|
|
|
127
72
|
app.use("/plugins", createPluginsRouter(ctx));
|
|
128
73
|
app.use("/templates", createTemplatesRouter(ctx));
|
|
129
74
|
|
|
130
|
-
app.use((error: unknown,
|
|
75
|
+
app.use((error: unknown, req: Request, res: Response, _next: NextFunction) => {
|
|
131
76
|
if (error instanceof RepositoryValidationError) {
|
|
132
77
|
return sendError(res, error.message, 422);
|
|
133
78
|
}
|
|
134
|
-
|
|
135
|
-
|
|
79
|
+
const requestId = req.requestId;
|
|
80
|
+
if (requestId) {
|
|
81
|
+
// eslint-disable-next-line no-console
|
|
82
|
+
console.error(`[request ${requestId}]`, error);
|
|
83
|
+
} else {
|
|
84
|
+
// eslint-disable-next-line no-console
|
|
85
|
+
console.error(error);
|
|
86
|
+
}
|
|
136
87
|
return sendError(res, "Internal server error", 500);
|
|
137
88
|
});
|
|
138
89
|
|
|
139
90
|
return app;
|
|
140
91
|
}
|
|
141
|
-
|
|
142
|
-
function isCrudMethod(method: string) {
|
|
143
|
-
return method === "GET" || method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
144
|
-
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type DrainableWorkTracker = {
|
|
2
|
+
beginShutdown: () => void;
|
|
3
|
+
isShuttingDown: () => boolean;
|
|
4
|
+
track: <T>(promise: Promise<T>) => Promise<T>;
|
|
5
|
+
drain: () => Promise<void>;
|
|
6
|
+
resetForTests: () => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function createDrainableWorkTracker(): DrainableWorkTracker {
|
|
10
|
+
let shuttingDown = false;
|
|
11
|
+
const pending = new Set<Promise<unknown>>();
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
beginShutdown() {
|
|
15
|
+
shuttingDown = true;
|
|
16
|
+
},
|
|
17
|
+
isShuttingDown() {
|
|
18
|
+
return shuttingDown;
|
|
19
|
+
},
|
|
20
|
+
track<T>(promise: Promise<T>) {
|
|
21
|
+
let tracked: Promise<T>;
|
|
22
|
+
tracked = promise.finally(() => {
|
|
23
|
+
pending.delete(tracked);
|
|
24
|
+
});
|
|
25
|
+
pending.add(tracked);
|
|
26
|
+
return tracked;
|
|
27
|
+
},
|
|
28
|
+
async drain() {
|
|
29
|
+
await Promise.allSettled([...pending]);
|
|
30
|
+
},
|
|
31
|
+
resetForTests() {
|
|
32
|
+
shuttingDown = false;
|
|
33
|
+
pending.clear();
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -70,6 +70,14 @@ function normalizeWorkspaceRelativeArtifactPath(value: string) {
|
|
|
70
70
|
}
|
|
71
71
|
const workspaceScopedMatch = normalized.match(/(?:^|\/)workspace\/([^/]+)\/(.+)$/);
|
|
72
72
|
if (!workspaceScopedMatch) {
|
|
73
|
+
const projectAgentsMatch = normalized.match(/(?:^|\/)projects\/agents\/([^/]+)\/operating\/(.+)$/);
|
|
74
|
+
if (projectAgentsMatch) {
|
|
75
|
+
const [, agentId, suffix] = projectAgentsMatch;
|
|
76
|
+
if (!agentId || !suffix) {
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
return `agents/${agentId}/operating/${suffix}`;
|
|
80
|
+
}
|
|
73
81
|
return normalized;
|
|
74
82
|
}
|
|
75
83
|
const scopedRelativePath = workspaceScopedMatch[2];
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { and, eq, inArray } from "drizzle-orm";
|
|
2
1
|
import type { BopoDb } from "bopodev-db";
|
|
3
|
-
import { projectWorkspaces, projects } from "bopodev-db";
|
|
2
|
+
import { and, eq, inArray, projectWorkspaces, projects } from "bopodev-db";
|
|
4
3
|
import {
|
|
5
4
|
assertPathInsideCompanyWorkspaceRoot,
|
|
6
5
|
isInsidePath,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import cors from "cors";
|
|
2
|
+
import type { DeploymentMode } from "../security/deployment-mode";
|
|
3
|
+
|
|
4
|
+
export function createCorsMiddleware(deploymentMode: DeploymentMode, allowedOrigins: string[]) {
|
|
5
|
+
return cors({
|
|
6
|
+
origin(origin, callback) {
|
|
7
|
+
if (!origin) {
|
|
8
|
+
callback(null, true);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
if (
|
|
12
|
+
deploymentMode === "local" &&
|
|
13
|
+
(origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:"))
|
|
14
|
+
) {
|
|
15
|
+
callback(null, true);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (allowedOrigins.includes(origin)) {
|
|
19
|
+
callback(null, true);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
callback(new Error(`CORS origin denied: ${origin}`));
|
|
23
|
+
},
|
|
24
|
+
credentials: true,
|
|
25
|
+
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
26
|
+
allowedHeaders: [
|
|
27
|
+
"content-type",
|
|
28
|
+
"x-company-id",
|
|
29
|
+
"authorization",
|
|
30
|
+
"x-client-trace-id",
|
|
31
|
+
"x-bopo-actor-token",
|
|
32
|
+
"x-request-id",
|
|
33
|
+
"x-bopodev-run-id"
|
|
34
|
+
]
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -3,23 +3,9 @@ import { RequestActorHeadersSchema } from "bopodev-contracts";
|
|
|
3
3
|
import { sendError } from "../http";
|
|
4
4
|
import { verifyActorToken } from "../security/actor-token";
|
|
5
5
|
import { isAuthenticatedMode, resolveDeploymentMode } from "../security/deployment-mode";
|
|
6
|
+
import type { RequestActor } from "../types/request-actor";
|
|
6
7
|
|
|
7
|
-
export type RequestActor
|
|
8
|
-
type: "board" | "member" | "agent";
|
|
9
|
-
id: string;
|
|
10
|
-
companyIds: string[] | null;
|
|
11
|
-
permissions: string[];
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
declare global {
|
|
15
|
-
namespace Express {
|
|
16
|
-
interface Request {
|
|
17
|
-
actor?: RequestActor;
|
|
18
|
-
companyId?: string;
|
|
19
|
-
requestId?: string;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
}
|
|
8
|
+
export type { RequestActor };
|
|
23
9
|
|
|
24
10
|
export function attachRequestActor(req: Request, res: Response, next: NextFunction) {
|
|
25
11
|
const deploymentMode = resolveDeploymentMode();
|
|
@@ -136,6 +122,14 @@ export function requirePermission(permission: string) {
|
|
|
136
122
|
};
|
|
137
123
|
}
|
|
138
124
|
|
|
125
|
+
export function enforcePermission(req: Request, res: Response, permission: string): boolean {
|
|
126
|
+
let allowed = false;
|
|
127
|
+
requirePermission(permission)(req, res, () => {
|
|
128
|
+
allowed = true;
|
|
129
|
+
});
|
|
130
|
+
return allowed;
|
|
131
|
+
}
|
|
132
|
+
|
|
139
133
|
export function requireBoardRole(req: Request, res: Response, next: NextFunction) {
|
|
140
134
|
if (req.actor?.type !== "board") {
|
|
141
135
|
return sendError(res, "Board role required.", 403);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from "express";
|
|
2
|
+
import { nanoid } from "nanoid";
|
|
3
|
+
|
|
4
|
+
export function attachRequestId(req: Request, res: Response, next: NextFunction) {
|
|
5
|
+
const requestId = req.header("x-request-id")?.trim() || nanoid(14);
|
|
6
|
+
req.requestId = requestId;
|
|
7
|
+
res.setHeader("x-request-id", requestId);
|
|
8
|
+
next();
|
|
9
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from "express";
|
|
2
|
+
|
|
3
|
+
function isCrudMethod(method: string) {
|
|
4
|
+
return method === "GET" || method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function attachCrudRequestLogging(req: Request, res: Response, next: NextFunction) {
|
|
8
|
+
if (req.path === "/health") {
|
|
9
|
+
next();
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const method = req.method.toUpperCase();
|
|
13
|
+
if (!isCrudMethod(method)) {
|
|
14
|
+
next();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const startedAt = Date.now();
|
|
18
|
+
res.on("finish", () => {
|
|
19
|
+
const elapsedMs = Date.now() - startedAt;
|
|
20
|
+
const timestamp = new Date().toTimeString().slice(0, 8);
|
|
21
|
+
process.stderr.write(`[${timestamp}] INFO: ${method} ${req.originalUrl} ${res.statusCode} ${elapsedMs}ms\n`);
|
|
22
|
+
});
|
|
23
|
+
next();
|
|
24
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { and, desc, eq } from "drizzle-orm";
|
|
2
1
|
import type { OfficeOccupant, RealtimeEventEnvelope, RealtimeMessage } from "bopodev-contracts";
|
|
3
2
|
import { AGENT_ROLE_LABELS, AgentRoleKeySchema } from "bopodev-contracts";
|
|
4
3
|
import {
|
|
4
|
+
and,
|
|
5
5
|
agents,
|
|
6
6
|
approvalRequests,
|
|
7
|
+
desc,
|
|
8
|
+
eq,
|
|
7
9
|
getApprovalRequest,
|
|
8
10
|
heartbeatRuns,
|
|
9
11
|
issues,
|
package/src/routes/agents.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { resolveHiringDelegate } from "../lib/hiring-delegate";
|
|
|
37
37
|
import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
|
|
38
38
|
import { assertRuntimeCwdForCompany, hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
|
|
39
39
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
40
|
-
import { requireBoardRole, requirePermission } from "../middleware/request-actor";
|
|
40
|
+
import { enforcePermission, requireBoardRole, requirePermission } from "../middleware/request-actor";
|
|
41
41
|
import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
|
|
42
42
|
import { publishAttentionSnapshot } from "../realtime/attention";
|
|
43
43
|
import {
|
|
@@ -629,10 +629,7 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
629
629
|
});
|
|
630
630
|
|
|
631
631
|
router.post("/:agentId/pause", async (req, res) => {
|
|
632
|
-
|
|
633
|
-
if (res.headersSent) {
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
632
|
+
if (!enforcePermission(req, res, "agents:lifecycle")) return;
|
|
636
633
|
const agent = await updateAgent(ctx.db, {
|
|
637
634
|
companyId: req.companyId!,
|
|
638
635
|
id: req.params.agentId,
|
|
@@ -656,10 +653,7 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
656
653
|
});
|
|
657
654
|
|
|
658
655
|
router.post("/:agentId/resume", async (req, res) => {
|
|
659
|
-
|
|
660
|
-
if (res.headersSent) {
|
|
661
|
-
return;
|
|
662
|
-
}
|
|
656
|
+
if (!enforcePermission(req, res, "agents:lifecycle")) return;
|
|
663
657
|
const agent = await updateAgent(ctx.db, {
|
|
664
658
|
companyId: req.companyId!,
|
|
665
659
|
id: req.params.agentId,
|
package/src/routes/companies.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { normalizeRuntimeConfig, resolveRuntimeModelForProvider, runtimeConfigTo
|
|
|
10
10
|
import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
|
|
11
11
|
import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
|
|
12
12
|
import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
|
|
13
|
+
import { buildCompanyPortabilityExport } from "../services/company-export-service";
|
|
13
14
|
import { canAccessCompany, requireBoardRole, requirePermission } from "../middleware/request-actor";
|
|
14
15
|
import { ensureCompanyBuiltinPluginDefaults } from "../services/plugin-runtime";
|
|
15
16
|
import { ensureCompanyBuiltinTemplateDefaults } from "../services/template-catalog";
|
|
@@ -21,7 +22,7 @@ const createCompanySchema = z.object({
|
|
|
21
22
|
name: z.string().min(1),
|
|
22
23
|
mission: z.string().optional(),
|
|
23
24
|
providerType: z
|
|
24
|
-
.enum(["codex", "claude_code", "cursor", "gemini_cli", "opencode", "openai_api", "anthropic_api", "shell"])
|
|
25
|
+
.enum(["codex", "claude_code", "cursor", "gemini_cli", "opencode", "openai_api", "anthropic_api", "http", "shell"])
|
|
25
26
|
.optional(),
|
|
26
27
|
runtimeModel: z.string().optional()
|
|
27
28
|
});
|
|
@@ -53,6 +54,21 @@ export function createCompaniesRouter(ctx: AppContext) {
|
|
|
53
54
|
);
|
|
54
55
|
});
|
|
55
56
|
|
|
57
|
+
router.get("/:companyId/export", async (req, res) => {
|
|
58
|
+
const companyId = readCompanyIdParam(req);
|
|
59
|
+
if (!companyId) {
|
|
60
|
+
return sendError(res, "Missing company id.", 422);
|
|
61
|
+
}
|
|
62
|
+
if (!canAccessCompany(req, companyId)) {
|
|
63
|
+
return sendError(res, "Actor does not have access to this company.", 403);
|
|
64
|
+
}
|
|
65
|
+
const payload = await buildCompanyPortabilityExport(ctx.db, companyId);
|
|
66
|
+
if (!payload) {
|
|
67
|
+
return sendError(res, "Company not found.", 404);
|
|
68
|
+
}
|
|
69
|
+
return sendOk(res, payload);
|
|
70
|
+
});
|
|
71
|
+
|
|
56
72
|
router.post("/", requireBoardRole, async (req, res) => {
|
|
57
73
|
const parsed = createCompanySchema.safeParse(req.body);
|
|
58
74
|
if (!parsed.success) {
|
|
@@ -171,6 +187,7 @@ function parseAgentProvider(value: unknown) {
|
|
|
171
187
|
value === "opencode" ||
|
|
172
188
|
value === "openai_api" ||
|
|
173
189
|
value === "anthropic_api" ||
|
|
190
|
+
value === "http" ||
|
|
174
191
|
value === "shell"
|
|
175
192
|
) {
|
|
176
193
|
return value;
|
package/src/routes/goals.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { appendAuditEvent, createApprovalRequest, createGoal, deleteGoal, getApp
|
|
|
5
5
|
import type { AppContext } from "../context";
|
|
6
6
|
import { sendError, sendOk, sendOkValidated } from "../http";
|
|
7
7
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
8
|
-
import {
|
|
8
|
+
import { enforcePermission } from "../middleware/request-actor";
|
|
9
9
|
import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
|
|
10
10
|
import { publishAttentionSnapshot } from "../realtime/attention";
|
|
11
11
|
import { isApprovalRequired } from "../services/governance-service";
|
|
@@ -13,6 +13,7 @@ import { isApprovalRequired } from "../services/governance-service";
|
|
|
13
13
|
const createGoalSchema = z.object({
|
|
14
14
|
projectId: z.string().optional(),
|
|
15
15
|
parentGoalId: z.string().optional(),
|
|
16
|
+
ownerAgentId: z.string().optional(),
|
|
16
17
|
level: z.enum(["company", "project", "agent"]),
|
|
17
18
|
title: z.string().min(1),
|
|
18
19
|
description: z.string().optional(),
|
|
@@ -23,6 +24,7 @@ const updateGoalSchema = z
|
|
|
23
24
|
.object({
|
|
24
25
|
projectId: z.string().nullable().optional(),
|
|
25
26
|
parentGoalId: z.string().nullable().optional(),
|
|
27
|
+
ownerAgentId: z.string().nullable().optional(),
|
|
26
28
|
level: z.enum(["company", "project", "agent"]).optional(),
|
|
27
29
|
title: z.string().min(1).optional(),
|
|
28
30
|
description: z.string().nullable().optional(),
|
|
@@ -44,10 +46,7 @@ export function createGoalsRouter(ctx: AppContext) {
|
|
|
44
46
|
});
|
|
45
47
|
|
|
46
48
|
router.post("/", async (req, res) => {
|
|
47
|
-
|
|
48
|
-
if (res.headersSent) {
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
49
|
+
if (!enforcePermission(req, res, "goals:write")) return;
|
|
51
50
|
const parsed = createGoalSchema.safeParse(req.body);
|
|
52
51
|
if (!parsed.success) {
|
|
53
52
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -76,6 +75,7 @@ export function createGoalsRouter(ctx: AppContext) {
|
|
|
76
75
|
companyId: req.companyId!,
|
|
77
76
|
projectId: parsed.data.projectId,
|
|
78
77
|
parentGoalId: parsed.data.parentGoalId,
|
|
78
|
+
ownerAgentId: parsed.data.ownerAgentId,
|
|
79
79
|
level: parsed.data.level,
|
|
80
80
|
title: parsed.data.title,
|
|
81
81
|
description: parsed.data.description
|
|
@@ -92,10 +92,7 @@ export function createGoalsRouter(ctx: AppContext) {
|
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
router.put("/:goalId", async (req, res) => {
|
|
95
|
-
|
|
96
|
-
if (res.headersSent) {
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
95
|
+
if (!enforcePermission(req, res, "goals:write")) return;
|
|
99
96
|
const parsed = updateGoalSchema.safeParse(req.body);
|
|
100
97
|
if (!parsed.success) {
|
|
101
98
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -118,10 +115,7 @@ export function createGoalsRouter(ctx: AppContext) {
|
|
|
118
115
|
});
|
|
119
116
|
|
|
120
117
|
router.delete("/:goalId", async (req, res) => {
|
|
121
|
-
|
|
122
|
-
if (res.headersSent) {
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
118
|
+
if (!enforcePermission(req, res, "goals:write")) return;
|
|
125
119
|
const deleted = await deleteGoal(ctx.db, req.companyId!, req.params.goalId);
|
|
126
120
|
if (!deleted) {
|
|
127
121
|
return sendError(res, "Goal not found.", 404);
|
package/src/routes/governance.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import type { AppContext } from "../context";
|
|
15
15
|
import { sendError, sendOk } from "../http";
|
|
16
16
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
17
|
-
import {
|
|
17
|
+
import { enforcePermission } from "../middleware/request-actor";
|
|
18
18
|
import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
|
|
19
19
|
import { publishAttentionSnapshot } from "../realtime/attention";
|
|
20
20
|
import {
|
|
@@ -146,10 +146,7 @@ export function createGovernanceRouter(ctx: AppContext) {
|
|
|
146
146
|
});
|
|
147
147
|
|
|
148
148
|
router.post("/resolve", async (req, res) => {
|
|
149
|
-
|
|
150
|
-
if (res.headersSent) {
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
149
|
+
if (!enforcePermission(req, res, "governance:resolve")) return;
|
|
153
150
|
const parsed = resolveSchema.safeParse(req.body);
|
|
154
151
|
if (!parsed.success) {
|
|
155
152
|
return sendError(res, parsed.error.message, 422);
|
package/src/routes/heartbeats.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { Router } from "express";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { and, eq } from "
|
|
4
|
-
import { agents, heartbeatRuns, listHeartbeatQueueJobs } from "bopodev-db";
|
|
3
|
+
import { agents, and, eq, heartbeatRuns, listHeartbeatQueueJobs } from "bopodev-db";
|
|
5
4
|
import type { AppContext } from "../context";
|
|
6
5
|
import { sendError, sendOk } from "../http";
|
|
7
6
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
8
|
-
import {
|
|
7
|
+
import { enforcePermission } from "../middleware/request-actor";
|
|
9
8
|
import {
|
|
10
9
|
findPendingProjectBudgetOverrideBlocksForAgent,
|
|
11
10
|
runHeartbeatSweep,
|
|
@@ -31,10 +30,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
|
|
|
31
30
|
router.use(requireCompanyScope);
|
|
32
31
|
|
|
33
32
|
router.post("/run-agent", async (req, res) => {
|
|
34
|
-
|
|
35
|
-
if (res.headersSent) {
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
33
|
+
if (!enforcePermission(req, res, "heartbeats:run")) return;
|
|
38
34
|
const parsed = runAgentSchema.safeParse(req.body);
|
|
39
35
|
if (!parsed.success) {
|
|
40
36
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -85,10 +81,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
|
|
|
85
81
|
});
|
|
86
82
|
|
|
87
83
|
router.post("/:runId/stop", async (req, res) => {
|
|
88
|
-
|
|
89
|
-
if (res.headersSent) {
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
84
|
+
if (!enforcePermission(req, res, "heartbeats:run")) return;
|
|
92
85
|
const parsed = runIdParamsSchema.safeParse(req.params);
|
|
93
86
|
if (!parsed.success) {
|
|
94
87
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -174,10 +167,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
|
|
|
174
167
|
}
|
|
175
168
|
|
|
176
169
|
router.post("/:runId/resume", async (req, res) => {
|
|
177
|
-
|
|
178
|
-
if (res.headersSent) {
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
170
|
+
if (!enforcePermission(req, res, "heartbeats:run")) return;
|
|
181
171
|
const parsed = runIdParamsSchema.safeParse(req.params);
|
|
182
172
|
if (!parsed.success) {
|
|
183
173
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -201,10 +191,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
|
|
|
201
191
|
});
|
|
202
192
|
|
|
203
193
|
router.post("/:runId/redo", async (req, res) => {
|
|
204
|
-
|
|
205
|
-
if (res.headersSent) {
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
194
|
+
if (!enforcePermission(req, res, "heartbeats:run")) return;
|
|
208
195
|
const parsed = runIdParamsSchema.safeParse(req.params);
|
|
209
196
|
if (!parsed.success) {
|
|
210
197
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -228,10 +215,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
|
|
|
228
215
|
});
|
|
229
216
|
|
|
230
217
|
router.post("/sweep", async (req, res) => {
|
|
231
|
-
|
|
232
|
-
if (res.headersSent) {
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
218
|
+
if (!enforcePermission(req, res, "heartbeats:sweep")) return;
|
|
235
219
|
const runIds = await runHeartbeatSweep(ctx.db, req.companyId!, {
|
|
236
220
|
requestId: req.requestId,
|
|
237
221
|
realtimeHub: ctx.realtimeHub
|
|
@@ -240,10 +224,7 @@ export function createHeartbeatRouter(ctx: AppContext) {
|
|
|
240
224
|
});
|
|
241
225
|
|
|
242
226
|
router.get("/queue", async (req, res) => {
|
|
243
|
-
|
|
244
|
-
if (res.headersSent) {
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
227
|
+
if (!enforcePermission(req, res, "heartbeats:run")) return;
|
|
247
228
|
const parsed = queueQuerySchema.safeParse(req.query);
|
|
248
229
|
if (!parsed.success) {
|
|
249
230
|
return sendError(res, parsed.error.message, 422);
|