bopodev-api 0.1.30 → 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.30",
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.30",
21
- "bopodev-db": "0.1.30",
22
- "bopodev-agent-sdk": "0.1.30"
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));
@@ -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,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
+ }
@@ -16,7 +16,18 @@ import type { AppContext } from "../context";
16
16
  import { sendError, sendOk } from "../http";
17
17
  import { resolveRunArtifactAbsolutePath } from "../lib/run-artifact-paths";
18
18
  import { requireCompanyScope } from "../middleware/company-scope";
19
- import { listAgentMemoryFiles, loadAgentMemoryContext, readAgentMemoryFile } from "../services/memory-file-service";
19
+ import { enforcePermission } from "../middleware/request-actor";
20
+ import {
21
+ listAgentOperatingMarkdownFiles,
22
+ readAgentOperatingFile,
23
+ writeAgentOperatingFile
24
+ } from "../services/agent-operating-file-service";
25
+ import {
26
+ listAgentMemoryFiles,
27
+ loadAgentMemoryContext,
28
+ readAgentMemoryFile,
29
+ writeAgentMemoryFile
30
+ } from "../services/memory-file-service";
20
31
 
21
32
  export function createObservabilityRouter(ctx: AppContext) {
22
33
  const router = Router();
@@ -259,6 +270,117 @@ export function createObservabilityRouter(ctx: AppContext) {
259
270
  }
260
271
  });
261
272
 
