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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-api",
3
- "version": "0.1.29",
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-contracts": "0.1.29",
21
- "bopodev-agent-sdk": "0.1.29",
22
- "bopodev-db": "0.1.29"
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
@@ -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
- if (!runtimePreflightSchema.shape.providerType.safeParse(providerType).success) {
233
- return sendError(res, `Unsupported provider type: ${providerType}`, 422);
234
- }
235
- const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, req.companyId!);
236
- let runtimeConfig: ReturnType<typeof normalizeRuntimeConfig>;
237
- try {
238
- runtimeConfig = normalizeRuntimeConfig({
239
- runtimeConfig: req.body?.runtimeConfig,
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
- const typedProviderType = providerType as
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,
@@ -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 * * * *",
@@ -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
+ }