bopodev-api 0.1.29 → 0.1.31
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 +2 -0
- package/src/lib/ceo-bootstrap-prompt.ts +1 -0
- package/src/lib/instance-paths.ts +5 -0
- package/src/middleware/cors-config.ts +1 -1
- package/src/realtime/office-space.ts +1 -0
- package/src/routes/agents.ts +87 -37
- package/src/routes/companies.ts +2 -0
- package/src/routes/issues.ts +3 -0
- package/src/routes/loops.ts +360 -0
- package/src/routes/observability.ts +123 -1
- package/src/scripts/onboard-seed.ts +13 -1
- package/src/services/agent-operating-file-service.ts +116 -0
- package/src/services/governance-service.ts +6 -13
- package/src/services/heartbeat-service/heartbeat-run.ts +25 -3
- package/src/services/heartbeat-service/types.ts +1 -0
- package/src/services/memory-file-service.ts +35 -1
- package/src/services/template-apply-service.ts +39 -0
- package/src/services/template-catalog.ts +37 -3
- 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/validation/issue-routes.ts +2 -1
- 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.31",
|
|
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-
|
|
21
|
-
"bopodev-agent-sdk": "0.1.
|
|
22
|
-
"bopodev-
|
|
20
|
+
"bopodev-db": "0.1.31",
|
|
21
|
+
"bopodev-agent-sdk": "0.1.31",
|
|
22
|
+
"bopodev-contracts": "0.1.31"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/cors": "^2.8.19",
|
package/src/app.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { createGoalsRouter } from "./routes/goals";
|
|
|
10
10
|
import { createGovernanceRouter } from "./routes/governance";
|
|
11
11
|
import { createHeartbeatRouter } from "./routes/heartbeats";
|
|
12
12
|
import { createIssuesRouter } from "./routes/issues";
|
|
13
|
+
import { createLoopsRouter } from "./routes/loops";
|
|
13
14
|
import { createObservabilityRouter } from "./routes/observability";
|
|
14
15
|
import { createProjectsRouter } from "./routes/projects";
|
|
15
16
|
import { createPluginsRouter } from "./routes/plugins";
|
|
@@ -64,6 +65,7 @@ export function createApp(ctx: AppContext) {
|
|
|
64
65
|
app.use("/companies", createCompaniesRouter(ctx));
|
|
65
66
|
app.use("/projects", createProjectsRouter(ctx));
|
|
66
67
|
app.use("/issues", createIssuesRouter(ctx));
|
|
68
|
+
app.use("/loops", createLoopsRouter(ctx));
|
|
67
69
|
app.use("/goals", createGoalsRouter(ctx));
|
|
68
70
|
app.use("/agents", createAgentsRouter(ctx));
|
|
69
71
|
app.use("/governance", createGovernanceRouter(ctx));
|
|
@@ -5,6 +5,7 @@ export function buildDefaultCeoBootstrapPrompt() {
|
|
|
5
5
|
"- Clarify missing constraints before hiring when requirements are ambiguous.",
|
|
6
6
|
"- Choose reporting lines, provider, model, and permissions that fit company goals and budget.",
|
|
7
7
|
"- Use governance-safe hiring via `POST /agents` with `requestApproval: true` unless explicitly told otherwise.",
|
|
8
|
+
"- Always set `capabilities` on every new hire: one or two sentences describing what they do, for the org chart and team roster (delegation). If the issue metadata or body specifies requested capabilities, use or refine that text; if missing, write an appropriate line from the role and request details.",
|
|
8
9
|
"- Avoid duplicate hires by checking existing agents and pending approvals first.",
|
|
9
10
|
"- Use the control-plane coordination skill as the source of truth for endpoint paths, required headers, and approval workflow steps.",
|
|
10
11
|
"- Record hiring rationale and key decisions in issue comments for auditability."
|
|
@@ -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",
|
|
@@ -437,6 +437,7 @@ function normalizeProviderType(value: string): OfficeOccupant["providerType"] {
|
|
|
437
437
|
value === "gemini_cli" ||
|
|
438
438
|
value === "openai_api" ||
|
|
439
439
|
value === "anthropic_api" ||
|
|
440
|
+
value === "openclaw_gateway" ||
|
|
440
441
|
value === "http" ||
|
|
441
442
|
value === "shell"
|
|
442
443
|
? value
|
package/src/routes/agents.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Router } from "express";
|
|
1
|
+
import { Router, type Response } from "express";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import {
|
|
@@ -82,17 +82,25 @@ const runtimePreflightSchema = z.object({
|
|
|
82
82
|
"gemini_cli",
|
|
83
83
|
"openai_api",
|
|
84
84
|
"anthropic_api",
|
|
85
|
+
"openclaw_gateway",
|
|
85
86
|
"http",
|
|
86
87
|
"shell"
|
|
87
88
|
]),
|
|
88
89
|
runtimeConfig: z.record(z.string(), z.unknown()).optional(),
|
|
89
90
|
...legacyRuntimeConfigSchema.shape
|
|
90
91
|
});
|
|
92
|
+
|
|
93
|
+
/** Body for POST /agents/adapter-models/:providerType (runtime for CLI discovery). */
|
|
94
|
+
const adapterModelsBodySchema = z.object({
|
|
95
|
+
runtimeConfig: z.record(z.string(), z.unknown()).optional(),
|
|
96
|
+
...legacyRuntimeConfigSchema.shape
|
|
97
|
+
});
|
|
91
98
|
const UPDATE_AGENT_ALLOWED_KEYS = new Set([
|
|
92
99
|
"managerAgentId",
|
|
93
100
|
"role",
|
|
94
101
|
"roleKey",
|
|
95
102
|
"title",
|
|
103
|
+
"capabilities",
|
|
96
104
|
"name",
|
|
97
105
|
"providerType",
|
|
98
106
|
"status",
|
|
@@ -135,7 +143,7 @@ function toAgentResponse(agent: Record<string, unknown>) {
|
|
|
135
143
|
}
|
|
136
144
|
|
|
137
145
|
function providerRequiresNamedModel(providerType: string) {
|
|
138
|
-
return providerType !== "http" && providerType !== "shell";
|
|
146
|
+
return providerType !== "http" && providerType !== "shell" && providerType !== "openclaw_gateway";
|
|
139
147
|
}
|
|
140
148
|
|
|
141
149
|
const agentResponseSchema = AgentSchema.extend({
|
|
@@ -149,6 +157,66 @@ function ensureNamedRuntimeModel(providerType: string, runtimeModel: string | un
|
|
|
149
157
|
return hasText(runtimeModel);
|
|
150
158
|
}
|
|
151
159
|
|
|
160
|
+
type AdapterModelsProviderType = NonNullable<z.infer<typeof runtimePreflightSchema>["providerType"]>;
|
|
161
|
+
|
|
162
|
+
async function handleAdapterModelsRequest(
|
|
163
|
+
ctx: AppContext,
|
|
164
|
+
res: Response,
|
|
165
|
+
companyId: string,
|
|
166
|
+
providerType: string,
|
|
167
|
+
parsedBody: z.infer<typeof adapterModelsBodySchema> | null
|
|
168
|
+
) {
|
|
169
|
+
if (!runtimePreflightSchema.shape.providerType.safeParse(providerType).success) {
|
|
170
|
+
return sendError(res, `Unsupported provider type: ${providerType}`, 422);
|
|
171
|
+
}
|
|
172
|
+
const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, companyId);
|
|
173
|
+
let runtimeConfig: ReturnType<typeof normalizeRuntimeConfig>;
|
|
174
|
+
try {
|
|
175
|
+
if (parsedBody) {
|
|
176
|
+
runtimeConfig = normalizeRuntimeConfig({
|
|
177
|
+
runtimeConfig: parsedBody.runtimeConfig,
|
|
178
|
+
legacy: {
|
|
179
|
+
runtimeCommand: parsedBody.runtimeCommand,
|
|
180
|
+
runtimeArgs: parsedBody.runtimeArgs,
|
|
181
|
+
runtimeCwd: parsedBody.runtimeCwd,
|
|
182
|
+
runtimeTimeoutMs: parsedBody.runtimeTimeoutMs,
|
|
183
|
+
runtimeModel: parsedBody.runtimeModel,
|
|
184
|
+
runtimeThinkingEffort: parsedBody.runtimeThinkingEffort,
|
|
185
|
+
bootstrapPrompt: parsedBody.bootstrapPrompt,
|
|
186
|
+
runtimeTimeoutSec: parsedBody.runtimeTimeoutSec,
|
|
187
|
+
interruptGraceSec: parsedBody.interruptGraceSec,
|
|
188
|
+
runtimeEnv: parsedBody.runtimeEnv,
|
|
189
|
+
runPolicy: parsedBody.runPolicy
|
|
190
|
+
},
|
|
191
|
+
defaultRuntimeCwd
|
|
192
|
+
});
|
|
193
|
+
} else {
|
|
194
|
+
runtimeConfig = normalizeRuntimeConfig({ defaultRuntimeCwd });
|
|
195
|
+
}
|
|
196
|
+
runtimeConfig = enforceRuntimeCwdPolicy(companyId, runtimeConfig);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
return sendError(res, String(error), 422);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (parsedBody && runtimeConfig.runtimeCwd) {
|
|
202
|
+
await mkdir(runtimeConfig.runtimeCwd, { recursive: true });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const typedProviderType = providerType as AdapterModelsProviderType;
|
|
206
|
+
const models = await getAdapterModels(typedProviderType, {
|
|
207
|
+
command: runtimeConfig.runtimeCommand,
|
|
208
|
+
args: runtimeConfig.runtimeArgs,
|
|
209
|
+
cwd: runtimeConfig.runtimeCwd,
|
|
210
|
+
env: runtimeConfig.runtimeEnv,
|
|
211
|
+
model: runtimeConfig.runtimeModel,
|
|
212
|
+
thinkingEffort: runtimeConfig.runtimeThinkingEffort,
|
|
213
|
+
timeoutMs: runtimeConfig.runtimeTimeoutSec > 0 ? runtimeConfig.runtimeTimeoutSec * 1000 : undefined,
|
|
214
|
+
interruptGraceSec: runtimeConfig.interruptGraceSec,
|
|
215
|
+
runPolicy: runtimeConfig.runPolicy
|
|
216
|
+
});
|
|
217
|
+
return sendOk(res, { providerType: typedProviderType, models });
|
|
218
|
+
}
|
|
219
|
+
|
|
152
220
|
export function createAgentsRouter(ctx: AppContext) {
|
|
153
221
|
const router = Router();
|
|
154
222
|
router.use(requireCompanyScope);
|
|
@@ -229,42 +297,16 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
229
297
|
|
|
230
298
|
router.get("/adapter-models/:providerType", async (req, res) => {
|
|
231
299
|
const providerType = req.params.providerType;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
defaultRuntimeCwd
|
|
241
|
-
});
|
|
242
|
-
runtimeConfig = enforceRuntimeCwdPolicy(req.companyId!, runtimeConfig);
|
|
243
|
-
} catch (error) {
|
|
244
|
-
return sendError(res, String(error), 422);
|
|
300
|
+
return handleAdapterModelsRequest(ctx, res, req.companyId!, providerType, null);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
router.post("/adapter-models/:providerType", async (req, res) => {
|
|
304
|
+
const providerType = req.params.providerType;
|
|
305
|
+
const parsed = adapterModelsBodySchema.safeParse(req.body ?? {});
|
|
306
|
+
if (!parsed.success) {
|
|
307
|
+
return sendError(res, parsed.error.message, 422);
|
|
245
308
|
}
|
|
246
|
-
|
|
247
|
-
| "claude_code"
|
|
248
|
-
| "codex"
|
|
249
|
-
| "cursor"
|
|
250
|
-
| "opencode"
|
|
251
|
-
| "gemini_cli"
|
|
252
|
-
| "openai_api"
|
|
253
|
-
| "anthropic_api"
|
|
254
|
-
| "http"
|
|
255
|
-
| "shell";
|
|
256
|
-
const models = await getAdapterModels(typedProviderType, {
|
|
257
|
-
command: runtimeConfig.runtimeCommand,
|
|
258
|
-
args: runtimeConfig.runtimeArgs,
|
|
259
|
-
cwd: runtimeConfig.runtimeCwd,
|
|
260
|
-
env: runtimeConfig.runtimeEnv,
|
|
261
|
-
model: runtimeConfig.runtimeModel,
|
|
262
|
-
thinkingEffort: runtimeConfig.runtimeThinkingEffort,
|
|
263
|
-
timeoutMs: runtimeConfig.runtimeTimeoutSec > 0 ? runtimeConfig.runtimeTimeoutSec * 1000 : undefined,
|
|
264
|
-
interruptGraceSec: runtimeConfig.interruptGraceSec,
|
|
265
|
-
runPolicy: runtimeConfig.runPolicy
|
|
266
|
-
});
|
|
267
|
-
return sendOk(res, { providerType: typedProviderType, models });
|
|
309
|
+
return handleAdapterModelsRequest(ctx, res, req.companyId!, providerType, parsed.data);
|
|
268
310
|
});
|
|
269
311
|
|
|
270
312
|
router.post("/runtime-preflight", async (req, res) => {
|
|
@@ -425,6 +467,7 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
425
467
|
role: resolveAgentRoleText(parsed.data.role, parsed.data.roleKey, parsed.data.title),
|
|
426
468
|
roleKey: normalizeRoleKey(parsed.data.roleKey),
|
|
427
469
|
title: normalizeTitle(parsed.data.title),
|
|
470
|
+
capabilities: normalizeCapabilities(parsed.data.capabilities),
|
|
428
471
|
name: parsed.data.name,
|
|
429
472
|
providerType: parsed.data.providerType,
|
|
430
473
|
heartbeatCron: parsed.data.heartbeatCron,
|
|
@@ -573,6 +616,8 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
573
616
|
: undefined,
|
|
574
617
|
roleKey: parsed.data.roleKey !== undefined ? normalizeRoleKey(parsed.data.roleKey) : undefined,
|
|
575
618
|
title: parsed.data.title !== undefined ? normalizeTitle(parsed.data.title) : undefined,
|
|
619
|
+
capabilities:
|
|
620
|
+
parsed.data.capabilities !== undefined ? normalizeCapabilities(parsed.data.capabilities) : undefined,
|
|
576
621
|
name: parsed.data.name,
|
|
577
622
|
providerType: parsed.data.providerType,
|
|
578
623
|
status: parsed.data.status,
|
|
@@ -823,6 +868,11 @@ function normalizeTitle(input: string | null | undefined) {
|
|
|
823
868
|
return normalized ? normalized : null;
|
|
824
869
|
}
|
|
825
870
|
|
|
871
|
+
function normalizeCapabilities(input: string | null | undefined) {
|
|
872
|
+
const normalized = input?.trim();
|
|
873
|
+
return normalized ? normalized : null;
|
|
874
|
+
}
|
|
875
|
+
|
|
826
876
|
function resolveAgentRoleText(
|
|
827
877
|
legacyRole: string | undefined,
|
|
828
878
|
roleKeyInput: string | null | undefined,
|
package/src/routes/companies.ts
CHANGED
|
@@ -114,6 +114,8 @@ export function createCompaniesRouter(ctx: AppContext) {
|
|
|
114
114
|
role: "CEO",
|
|
115
115
|
roleKey: "ceo",
|
|
116
116
|
title: "CEO",
|
|
117
|
+
capabilities:
|
|
118
|
+
"Company leadership: priorities, hiring, governance, and aligning agents to mission and budget.",
|
|
117
119
|
name: "CEO",
|
|
118
120
|
providerType,
|
|
119
121
|
heartbeatCron: "*/5 * * * *",
|
package/src/routes/issues.ts
CHANGED
|
@@ -612,10 +612,13 @@ function applyIssueMetadataToBody(
|
|
|
612
612
|
delegatedHiringIntent?: {
|
|
613
613
|
intentType: "agent_hiring_request";
|
|
614
614
|
requestedRole?: string | null;
|
|
615
|
+
requestedRoleKey?: string | null;
|
|
616
|
+
requestedTitle?: string | null;
|
|
615
617
|
requestedName?: string | null;
|
|
616
618
|
requestedManagerAgentId?: string | null;
|
|
617
619
|
requestedProviderType?: string | null;
|
|
618
620
|
requestedRuntimeModel?: string | null;
|
|
621
|
+
requestedCapabilities?: string | null;
|
|
619
622
|
};
|
|
620
623
|
}
|
|
621
624
|
| undefined
|
|
@@ -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
|
+
}
|