273
+ router.put("/memory/:agentId/file", async (req, res) => {
274
+ if (!enforcePermission(req, res, "agents:write")) {
275
+ return;
276
+ }
277
+ const companyId = req.companyId!;
278
+ const agentId = req.params.agentId;
279
+ const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
280
+ if (!relativePath) {
281
+ return sendError(res, "Query parameter 'path' is required.", 422);
282
+ }
283
+ const body = req.body as { content?: unknown };
284
+ if (typeof body?.content !== "string") {
285
+ return sendError(res, "Expected JSON body with string 'content'.", 422);
286
+ }
287
+ const agents = await listAgents(ctx.db, companyId);
288
+ if (!agents.some((entry) => entry.id === agentId)) {
289
+ return sendError(res, "Agent not found", 404);
290
+ }
291
+ try {
292
+ const result = await writeAgentMemoryFile({
293
+ companyId,
294
+ agentId,
295
+ relativePath,
296
+ content: body.content
297
+ });
298
+ return sendOk(res, result);
299
+ } catch (error) {
300
+ return sendError(res, String(error), 422);
301
+ }
302
+ });
303
+
304
+ router.get("/agent-operating/:agentId/files", async (req, res) => {
305
+ const companyId = req.companyId!;
306
+ const agentId = req.params.agentId;
307
+ const agents = await listAgents(ctx.db, companyId);
308
+ if (!agents.some((entry) => entry.id === agentId)) {
309
+ return sendError(res, "Agent not found", 404);
310
+ }
311
+ const rawLimit = Number(req.query.limit ?? 100);
312
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 500) : 100;
313
+ try {
314
+ const files = await listAgentOperatingMarkdownFiles({
315
+ companyId,
316
+ agentId,
317
+ maxFiles: limit
318
+ });
319
+ return sendOk(res, {
320
+ items: files.map((file) => ({
321
+ relativePath: file.relativePath,
322
+ path: file.path
323
+ }))
324
+ });
325
+ } catch (error) {
326
+ return sendError(res, String(error), 422);
327
+ }
328
+ });
329
+
330
+ router.get("/agent-operating/:agentId/file", async (req, res) => {
331
+ const companyId = req.companyId!;
332
+ const agentId = req.params.agentId;
333
+ const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
334
+ if (!relativePath) {
335
+ return sendError(res, "Query parameter 'path' is required.", 422);
336
+ }
337
+ const agents = await listAgents(ctx.db, companyId);
338
+ if (!agents.some((entry) => entry.id === agentId)) {
339
+ return sendError(res, "Agent not found", 404);
340
+ }
341
+ try {
342
+ const file = await readAgentOperatingFile({
343
+ companyId,
344
+ agentId,
345
+ relativePath
346
+ });
347
+ return sendOk(res, file);
348
+ } catch (error) {
349
+ return sendError(res, String(error), 422);
350
+ }
351
+ });
352
+
353
+ router.put("/agent-operating/:agentId/file", async (req, res) => {
354
+ if (!enforcePermission(req, res, "agents:write")) {
355
+ return;
356
+ }
357
+ const companyId = req.companyId!;
358
+ const agentId = req.params.agentId;
359
+ const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
360
+ if (!relativePath) {
361
+ return sendError(res, "Query parameter 'path' is required.", 422);
362
+ }
363
+ const body = req.body as { content?: unknown };
364
+ if (typeof body?.content !== "string") {
365
+ return sendError(res, "Expected JSON body with string 'content'.", 422);
366
+ }
367
+ const agents = await listAgents(ctx.db, companyId);
368
+ if (!agents.some((entry) => entry.id === agentId)) {
369
+ return sendError(res, "Agent not found", 404);
370
+ }
371
+ try {
372
+ const result = await writeAgentOperatingFile({
373
+ companyId,
374
+ agentId,
375
+ relativePath,
376
+ content: body.content
377
+ });
378
+ return sendOk(res, result);
379
+ } catch (error) {
380
+ return sendError(res, String(error), 422);
381
+ }
382
+ });
383
+
262
384
  router.get("/memory/:agentId/context-preview", async (req, res) => {
263
385
  const companyId = req.companyId!;
264
386
  const agentId = req.params.agentId;
@@ -0,0 +1,116 @@
1
+ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import { isInsidePath, resolveAgentOperatingPath } from "../lib/instance-paths";
4
+
5
+ const MAX_OBSERVABILITY_FILES = 200;
6
+ const MAX_OBSERVABILITY_FILE_BYTES = 512 * 1024;
7
+
8
+ function isMarkdownFileName(name: string) {
9
+ return name.toLowerCase().endsWith(".md");
10
+ }
11
+
12
+ async function walkMarkdownFiles(root: string, maxFiles: number) {
13
+ const collected: string[] = [];
14
+ const queue = [root];
15
+ while (queue.length > 0 && collected.length < maxFiles) {
16
+ const current = queue.shift();
17
+ if (!current) {
18
+ continue;
19
+ }
20
+ const entries = await readdir(current, { withFileTypes: true });
21
+ for (const entry of entries) {
22
+ const absolutePath = join(current, entry.name);
23
+ if (entry.isDirectory()) {
24
+ queue.push(absolutePath);
25
+ continue;
26
+ }
27
+ if (entry.isFile() && isMarkdownFileName(entry.name)) {
28
+ collected.push(absolutePath);
29
+ if (collected.length >= maxFiles) {
30
+ break;
31
+ }
32
+ }
33
+ }
34
+ }
35
+ return collected.sort();
36
+ }
37
+
38
+ export async function listAgentOperatingMarkdownFiles(input: {
39
+ companyId: string;
40
+ agentId: string;
41
+ maxFiles?: number;
42
+ }) {
43
+ const root = resolveAgentOperatingPath(input.companyId, input.agentId);
44
+ await mkdir(root, { recursive: true });
45
+ const maxFiles = Math.max(1, Math.min(MAX_OBSERVABILITY_FILES, input.maxFiles ?? 100));
46
+ const files = await walkMarkdownFiles(root, maxFiles);
47
+ return files.map((filePath) => ({
48
+ path: filePath,
49
+ relativePath: relative(root, filePath),
50
+ operatingRoot: root
51
+ }));
52
+ }
53
+
54
+ export async function readAgentOperatingFile(input: {
55
+ companyId: string;
56
+ agentId: string;
57
+ relativePath: string;
58
+ }) {
59
+ const root = resolveAgentOperatingPath(input.companyId, input.agentId);
60
+ await mkdir(root, { recursive: true });
61
+ const candidate = resolve(root, input.relativePath);
62
+ if (!isInsidePath(root, candidate)) {
63
+ throw new Error("Requested operating path is outside of operating root.");
64
+ }
65
+ const info = await stat(candidate);
66
+ if (!info.isFile()) {
67
+ throw new Error("Requested operating path is not a file.");
68
+ }
69
+ if (info.size > MAX_OBSERVABILITY_FILE_BYTES) {
70
+ throw new Error("Requested operating file exceeds size limit.");
71
+ }
72
+ const content = await readFile(candidate, "utf8");
73
+ return {
74
+ path: candidate,
75
+ relativePath: relative(root, candidate),
76
+ content,
77
+ sizeBytes: info.size
78
+ };
79
+ }
80
+
81
+ export async function writeAgentOperatingFile(input: {
82
+ companyId: string;
83
+ agentId: string;
84
+ relativePath: string;
85
+ content: string;
86
+ }) {
87
+ const root = resolveAgentOperatingPath(input.companyId, input.agentId);
88
+ await mkdir(root, { recursive: true });
89
+ const normalizedRel = input.relativePath.trim();
90
+ if (!normalizedRel || normalizedRel.includes("..")) {
91
+ throw new Error("Invalid relative path.");
92
+ }
93
+ if (!isMarkdownFileName(normalizedRel)) {
94
+ throw new Error("Only .md files can be written under the operating directory.");
95
+ }
96
+ const candidate = resolve(root, normalizedRel);
97
+ if (!isInsidePath(root, candidate)) {
98
+ throw new Error("Requested operating path is outside of operating root.");
99
+ }
100
+ const bytes = Buffer.byteLength(input.content, "utf8");
101
+ if (bytes > MAX_OBSERVABILITY_FILE_BYTES) {
102
+ throw new Error("Content exceeds size limit.");
103
+ }
104
+ const parent = dirname(candidate);
105
+ if (!isInsidePath(root, parent)) {
106
+ throw new Error("Invalid parent directory.");
107
+ }
108
+ await mkdir(parent, { recursive: true });
109
+ await writeFile(candidate, input.content, { encoding: "utf8" });
110
+ const info = await stat(candidate);
111
+ return {
112
+ path: candidate,
113
+ relativePath: relative(root, candidate),
114
+ sizeBytes: info.size
115
+ };
116
+ }
@@ -669,7 +669,9 @@ export async function runHeartbeatForAgent(
669
669
  },
670
670
  failClosed: false
671
671
  });
