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 +4 -4
- package/src/app.ts +2 -0
- package/src/lib/instance-paths.ts +5 -0
- package/src/middleware/cors-config.ts +1 -1
- package/src/routes/loops.ts +360 -0
- package/src/routes/observability.ts +123 -1
- package/src/services/agent-operating-file-service.ts +116 -0
- package/src/services/heartbeat-service/heartbeat-run.ts +7 -2
- package/src/services/memory-file-service.ts +35 -1
- package/src/services/template-apply-service.ts +33 -0
- 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.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-
|
|
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));
|
|
@@ -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 {
|
|
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 =
|
|
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 (
|
|
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);
|