bopodev-api 0.1.30 → 0.1.32
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 +8 -4
- package/src/app.ts +4 -0
- package/src/lib/instance-paths.ts +5 -0
- package/src/middleware/cors-config.ts +1 -1
- package/src/routes/assistant.ts +109 -0
- package/src/routes/companies.ts +112 -1
- package/src/routes/loops.ts +360 -0
- package/src/routes/observability.ts +255 -2
- package/src/services/agent-operating-file-service.ts +116 -0
- package/src/services/company-assistant-brain.ts +50 -0
- package/src/services/company-assistant-cli.ts +388 -0
- package/src/services/company-assistant-context-snapshot.ts +287 -0
- package/src/services/company-assistant-llm.ts +375 -0
- package/src/services/company-assistant-service.ts +1012 -0
- package/src/services/company-file-archive-service.ts +444 -0
- package/src/services/company-file-import-service.ts +279 -0
- package/src/services/heartbeat-service/heartbeat-run.ts +7 -2
- package/src/services/memory-file-service.ts +105 -1
- package/src/services/template-apply-service.ts +33 -0
- package/src/services/template-catalog.ts +19 -6
- package/src/services/work-loop-service/index.ts +2 -0
- package/src/services/work-loop-service/loop-cron.ts +197 -0
- package/src/services/work-loop-service/work-loop-service.ts +665 -0
- package/src/worker/scheduler.ts +26 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bopodev-api",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -8,20 +8,24 @@
|
|
|
8
8
|
"tsconfig.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": {
|
|
11
|
+
"archiver": "^7.0.1",
|
|
11
12
|
"cors": "^2.8.5",
|
|
12
13
|
"cron-parser": "^5.3.1",
|
|
13
14
|
"dotenv": "^17.0.1",
|
|
14
15
|
"drizzle-orm": "^0.44.5",
|
|
15
16
|
"express": "^5.1.0",
|
|
17
|
+
"fflate": "^0.8.2",
|
|
16
18
|
"multer": "^2.1.1",
|
|
17
19
|
"nanoid": "^5.1.5",
|
|
18
20
|
"ws": "^8.19.0",
|
|
21
|
+
"yaml": "^2.8.3",
|
|
19
22
|
"zod": "^4.1.5",
|
|
20
|
-
"bopodev-
|
|
21
|
-
"bopodev-
|
|
22
|
-
"bopodev-
|
|
23
|
+
"bopodev-agent-sdk": "0.1.32",
|
|
24
|
+
"bopodev-contracts": "0.1.32",
|
|
25
|
+
"bopodev-db": "0.1.32"
|
|
23
26
|
},
|
|
24
27
|
"devDependencies": {
|
|
28
|
+
"@types/archiver": "^7.0.0",
|
|
25
29
|
"@types/cors": "^2.8.19",
|
|
26
30
|
"@types/express": "^5.0.3",
|
|
27
31
|
"@types/multer": "^2.1.0",
|
package/src/app.ts
CHANGED
|
@@ -2,6 +2,7 @@ import express from "express";
|
|
|
2
2
|
import type { NextFunction, Request, Response } from "express";
|
|
3
3
|
import { pingDatabase, RepositoryValidationError } from "bopodev-db";
|
|
4
4
|
import type { AppContext } from "./context";
|
|
5
|
+
import { createAssistantRouter } from "./routes/assistant";
|
|
5
6
|
import { createAgentsRouter } from "./routes/agents";
|
|
6
7
|
import { createAuthRouter } from "./routes/auth";
|
|
7
8
|
import { createAttentionRouter } from "./routes/attention";
|
|
@@ -10,6 +11,7 @@ import { createGoalsRouter } from "./routes/goals";
|
|
|
10
11
|
import { createGovernanceRouter } from "./routes/governance";
|
|
11
12
|
import { createHeartbeatRouter } from "./routes/heartbeats";
|
|
12
13
|
import { createIssuesRouter } from "./routes/issues";
|
|
14
|
+
import { createLoopsRouter } from "./routes/loops";
|
|
13
15
|
import { createObservabilityRouter } from "./routes/observability";
|
|
14
16
|
import { createProjectsRouter } from "./routes/projects";
|
|
15
17
|
import { createPluginsRouter } from "./routes/plugins";
|
|
@@ -61,9 +63,11 @@ export function createApp(ctx: AppContext) {
|
|
|
61
63
|
|
|
62
64
|
app.use("/auth", createAuthRouter(ctx));
|
|
63
65
|
app.use("/attention", createAttentionRouter(ctx));
|
|
66
|
+
app.use("/assistant", createAssistantRouter(ctx));
|
|
64
67
|
app.use("/companies", createCompaniesRouter(ctx));
|
|
65
68
|
app.use("/projects", createProjectsRouter(ctx));
|
|
66
69
|
app.use("/issues", createIssuesRouter(ctx));
|
|
70
|
+
app.use("/loops", createLoopsRouter(ctx));
|
|
67
71
|
app.use("/goals", createGoalsRouter(ctx));
|
|
68
72
|
app.use("/agents", createAgentsRouter(ctx));
|
|
69
73
|
app.use("/governance", createGovernanceRouter(ctx));
|
|
@@ -78,6 +78,11 @@ export function resolveAgentMemoryRootPath(companyId: string, agentId: string) {
|
|
|
78
78
|
return join(resolveAgentFallbackWorkspacePath(companyId, agentId), "memory");
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** Agent operating docs (AGENTS.md, HEARTBEAT.md, etc.) — matches `BOPODEV_AGENT_OPERATING_DIR` at runtime. */
|
|
82
|
+
export function resolveAgentOperatingPath(companyId: string, agentId: string) {
|
|
83
|
+
return join(resolveAgentFallbackWorkspacePath(companyId, agentId), "operating");
|
|
84
|
+
}
|
|
85
|
+
|
|
81
86
|
export function resolveCompanyMemoryRootPath(companyId: string) {
|
|
82
87
|
const safeCompanyId = assertPathSegment(companyId, "companyId");
|
|
83
88
|
return join(resolveBopoInstanceRoot(), "workspaces", safeCompanyId, "memory");
|
|
@@ -22,7 +22,7 @@ export function createCorsMiddleware(deploymentMode: DeploymentMode, allowedOrig
|
|
|
22
22
|
callback(new Error(`CORS origin denied: ${origin}`));
|
|
23
23
|
},
|
|
24
24
|
credentials: true,
|
|
25
|
-
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
25
|
+
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
26
26
|
allowedHeaders: [
|
|
27
27
|
"content-type",
|
|
28
28
|
"x-company-id",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
createAssistantThread,
|
|
5
|
+
getAssistantThreadById,
|
|
6
|
+
getOrCreateAssistantThread,
|
|
7
|
+
listAssistantMessages
|
|
8
|
+
} from "bopodev-db";
|
|
9
|
+
import type { AppContext } from "../context";
|
|
10
|
+
import { sendError, sendOk } from "../http";
|
|
11
|
+
import { requireCompanyScope } from "../middleware/company-scope";
|
|
12
|
+
import { ASK_ASSISTANT_BRAIN_IDS, listAskAssistantBrains } from "../services/company-assistant-brain";
|
|
13
|
+
import { getCompanyCeoPersona, runCompanyAssistantTurn } from "../services/company-assistant-service";
|
|
14
|
+
|
|
15
|
+
const brainEnum = z.enum(ASK_ASSISTANT_BRAIN_IDS);
|
|
16
|
+
|
|
17
|
+
const postMessageSchema = z.object({
|
|
18
|
+
message: z.string().trim().min(1).max(16_000),
|
|
19
|
+
/** Adapter / runtime used to answer (same catalog as hiring an agent). */
|
|
20
|
+
brain: brainEnum.optional(),
|
|
21
|
+
/** Active chat thread; omit to use latest-or-create for the company. */
|
|
22
|
+
threadId: z.string().trim().min(1).optional()
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export function createAssistantRouter(ctx: AppContext) {
|
|
26
|
+
const router = Router();
|
|
27
|
+
router.use(requireCompanyScope);
|
|
28
|
+
|
|
29
|
+
router.get("/brains", (_req, res) => {
|
|
30
|
+
return sendOk(res, { brains: listAskAssistantBrains() });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
router.get("/messages", async (req, res) => {
|
|
34
|
+
const companyId = req.companyId!;
|
|
35
|
+
const qThread =
|
|
36
|
+
typeof req.query.threadId === "string" && req.query.threadId.trim() ? req.query.threadId.trim() : "";
|
|
37
|
+
let thread;
|
|
38
|
+
if (qThread) {
|
|
39
|
+
const found = await getAssistantThreadById(ctx.db, companyId, qThread);
|
|
40
|
+
if (!found) {
|
|
41
|
+
return sendError(res, "Chat thread not found.", 404);
|
|
42
|
+
}
|
|
43
|
+
thread = found;
|
|
44
|
+
} else {
|
|
45
|
+
thread = await getOrCreateAssistantThread(ctx.db, companyId);
|
|
46
|
+
}
|
|
47
|
+
const rawLimit = Number(req.query.limit ?? 100);
|
|
48
|
+
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 200) : 100;
|
|
49
|
+
const rows = await listAssistantMessages(ctx.db, thread.id, limit);
|
|
50
|
+
const ceoPersona = await getCompanyCeoPersona(ctx.db, companyId);
|
|
51
|
+
return sendOk(res, {
|
|
52
|
+
threadId: thread.id,
|
|
53
|
+
ceoPersona,
|
|
54
|
+
messages: rows.map((m) => ({
|
|
55
|
+
id: m.id,
|
|
56
|
+
role: m.role,
|
|
57
|
+
body: m.body,
|
|
58
|
+
createdAt: m.createdAt instanceof Date ? m.createdAt.toISOString() : String(m.createdAt),
|
|
59
|
+
metadata: m.metadataJson ? safeJsonParse(m.metadataJson) : null
|
|
60
|
+
}))
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
router.post("/messages", async (req, res) => {
|
|
65
|
+
const parsed = postMessageSchema.safeParse(req.body);
|
|
66
|
+
if (!parsed.success) {
|
|
67
|
+
return sendError(res, parsed.error.message, 422);
|
|
68
|
+
}
|
|
69
|
+
const companyId = req.companyId!;
|
|
70
|
+
const actor = req.actor;
|
|
71
|
+
const auditActorType =
|
|
72
|
+
actor?.type === "agent" ? "agent" : actor?.type === "board" || actor?.type === "member" ? "human" : "human";
|
|
73
|
+
const actorId = actor?.id?.trim() || "unknown";
|
|
74
|
+
try {
|
|
75
|
+
const result = await runCompanyAssistantTurn({
|
|
76
|
+
db: ctx.db,
|
|
77
|
+
companyId,
|
|
78
|
+
userMessage: parsed.data.message,
|
|
79
|
+
actorType: auditActorType,
|
|
80
|
+
actorId,
|
|
81
|
+
brain: parsed.data.brain,
|
|
82
|
+
threadId: parsed.data.threadId
|
|
83
|
+
});
|
|
84
|
+
return sendOk(res, result);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
87
|
+
if (message.includes("Missing API key")) {
|
|
88
|
+
return sendError(res, message, 503);
|
|
89
|
+
}
|
|
90
|
+
return sendError(res, message, 422);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
router.post("/threads", async (req, res) => {
|
|
95
|
+
const companyId = req.companyId!;
|
|
96
|
+
const thread = await createAssistantThread(ctx.db, companyId);
|
|
97
|
+
return sendOk(res, { threadId: thread.id });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return router;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function safeJsonParse(raw: string): unknown {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(raw) as unknown;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/routes/companies.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import type { NextFunction, Request, Response } from "express";
|
|
3
3
|
import { Router } from "express";
|
|
4
|
+
import multer from "multer";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { CompanySchema } from "bopodev-contracts";
|
|
6
7
|
import { createAgent, createCompany, deleteCompany, listCompanies, updateCompany } from "bopodev-db";
|
|
@@ -10,11 +11,29 @@ import { normalizeRuntimeConfig, resolveRuntimeModelForProvider, runtimeConfigTo
|
|
|
10
11
|
import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
|
|
11
12
|
import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
|
|
12
13
|
import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
|
|
13
|
-
import { buildCompanyPortabilityExport } from "../services/company-export-service";
|
|
14
14
|
import { canAccessCompany, requireBoardRole, requirePermission } from "../middleware/request-actor";
|
|
15
|
+
import {
|
|
16
|
+
CompanyFileArchiveError,
|
|
17
|
+
listCompanyExportManifest,
|
|
18
|
+
normalizeExportPath,
|
|
19
|
+
pipeCompanyExportZip,
|
|
20
|
+
readCompanyExportFileText
|
|
21
|
+
} from "../services/company-file-archive-service";
|
|
22
|
+
import { buildCompanyPortabilityExport } from "../services/company-export-service";
|
|
23
|
+
import { CompanyFileImportError, importCompanyFromZipBuffer } from "../services/company-file-import-service";
|
|
15
24
|
import { ensureCompanyBuiltinPluginDefaults } from "../services/plugin-runtime";
|
|
16
25
|
import { ensureCompanyBuiltinTemplateDefaults } from "../services/template-catalog";
|
|
17
26
|
|
|
27
|
+
const zipUpload = multer({
|
|
28
|
+
storage: multer.memoryStorage(),
|
|
29
|
+
limits: { fileSize: 80 * 1024 * 1024 }
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const exportZipBodySchema = z.object({
|
|
33
|
+
paths: z.array(z.string()).nullable().optional(),
|
|
34
|
+
includeAgentMemory: z.boolean().optional().default(false)
|
|
35
|
+
});
|
|
36
|
+
|
|
18
37
|
const DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
|
|
19
38
|
const DEFAULT_AGENT_MODEL_ENV = "BOPO_DEFAULT_AGENT_MODEL";
|
|
20
39
|
|
|
@@ -54,6 +73,98 @@ export function createCompaniesRouter(ctx: AppContext) {
|
|
|
54
73
|
);
|
|
55
74
|
});
|
|
56
75
|
|
|
76
|
+
router.post("/import/files", requireBoardRole, zipUpload.single("archive"), async (req, res) => {
|
|
77
|
+
const file = req.file;
|
|
78
|
+
if (!file?.buffer) {
|
|
79
|
+
return sendError(res, 'Upload a .zip file in field "archive".', 422);
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const result = await importCompanyFromZipBuffer(ctx.db, file.buffer);
|
|
83
|
+
return sendOk(res, result);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const message = err instanceof CompanyFileImportError ? err.message : String(err);
|
|
86
|
+
return sendError(res, message, 422);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
router.get("/:companyId/export/files/manifest", async (req, res) => {
|
|
91
|
+
const companyId = readCompanyIdParam(req);
|
|
92
|
+
if (!companyId) {
|
|
93
|
+
return sendError(res, "Missing company id.", 422);
|
|
94
|
+
}
|
|
95
|
+
if (!canAccessCompany(req, companyId)) {
|
|
96
|
+
return sendError(res, "Actor does not have access to this company.", 403);
|
|
97
|
+
}
|
|
98
|
+
const includeAgentMemory = req.query.includeAgentMemory === "1" || req.query.includeAgentMemory === "true";
|
|
99
|
+
try {
|
|
100
|
+
const files = await listCompanyExportManifest(ctx.db, companyId, { includeAgentMemory });
|
|
101
|
+
return sendOk(res, { files, includeAgentMemory });
|
|
102
|
+
} catch (err) {
|
|
103
|
+
const message = err instanceof CompanyFileArchiveError ? err.message : String(err);
|
|
104
|
+
return sendError(res, message, 422);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
router.get("/:companyId/export/files/preview", async (req, res) => {
|
|
109
|
+
const companyId = readCompanyIdParam(req);
|
|
110
|
+
if (!companyId) {
|
|
111
|
+
return sendError(res, "Missing company id.", 422);
|
|
112
|
+
}
|
|
113
|
+
if (!canAccessCompany(req, companyId)) {
|
|
114
|
+
return sendError(res, "Actor does not have access to this company.", 403);
|
|
115
|
+
}
|
|
116
|
+
const pathRaw = typeof req.query.path === "string" ? req.query.path : "";
|
|
117
|
+
const includeAgentMemory = req.query.includeAgentMemory === "1" || req.query.includeAgentMemory === "true";
|
|
118
|
+
const normalizedPath = normalizeExportPath(pathRaw);
|
|
119
|
+
if (!normalizedPath) {
|
|
120
|
+
return sendError(res, "Invalid or missing path query parameter.", 422);
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const preview = await readCompanyExportFileText(ctx.db, companyId, normalizedPath, { includeAgentMemory });
|
|
124
|
+
if (!preview) {
|
|
125
|
+
return sendError(res, "File not found in export manifest.", 404);
|
|
126
|
+
}
|
|
127
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
128
|
+
return res.status(200).send(preview.content);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
const message = err instanceof CompanyFileArchiveError ? err.message : String(err);
|
|
131
|
+
return sendError(res, message, 422);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
router.post("/:companyId/export/files/zip", async (req, res) => {
|
|
136
|
+
const companyId = readCompanyIdParam(req);
|
|
137
|
+
if (!companyId) {
|
|
138
|
+
return sendError(res, "Missing company id.", 422);
|
|
139
|
+
}
|
|
140
|
+
if (!canAccessCompany(req, companyId)) {
|
|
141
|
+
return sendError(res, "Actor does not have access to this company.", 403);
|
|
142
|
+
}
|
|
143
|
+
const parsed = exportZipBodySchema.safeParse(req.body ?? {});
|
|
144
|
+
if (!parsed.success) {
|
|
145
|
+
return sendError(res, parsed.error.message, 422);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const stream = await pipeCompanyExportZip(ctx.db, companyId, {
|
|
149
|
+
paths: parsed.data.paths ?? null,
|
|
150
|
+
includeAgentMemory: parsed.data.includeAgentMemory
|
|
151
|
+
});
|
|
152
|
+
res.setHeader("Content-Type", "application/zip");
|
|
153
|
+
res.setHeader("Content-Disposition", `attachment; filename="company-${companyId}-export.zip"`);
|
|
154
|
+
stream.on("error", () => {
|
|
155
|
+
if (!res.headersSent) {
|
|
156
|
+
sendError(res, "Zip stream failed.", 500);
|
|
157
|
+
} else {
|
|
158
|
+
res.end();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
stream.pipe(res);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
const message = err instanceof CompanyFileArchiveError ? err.message : String(err);
|
|
164
|
+
return sendError(res, message, 422);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
57
168
|
router.get("/:companyId/export", async (req, res) => {
|
|
58
169
|
const companyId = readCompanyIdParam(req);
|
|
59
170
|
if (!companyId) {
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { appendAuditEvent, listAuditEvents } from "bopodev-db";
|
|
3
|
+
import {
|
|
4
|
+
WorkLoopCreateRequestSchema,
|
|
5
|
+
WorkLoopTriggerCreateRequestSchema,
|
|
6
|
+
WorkLoopUpdateRequestSchema,
|
|
7
|
+
WorkLoopTriggerUpdateRequestSchema
|
|
8
|
+
} from "bopodev-contracts";
|
|
9
|
+
import type { AppContext } from "../context";
|
|
10
|
+
import { sendError, sendOk } from "../http";
|
|
11
|
+
import { requireCompanyScope } from "../middleware/company-scope";
|
|
12
|
+
import { enforcePermission } from "../middleware/request-actor";
|
|
13
|
+
import {
|
|
14
|
+
workLoopRuns,
|
|
15
|
+
workLoops,
|
|
16
|
+
workLoopTriggers
|
|
17
|
+
} from "bopodev-db";
|
|
18
|
+
import {
|
|
19
|
+
addWorkLoopTrigger,
|
|
20
|
+
addWorkLoopTriggerFromPreset,
|
|
21
|
+
dispatchLoopRun,
|
|
22
|
+
getWorkLoop,
|
|
23
|
+
listWorkLoopRuns,
|
|
24
|
+
listWorkLoops,
|
|
25
|
+
listWorkLoopTriggers,
|
|
26
|
+
createWorkLoop,
|
|
27
|
+
updateWorkLoop,
|
|
28
|
+
updateWorkLoopTrigger,
|
|
29
|
+
deleteWorkLoopTrigger
|
|
30
|
+
} from "../services/work-loop-service";
|
|
31
|
+
|
|
32
|
+
function serializeLoop(row: typeof workLoops.$inferSelect) {
|
|
33
|
+
let goalIds: string[] = [];
|
|
34
|
+
try {
|
|
35
|
+
goalIds = JSON.parse(row.goalIdsJson || "[]") as string[];
|
|
36
|
+
} catch {
|
|
37
|
+
goalIds = [];
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
id: row.id,
|
|
41
|
+
companyId: row.companyId,
|
|
42
|
+
projectId: row.projectId,
|
|
43
|
+
parentIssueId: row.parentIssueId,
|
|
44
|
+
goalIds,
|
|
45
|
+
title: row.title,
|
|
46
|
+
description: row.description,
|
|
47
|
+
assigneeAgentId: row.assigneeAgentId,
|
|
48
|
+
priority: row.priority,
|
|
49
|
+
status: row.status,
|
|
50
|
+
concurrencyPolicy: row.concurrencyPolicy,
|
|
51
|
+
catchUpPolicy: row.catchUpPolicy,
|
|
52
|
+
lastTriggeredAt: row.lastTriggeredAt ? row.lastTriggeredAt.toISOString() : null,
|
|
53
|
+
createdAt: row.createdAt.toISOString(),
|
|
54
|
+
updatedAt: row.updatedAt.toISOString()
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function serializeTrigger(row: typeof workLoopTriggers.$inferSelect) {
|
|
59
|
+
return {
|
|
60
|
+
id: row.id,
|
|
61
|
+
companyId: row.companyId,
|
|
62
|
+
workLoopId: row.workLoopId,
|
|
63
|
+
kind: row.kind,
|
|
64
|
+
label: row.label,
|
|
65
|
+
enabled: row.enabled,
|
|
66
|
+
cronExpression: row.cronExpression,
|
|
67
|
+
timezone: row.timezone,
|
|
68
|
+
nextRunAt: row.nextRunAt ? row.nextRunAt.toISOString() : null,
|
|
69
|
+
lastFiredAt: row.lastFiredAt ? row.lastFiredAt.toISOString() : null,
|
|
70
|
+
lastResult: row.lastResult,
|
|
71
|
+
createdAt: row.createdAt.toISOString(),
|
|
72
|
+
updatedAt: row.updatedAt.toISOString()
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function serializeRun(row: typeof workLoopRuns.$inferSelect) {
|
|
77
|
+
return {
|
|
78
|
+
id: row.id,
|
|
79
|
+
companyId: row.companyId,
|
|
80
|
+
workLoopId: row.workLoopId,
|
|
81
|
+
triggerId: row.triggerId,
|
|
82
|
+
source: row.source,
|
|
83
|
+
status: row.status,
|
|
84
|
+
triggeredAt: row.triggeredAt.toISOString(),
|
|
85
|
+
idempotencyKey: row.idempotencyKey,
|
|
86
|
+
linkedIssueId: row.linkedIssueId,
|
|
87
|
+
coalescedIntoRunId: row.coalescedIntoRunId,
|
|
88
|
+
failureReason: row.failureReason,
|
|
89
|
+
completedAt: row.completedAt ? row.completedAt.toISOString() : null,
|
|
90
|
+
createdAt: row.createdAt.toISOString(),
|
|
91
|
+
updatedAt: row.updatedAt.toISOString()
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createLoopsRouter(ctx: AppContext) {
|
|
96
|
+
const router = Router();
|
|
97
|
+
router.use(requireCompanyScope);
|
|
98
|
+
|
|
99
|
+
router.get("/", async (req, res) => {
|
|
100
|
+
if (!enforcePermission(req, res, "loops:read")) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const rows = await listWorkLoops(ctx.db, req.companyId!);
|
|
104
|
+
return sendOk(res, { data: rows.map(serializeLoop) });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
router.post("/", async (req, res) => {
|
|
108
|
+
if (!enforcePermission(req, res, "loops:write")) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const parsed = WorkLoopCreateRequestSchema.safeParse(req.body);
|
|
112
|
+
if (!parsed.success) {
|
|
113
|
+
return sendError(res, parsed.error.message, 422);
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const row = await createWorkLoop(ctx.db, {
|
|
117
|
+
companyId: req.companyId!,
|
|
118
|
+
projectId: parsed.data.projectId,
|
|
119
|
+
parentIssueId: parsed.data.parentIssueId,
|
|
120
|
+
goalIds: parsed.data.goalIds,
|
|
121
|
+
title: parsed.data.title,
|
|
122
|
+
description: parsed.data.description,
|
|
123
|
+
assigneeAgentId: parsed.data.assigneeAgentId,
|
|
124
|
+
priority: parsed.data.priority,
|
|
125
|
+
status: parsed.data.status,
|
|
126
|
+
concurrencyPolicy: parsed.data.concurrencyPolicy,
|
|
127
|
+
catchUpPolicy: parsed.data.catchUpPolicy
|
|
128
|
+
});
|
|
129
|
+
if (!row) {
|
|
130
|
+
return sendError(res, "Failed to create work loop.", 500);
|
|
131
|
+
}
|
|
132
|
+
await appendAuditEvent(ctx.db, {
|
|
133
|
+
companyId: req.companyId!,
|
|
134
|
+
actorType: "human",
|
|
135
|
+
actorId: req.actor?.id ?? null,
|
|
136
|
+
eventType: "work_loop.created",
|
|
137
|
+
entityType: "work_loop",
|
|
138
|
+
entityId: row.id,
|
|
139
|
+
correlationId: req.requestId ?? null,
|
|
140
|
+
payload: { loopId: row.id, title: row.title }
|
|
141
|
+
});
|
|
142
|
+
return sendOk(res, { data: serializeLoop(row) });
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return sendError(res, e instanceof Error ? e.message : "Failed to create work loop.", 422);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
router.get("/:loopId", async (req, res) => {
|
|
149
|
+
if (!enforcePermission(req, res, "loops:read")) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const loopId = req.params.loopId;
|
|
153
|
+
const row = await getWorkLoop(ctx.db, req.companyId!, loopId);
|
|
154
|
+
if (!row) {
|
|
155
|
+
return sendError(res, "Work loop not found.", 404);
|
|
156
|
+
}
|
|
157
|
+
const [triggers, recentRuns] = await Promise.all([
|
|
158
|
+
listWorkLoopTriggers(ctx.db, req.companyId!, loopId),
|
|
159
|
+
listWorkLoopRuns(ctx.db, req.companyId!, loopId, 30)
|
|
160
|
+
]);
|
|
161
|
+
return sendOk(res, {
|
|
162
|
+
data: {
|
|
163
|
+
...serializeLoop(row),
|
|
164
|
+
triggers: triggers.map(serializeTrigger),
|
|
165
|
+
recentRuns: recentRuns.map(serializeRun)
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
router.patch("/:loopId", async (req, res) => {
|
|
171
|
+
if (!enforcePermission(req, res, "loops:write")) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const parsed = WorkLoopUpdateRequestSchema.safeParse(req.body);
|
|
175
|
+
if (!parsed.success) {
|
|
176
|
+
return sendError(res, parsed.error.message, 422);
|
|
177
|
+
}
|
|
178
|
+
const row = await updateWorkLoop(ctx.db, req.companyId!, req.params.loopId, parsed.data);
|
|
179
|
+
if (!row) {
|
|
180
|
+
return sendError(res, "Work loop not found.", 404);
|
|
181
|
+
}
|
|
182
|
+
await appendAuditEvent(ctx.db, {
|
|
183
|
+
companyId: req.companyId!,
|
|
184
|
+
actorType: "human",
|
|
185
|
+
actorId: req.actor?.id ?? null,
|
|
186
|
+
eventType: "work_loop.updated",
|
|
187
|
+
entityType: "work_loop",
|
|
188
|
+
entityId: row.id,
|
|
189
|
+
correlationId: req.requestId ?? null,
|
|
190
|
+
payload: { patch: parsed.data }
|
|
191
|
+
});
|
|
192
|
+
return sendOk(res, { data: serializeLoop(row) });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
router.post("/:loopId/run", async (req, res) => {
|
|
196
|
+
if (!enforcePermission(req, res, "loops:run")) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const loopId = req.params.loopId;
|
|
200
|
+
const loop = await getWorkLoop(ctx.db, req.companyId!, loopId);
|
|
201
|
+
if (!loop) {
|
|
202
|
+
return sendError(res, "Work loop not found.", 404);
|
|
203
|
+
}
|
|
204
|
+
const run = await dispatchLoopRun(ctx.db, {
|
|
205
|
+
companyId: req.companyId!,
|
|
206
|
+
loopId,
|
|
207
|
+
triggerId: null,
|
|
208
|
+
source: "manual",
|
|
209
|
+
idempotencyKey: req.requestId ? `manual:${loopId}:${req.requestId}` : `manual:${loopId}:${Date.now()}`,
|
|
210
|
+
realtimeHub: ctx.realtimeHub,
|
|
211
|
+
requestId: req.requestId
|
|
212
|
+
});
|
|
213
|
+
if (!run) {
|
|
214
|
+
return sendError(res, "Work loop is not active or could not be dispatched.", 409);
|
|
215
|
+
}
|
|
216
|
+
await appendAuditEvent(ctx.db, {
|
|
217
|
+
companyId: req.companyId!,
|
|
218
|
+
actorType: "human",
|
|
219
|
+
actorId: req.actor?.id ?? null,
|
|
220
|
+
eventType: "work_loop.manual_run",
|
|
221
|
+
entityType: "work_loop",
|
|
222
|
+
entityId: loopId,
|
|
223
|
+
correlationId: req.requestId ?? null,
|
|
224
|
+
payload: { runId: run.id, status: run.status }
|
|
225
|
+
});
|
|
226
|
+
return sendOk(res, { data: serializeRun(run) });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
router.get("/:loopId/runs", async (req, res) => {
|
|
230
|
+
if (!enforcePermission(req, res, "loops:read")) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const loop = await getWorkLoop(ctx.db, req.companyId!, req.params.loopId);
|
|
234
|
+
if (!loop) {
|
|
235
|
+
return sendError(res, "Work loop not found.", 404);
|
|
236
|
+
}
|
|
237
|
+
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
|
|
238
|
+
const runs = await listWorkLoopRuns(ctx.db, req.companyId!, req.params.loopId, limit);
|
|
239
|
+
return sendOk(res, { data: runs.map(serializeRun) });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
router.get("/:loopId/activity", async (req, res) => {
|
|
243
|
+
if (!enforcePermission(req, res, "loops:read")) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const loopId = req.params.loopId;
|
|
247
|
+
const loop = await getWorkLoop(ctx.db, req.companyId!, loopId);
|
|
248
|
+
if (!loop) {
|
|
249
|
+
return sendError(res, "Work loop not found.", 404);
|
|
250
|
+
}
|
|
251
|
+
const events = await listAuditEvents(ctx.db, req.companyId!, 200);
|
|
252
|
+
const filtered = events.filter((e) => e.entityType === "work_loop" && e.entityId === loopId);
|
|
253
|
+
return sendOk(res, {
|
|
254
|
+
data: filtered.map((e) => ({
|
|
255
|
+
id: e.id,
|
|
256
|
+
eventType: e.eventType,
|
|
257
|
+
actorType: e.actorType,
|
|
258
|
+
actorId: e.actorId,
|
|
259
|
+
payload: JSON.parse(e.payloadJson || "{}") as Record<string, unknown>,
|
|
260
|
+
createdAt: e.createdAt.toISOString()
|
|
261
|
+
}))
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
router.post("/:loopId/triggers", async (req, res) => {
|
|
266
|
+
if (!enforcePermission(req, res, "loops:write")) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const loopId = req.params.loopId;
|
|
270
|
+
const loop = await getWorkLoop(ctx.db, req.companyId!, loopId);
|
|
271
|
+
if (!loop) {
|
|
272
|
+
return sendError(res, "Work loop not found.", 404);
|
|
273
|
+
}
|
|
274
|
+
const parsed = WorkLoopTriggerCreateRequestSchema.safeParse(req.body);
|
|
275
|
+
if (!parsed.success) {
|
|
276
|
+
return sendError(res, parsed.error.message, 422);
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const body = parsed.data;
|
|
280
|
+
const trigger =
|
|
281
|
+
body.mode === "cron"
|
|
282
|
+
? await addWorkLoopTrigger(ctx.db, {
|
|
283
|
+
companyId: req.companyId!,
|
|
284
|
+
workLoopId: loopId,
|
|
285
|
+
cronExpression: body.cronExpression,
|
|
286
|
+
timezone: body.timezone,
|
|
287
|
+
label: body.label ?? null,
|
|
288
|
+
enabled: body.enabled
|
|
289
|
+
})
|
|
290
|
+
: await addWorkLoopTriggerFromPreset(ctx.db, {
|
|
291
|
+
companyId: req.companyId!,
|
|
292
|
+
workLoopId: loopId,
|
|
293
|
+
preset: body.preset,
|
|
294
|
+
hour24: body.hour24,
|
|
295
|
+
minute: body.minute,
|
|
296
|
+
dayOfWeek: body.preset === "weekly" ? (body.dayOfWeek ?? 1) : undefined,
|
|
297
|
+
timezone: body.timezone,
|
|
298
|
+
label: body.label ?? null,
|
|
299
|
+
enabled: body.enabled
|
|
300
|
+
});
|
|
301
|
+
if (!trigger) {
|
|
302
|
+
return sendError(res, "Failed to create trigger.", 500);
|
|
303
|
+
}
|
|
304
|
+
return sendOk(res, { data: serializeTrigger(trigger) });
|
|
305
|
+
} catch (e) {
|
|
306
|
+
return sendError(res, e instanceof Error ? e.message : "Failed to create trigger.", 422);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
router.patch("/:loopId/triggers/:triggerId", async (req, res) => {
|
|
311
|
+
if (!enforcePermission(req, res, "loops:write")) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const parsed = WorkLoopTriggerUpdateRequestSchema.safeParse(req.body);
|
|
315
|
+
if (!parsed.success) {
|
|
316
|
+
return sendError(res, parsed.error.message, 422);
|
|
317
|
+
}
|
|
318
|
+
const loop = await getWorkLoop(ctx.db, req.companyId!, req.params.loopId);
|
|
319
|
+
if (!loop) {
|
|
320
|
+
return sendError(res, "Work loop not found.", 404);
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
const row = await updateWorkLoopTrigger(ctx.db, req.companyId!, req.params.triggerId, parsed.data);
|
|
324
|
+
if (!row || row.workLoopId !== req.params.loopId) {
|
|
325
|
+
return sendError(res, "Trigger not found.", 404);
|
|
326
|
+
}
|
|
327
|
+
return sendOk(res, { data: serializeTrigger(row) });
|
|
328
|
+
} catch (e) {
|
|
329
|
+
return sendError(res, e instanceof Error ? e.message : "Failed to update trigger.", 422);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
router.delete("/:loopId/triggers/:triggerId", async (req, res) => {
|
|
334
|
+
if (!enforcePermission(req, res, "loops:write")) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const { loopId, triggerId } = req.params;
|
|
338
|
+
const loop = await getWorkLoop(ctx.db, req.companyId!, loopId);
|
|
339
|
+
if (!loop) {
|
|
340
|
+
return sendError(res, "Work loop not found.", 404);
|
|
341
|
+
}
|
|
342
|
+
const deleted = await deleteWorkLoopTrigger(ctx.db, req.companyId!, loopId, triggerId);
|
|
343
|
+
if (!deleted) {
|
|
344
|
+
return sendError(res, "Trigger not found.", 404);
|
|
345
|
+
}
|
|
346
|
+
await appendAuditEvent(ctx.db, {
|
|
347
|
+
companyId: req.companyId!,
|
|
348
|
+
actorType: "human",
|
|
349
|
+
actorId: req.actor?.id ?? null,
|
|
350
|
+
eventType: "work_loop.trigger_deleted",
|
|
351
|
+
entityType: "work_loop",
|
|
352
|
+
entityId: loopId,
|
|
353
|
+
correlationId: req.requestId ?? null,
|
|
354
|
+
payload: { triggerId }
|
|
355
|
+
});
|
|
356
|
+
return sendOk(res, { deleted: true });
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
return router;
|
|
360
|
+
}
|