672
- const isCommentOrderWake = options?.wakeContext?.reason === "issue_comment_recipient";
672
+ const isCommentOrderWake =
673
+ options?.wakeContext?.reason === "issue_comment_recipient" ||
674
+ options?.wakeContext?.reason === "loop_execution";
673
675
  const heartbeatIdlePolicy = resolveHeartbeatIdlePolicy();
674
676
  const workItems = isCommentOrderWake ? [] : await claimIssuesForAgent(db, companyId, agentId, runId);
675
677
  const wakeWorkItems = await loadWakeContextWorkItems(db, companyId, options?.wakeContext?.issueIds);
@@ -1941,7 +1943,10 @@ function resolveExecutionWorkItems(
1941
1943
  wakeContextItems: IssueWorkItemRow[],
1942
1944
  wakeContext?: HeartbeatWakeContext
1943
1945
  ) {
1944
- if (wakeContext?.reason === "issue_comment_recipient" && wakeContextItems.length > 0) {
1946
+ if (
1947
+ (wakeContext?.reason === "issue_comment_recipient" || wakeContext?.reason === "loop_execution") &&
1948
+ wakeContextItems.length > 0
1949
+ ) {
1945
1950
  return wakeContextItems;
1946
1951
  }
1947
1952
  return mergeContextWorkItems(assigned, wakeContextItems);