@aitne/daemon 0.1.4 → 0.1.6
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/dist/adapters/notification-manager.d.ts +12 -0
- package/dist/adapters/notification-manager.d.ts.map +1 -1
- package/dist/adapters/notification-manager.js +39 -1
- package/dist/adapters/notification-manager.js.map +1 -1
- package/dist/api/routes/agent.d.ts.map +1 -1
- package/dist/api/routes/agent.js +7 -0
- package/dist/api/routes/agent.js.map +1 -1
- package/dist/api/routes/commands.d.ts.map +1 -1
- package/dist/api/routes/commands.js +16 -13
- package/dist/api/routes/commands.js.map +1 -1
- package/dist/api/routes/context.d.ts.map +1 -1
- package/dist/api/routes/context.js +13 -2
- package/dist/api/routes/context.js.map +1 -1
- package/dist/api/routes/dashboard.d.ts.map +1 -1
- package/dist/api/routes/dashboard.js +28 -0
- package/dist/api/routes/dashboard.js.map +1 -1
- package/dist/api/routes/fs.d.ts +23 -0
- package/dist/api/routes/fs.d.ts.map +1 -0
- package/dist/api/routes/fs.js +156 -0
- package/dist/api/routes/fs.js.map +1 -0
- package/dist/api/routes/fs.logic.d.ts +62 -0
- package/dist/api/routes/fs.logic.d.ts.map +1 -0
- package/dist/api/routes/fs.logic.js +137 -0
- package/dist/api/routes/fs.logic.js.map +1 -0
- package/dist/api/routes/health.d.ts.map +1 -1
- package/dist/api/routes/health.js +4 -2
- package/dist/api/routes/health.js.map +1 -1
- package/dist/api/routes/integrations.d.ts.map +1 -1
- package/dist/api/routes/integrations.js +8 -6
- package/dist/api/routes/integrations.js.map +1 -1
- package/dist/api/routes/metrics.d.ts +1 -0
- package/dist/api/routes/metrics.d.ts.map +1 -1
- package/dist/api/routes/metrics.js +24 -0
- package/dist/api/routes/metrics.js.map +1 -1
- package/dist/api/routes/observations.d.ts.map +1 -1
- package/dist/api/routes/observations.js +538 -25
- package/dist/api/routes/observations.js.map +1 -1
- package/dist/api/routes/skills.d.ts +9 -1
- package/dist/api/routes/skills.d.ts.map +1 -1
- package/dist/api/routes/skills.js +38 -16
- package/dist/api/routes/skills.js.map +1 -1
- package/dist/api/routes/wiki.d.ts +4 -0
- package/dist/api/routes/wiki.d.ts.map +1 -0
- package/dist/api/routes/wiki.js +1075 -0
- package/dist/api/routes/wiki.js.map +1 -0
- package/dist/api/server.d.ts +13 -0
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +27 -1
- package/dist/api/server.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +26 -0
- package/dist/config.js.map +1 -1
- package/dist/core/agent-core.d.ts +25 -0
- package/dist/core/agent-core.d.ts.map +1 -1
- package/dist/core/agent-core.js.map +1 -1
- package/dist/core/backends/backend-router.d.ts +5 -1
- package/dist/core/backends/backend-router.d.ts.map +1 -1
- package/dist/core/backends/backend-router.js +10 -1
- package/dist/core/backends/backend-router.js.map +1 -1
- package/dist/core/backends/claude-code-core.d.ts.map +1 -1
- package/dist/core/backends/claude-code-core.js +62 -4
- package/dist/core/backends/claude-code-core.js.map +1 -1
- package/dist/core/backends/claude-tool-collection.d.ts +1 -1
- package/dist/core/backends/claude-tool-collection.d.ts.map +1 -1
- package/dist/core/backends/claude-tool-collection.js +327 -65
- package/dist/core/backends/claude-tool-collection.js.map +1 -1
- package/dist/core/backends/codex-core.d.ts.map +1 -1
- package/dist/core/backends/codex-core.js +36 -0
- package/dist/core/backends/codex-core.js.map +1 -1
- package/dist/core/backends/gemini-cli-core.d.ts +24 -5
- package/dist/core/backends/gemini-cli-core.d.ts.map +1 -1
- package/dist/core/backends/gemini-cli-core.js +62 -30
- package/dist/core/backends/gemini-cli-core.js.map +1 -1
- package/dist/core/backends/plan-presets.d.ts +3 -1
- package/dist/core/backends/plan-presets.d.ts.map +1 -1
- package/dist/core/backends/plan-presets.js +42 -2
- package/dist/core/backends/plan-presets.js.map +1 -1
- package/dist/core/bang-commands/commands-help.d.ts +5 -0
- package/dist/core/bang-commands/commands-help.d.ts.map +1 -0
- package/dist/core/bang-commands/commands-help.js +69 -0
- package/dist/core/bang-commands/commands-help.js.map +1 -0
- package/dist/core/bang-commands/commands-wiki.d.ts +75 -0
- package/dist/core/bang-commands/commands-wiki.d.ts.map +1 -0
- package/dist/core/bang-commands/commands-wiki.js +574 -0
- package/dist/core/bang-commands/commands-wiki.js.map +1 -0
- package/dist/core/bang-commands/index.d.ts +4 -2
- package/dist/core/bang-commands/index.d.ts.map +1 -1
- package/dist/core/bang-commands/index.js +15 -1
- package/dist/core/bang-commands/index.js.map +1 -1
- package/dist/core/bang-commands/registry.d.ts +47 -4
- package/dist/core/bang-commands/registry.d.ts.map +1 -1
- package/dist/core/bang-commands/registry.js +85 -15
- package/dist/core/bang-commands/registry.js.map +1 -1
- package/dist/core/context-builder.d.ts +17 -0
- package/dist/core/context-builder.d.ts.map +1 -1
- package/dist/core/context-builder.js +64 -6
- package/dist/core/context-builder.js.map +1 -1
- package/dist/core/daemon-api-cli.d.ts.map +1 -1
- package/dist/core/daemon-api-cli.js +50 -2
- package/dist/core/daemon-api-cli.js.map +1 -1
- package/dist/core/dispatcher-message-handler.d.ts.map +1 -1
- package/dist/core/dispatcher-message-handler.js +10 -0
- package/dist/core/dispatcher-message-handler.js.map +1 -1
- package/dist/core/dispatcher-morning-routine.d.ts.map +1 -1
- package/dist/core/dispatcher-morning-routine.js +17 -2
- package/dist/core/dispatcher-morning-routine.js.map +1 -1
- package/dist/core/dispatcher-result-processor.d.ts +23 -0
- package/dist/core/dispatcher-result-processor.d.ts.map +1 -1
- package/dist/core/dispatcher-result-processor.js +124 -5
- package/dist/core/dispatcher-result-processor.js.map +1 -1
- package/dist/core/dispatcher-scheduled-tasks.d.ts.map +1 -1
- package/dist/core/dispatcher-scheduled-tasks.js +114 -80
- package/dist/core/dispatcher-scheduled-tasks.js.map +1 -1
- package/dist/core/dispatcher-types.d.ts +116 -1
- package/dist/core/dispatcher-types.d.ts.map +1 -1
- package/dist/core/dispatcher-types.js.map +1 -1
- package/dist/core/dispatcher.d.ts +36 -0
- package/dist/core/dispatcher.d.ts.map +1 -1
- package/dist/core/dispatcher.js +94 -1
- package/dist/core/dispatcher.js.map +1 -1
- package/dist/core/integration-lifecycle.d.ts.map +1 -1
- package/dist/core/integration-lifecycle.js +6 -8
- package/dist/core/integration-lifecycle.js.map +1 -1
- package/dist/core/metrics.d.ts +127 -0
- package/dist/core/metrics.d.ts.map +1 -1
- package/dist/core/metrics.js +256 -1
- package/dist/core/metrics.js.map +1 -1
- package/dist/core/prompts.d.ts +2 -1
- package/dist/core/prompts.d.ts.map +1 -1
- package/dist/core/prompts.js +40 -0
- package/dist/core/prompts.js.map +1 -1
- package/dist/core/roadmap-validate.js +13 -1
- package/dist/core/roadmap-validate.js.map +1 -1
- package/dist/core/routine-acquisition-plan.d.ts +51 -0
- package/dist/core/routine-acquisition-plan.d.ts.map +1 -1
- package/dist/core/routine-acquisition-plan.js +111 -12
- package/dist/core/routine-acquisition-plan.js.map +1 -1
- package/dist/core/routine-fetch-window-retry.d.ts +109 -0
- package/dist/core/routine-fetch-window-retry.d.ts.map +1 -0
- package/dist/core/routine-fetch-window-retry.js +210 -0
- package/dist/core/routine-fetch-window-retry.js.map +1 -0
- package/dist/core/routine-fetch-window-runner.d.ts +258 -32
- package/dist/core/routine-fetch-window-runner.d.ts.map +1 -1
- package/dist/core/routine-fetch-window-runner.js +1115 -185
- package/dist/core/routine-fetch-window-runner.js.map +1 -1
- package/dist/core/routine-windows.d.ts +19 -4
- package/dist/core/routine-windows.d.ts.map +1 -1
- package/dist/core/routine-windows.js +47 -0
- package/dist/core/routine-windows.js.map +1 -1
- package/dist/core/scheduler.d.ts +50 -2
- package/dist/core/scheduler.d.ts.map +1 -1
- package/dist/core/scheduler.js +88 -7
- package/dist/core/scheduler.js.map +1 -1
- package/dist/core/skill-curation/declarations.d.ts.map +1 -1
- package/dist/core/skill-curation/declarations.js +11 -12
- package/dist/core/skill-curation/declarations.js.map +1 -1
- package/dist/core/skill-source-paths.d.ts +14 -0
- package/dist/core/skill-source-paths.d.ts.map +1 -0
- package/dist/core/skill-source-paths.js +82 -0
- package/dist/core/skill-source-paths.js.map +1 -0
- package/dist/core/skills-compiler.d.ts +18 -0
- package/dist/core/skills-compiler.d.ts.map +1 -1
- package/dist/core/skills-compiler.js +65 -18
- package/dist/core/skills-compiler.js.map +1 -1
- package/dist/core/skills-manifest.d.ts.map +1 -1
- package/dist/core/skills-manifest.js +46 -0
- package/dist/core/skills-manifest.js.map +1 -1
- package/dist/core/system-reset.d.ts +25 -0
- package/dist/core/system-reset.d.ts.map +1 -1
- package/dist/core/system-reset.js +47 -0
- package/dist/core/system-reset.js.map +1 -1
- package/dist/core/wiki/approval-queue.d.ts +31 -0
- package/dist/core/wiki/approval-queue.d.ts.map +1 -0
- package/dist/core/wiki/approval-queue.js +44 -0
- package/dist/core/wiki/approval-queue.js.map +1 -0
- package/dist/core/wiki/bridge.d.ts +74 -0
- package/dist/core/wiki/bridge.d.ts.map +1 -0
- package/dist/core/wiki/bridge.js +405 -0
- package/dist/core/wiki/bridge.js.map +1 -0
- package/dist/core/wiki/compile-lock.d.ts +42 -0
- package/dist/core/wiki/compile-lock.d.ts.map +1 -0
- package/dist/core/wiki/compile-lock.js +55 -0
- package/dist/core/wiki/compile-lock.js.map +1 -0
- package/dist/core/wiki/compile-preview.d.ts +8 -0
- package/dist/core/wiki/compile-preview.d.ts.map +1 -0
- package/dist/core/wiki/compile-preview.js +200 -0
- package/dist/core/wiki/compile-preview.js.map +1 -0
- package/dist/core/wiki/cost-estimate.d.ts +30 -0
- package/dist/core/wiki/cost-estimate.d.ts.map +1 -0
- package/dist/core/wiki/cost-estimate.js +243 -0
- package/dist/core/wiki/cost-estimate.js.map +1 -0
- package/dist/core/wiki/dispatcher.d.ts +48 -0
- package/dist/core/wiki/dispatcher.d.ts.map +1 -0
- package/dist/core/wiki/dispatcher.js +92 -0
- package/dist/core/wiki/dispatcher.js.map +1 -0
- package/dist/core/wiki/git-precompile.d.ts +86 -0
- package/dist/core/wiki/git-precompile.d.ts.map +1 -0
- package/dist/core/wiki/git-precompile.js +96 -0
- package/dist/core/wiki/git-precompile.js.map +1 -0
- package/dist/core/wiki/import-migrate.d.ts +38 -0
- package/dist/core/wiki/import-migrate.d.ts.map +1 -0
- package/dist/core/wiki/import-migrate.js +310 -0
- package/dist/core/wiki/import-migrate.js.map +1 -0
- package/dist/core/wiki/import-probe.d.ts +76 -0
- package/dist/core/wiki/import-probe.d.ts.map +1 -0
- package/dist/core/wiki/import-probe.js +245 -0
- package/dist/core/wiki/import-probe.js.map +1 -0
- package/dist/core/wiki/index-cache.d.ts +39 -0
- package/dist/core/wiki/index-cache.d.ts.map +1 -0
- package/dist/core/wiki/index-cache.js +152 -0
- package/dist/core/wiki/index-cache.js.map +1 -0
- package/dist/core/wiki/multi-url-dispatch.d.ts +52 -0
- package/dist/core/wiki/multi-url-dispatch.d.ts.map +1 -0
- package/dist/core/wiki/multi-url-dispatch.js +72 -0
- package/dist/core/wiki/multi-url-dispatch.js.map +1 -0
- package/dist/core/wiki/wiki-fts.d.ts +75 -0
- package/dist/core/wiki/wiki-fts.d.ts.map +1 -0
- package/dist/core/wiki/wiki-fts.js +265 -0
- package/dist/core/wiki/wiki-fts.js.map +1 -0
- package/dist/core/wiki/workspaces.d.ts +101 -0
- package/dist/core/wiki/workspaces.d.ts.map +1 -0
- package/dist/core/wiki/workspaces.js +352 -0
- package/dist/core/wiki/workspaces.js.map +1 -0
- package/dist/core/wiki/write-strategy.d.ts +70 -0
- package/dist/core/wiki/write-strategy.d.ts.map +1 -0
- package/dist/core/wiki/write-strategy.js +112 -0
- package/dist/core/wiki/write-strategy.js.map +1 -0
- package/dist/core/workdir.d.ts +8 -1
- package/dist/core/workdir.d.ts.map +1 -1
- package/dist/core/workdir.js +4 -1
- package/dist/core/workdir.js.map +1 -1
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +122 -0
- package/dist/db/schema.js.map +1 -1
- package/dist/db/wiki-store.d.ts +3 -0
- package/dist/db/wiki-store.d.ts.map +1 -0
- package/dist/db/wiki-store.js +7 -0
- package/dist/db/wiki-store.js.map +1 -0
- package/dist/index.js +80 -4
- package/dist/index.js.map +1 -1
- package/dist/messaging/url-extract.d.ts +8 -0
- package/dist/messaging/url-extract.d.ts.map +1 -0
- package/dist/messaging/url-extract.js +41 -0
- package/dist/messaging/url-extract.js.map +1 -0
- package/dist/observers/delegated-sync-worker.d.ts +33 -25
- package/dist/observers/delegated-sync-worker.d.ts.map +1 -1
- package/dist/observers/delegated-sync-worker.js +38 -31
- package/dist/observers/delegated-sync-worker.js.map +1 -1
- package/dist/observers/imminent-event-scheduler.d.ts +20 -7
- package/dist/observers/imminent-event-scheduler.d.ts.map +1 -1
- package/dist/observers/imminent-event-scheduler.js +134 -29
- package/dist/observers/imminent-event-scheduler.js.map +1 -1
- package/dist/safety/always-disallowed.d.ts +65 -0
- package/dist/safety/always-disallowed.d.ts.map +1 -1
- package/dist/safety/always-disallowed.js +106 -10
- package/dist/safety/always-disallowed.js.map +1 -1
- package/dist/safety/audit.d.ts +46 -1
- package/dist/safety/audit.d.ts.map +1 -1
- package/dist/safety/audit.js +79 -16
- package/dist/safety/audit.js.map +1 -1
- package/dist/safety/risk-classifier.d.ts.map +1 -1
- package/dist/safety/risk-classifier.js +29 -0
- package/dist/safety/risk-classifier.js.map +1 -1
- package/dist/settings/runtime-settings.d.ts +12 -1
- package/dist/settings/runtime-settings.d.ts.map +1 -1
- package/dist/settings/runtime-settings.js +59 -1
- package/dist/settings/runtime-settings.js.map +1 -1
- package/package.json +2 -2
|
@@ -3,7 +3,7 @@ import { INTEGRATION_KEYS, isIntegrationKey, } from "@aitne/shared";
|
|
|
3
3
|
import { consumeObservations, getNoveltyDistribution, getObservationStats, getPendingObservations, getSummaryStatusCounts, recordObservation, } from "../../db/observations.js";
|
|
4
4
|
import { readIntegrationFlipLock } from "../../core/integration-lifecycle.js";
|
|
5
5
|
import { createLogger } from "../../logging.js";
|
|
6
|
-
import { readJsonBody } from "../json-body.js";
|
|
6
|
+
import { DEFAULT_JSON_BODY_MAX_BYTES, readJsonBody } from "../json-body.js";
|
|
7
7
|
const logger = createLogger("observations-api");
|
|
8
8
|
function parseBoolean(value, defaultValue) {
|
|
9
9
|
if (value === undefined)
|
|
@@ -222,32 +222,151 @@ export function createObservationRoutes(deps) {
|
|
|
222
222
|
* old mode label. Sources outside the integration registry (Obsidian,
|
|
223
223
|
* Git, messaging adapter) are never rejected.
|
|
224
224
|
*/
|
|
225
|
+
const RECORD_EXPECTED_SHAPE = '{"source": string, "ref": string, "changeType"?: "created"|"modified"|"deleted", "actor"?: "agent"|"system", "payload"?: unknown}';
|
|
226
|
+
const RECORD_EXAMPLE = '{"source":"roadmap_candidate:travel","ref":"trip-portland-2026-summer","changeType":"created","actor":"agent","payload":{"note":"DM mentioned Portland trip"}}';
|
|
225
227
|
app.post("/observations", async (c) => {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
228
|
+
// Peek at the raw body BEFORE delegating to `readJsonBody` so we can
|
|
229
|
+
// turn a query-string-shaped body ("limit=30", "actor=user&limit=20")
|
|
230
|
+
// into a method-confusion hint. Production telemetry showed the
|
|
231
|
+
// hourly_check agent sending `POST /api/observations` with body
|
|
232
|
+
// `limit=30`, expecting it to fetch. Forwarding readJsonBody's
|
|
233
|
+
// generic "Unexpected token 'l'" message gave the agent no signal
|
|
234
|
+
// that the right call was `GET /api/observations?limit=30`.
|
|
235
|
+
//
|
|
236
|
+
// Size cap mirrors `readJsonBody`'s defense-in-depth: declared
|
|
237
|
+
// Content-Length AND post-read byteLength are both checked against
|
|
238
|
+
// `DEFAULT_JSON_BODY_MAX_BYTES` (1 MiB). Without this an inline
|
|
239
|
+
// reader would happily buffer a malicious 10 MB body and pin RAM.
|
|
240
|
+
const declared = c.req.header("content-length");
|
|
241
|
+
if (declared !== undefined) {
|
|
242
|
+
const declaredN = Number.parseInt(declared, 10);
|
|
243
|
+
if (Number.isFinite(declaredN) && declaredN > DEFAULT_JSON_BODY_MAX_BYTES) {
|
|
244
|
+
return c.json({
|
|
245
|
+
error: "body_too_large",
|
|
246
|
+
maxBytes: DEFAULT_JSON_BODY_MAX_BYTES,
|
|
247
|
+
actualBytes: declaredN,
|
|
248
|
+
}, 413);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
let raw;
|
|
252
|
+
try {
|
|
253
|
+
raw = await c.req.text();
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
257
|
+
return c.json({ error: "invalid_json_body", message: detail }, 400);
|
|
258
|
+
}
|
|
259
|
+
const actualBytes = Buffer.byteLength(raw, "utf-8");
|
|
260
|
+
if (actualBytes > DEFAULT_JSON_BODY_MAX_BYTES) {
|
|
261
|
+
return c.json({
|
|
262
|
+
error: "body_too_large",
|
|
263
|
+
maxBytes: DEFAULT_JSON_BODY_MAX_BYTES,
|
|
264
|
+
actualBytes,
|
|
265
|
+
}, 413);
|
|
266
|
+
}
|
|
267
|
+
const trimmed = raw.trim();
|
|
268
|
+
if (trimmed.length > 0 && trimmed[0] !== "{" && trimmed[0] !== "[") {
|
|
269
|
+
// A bare `key=value(&key=value)+` body shape is unambiguous:
|
|
270
|
+
// there is no JSON document that starts with a bare identifier
|
|
271
|
+
// followed by `=`. Suggest the GET form verbatim so the agent
|
|
272
|
+
// can copy-paste it.
|
|
273
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(trimmed)) {
|
|
274
|
+
return c.json({
|
|
275
|
+
error: "method_confusion",
|
|
276
|
+
message: "POST /api/observations records a new observation (JSON body). Your body looks like a query string — did you mean to GET?",
|
|
277
|
+
hint: `Use GET /api/observations?${trimmed} to fetch, or POST with a JSON body matching expectedShape to record.`,
|
|
278
|
+
expectedShape: RECORD_EXPECTED_SHAPE,
|
|
279
|
+
example: RECORD_EXAMPLE,
|
|
280
|
+
}, 400);
|
|
281
|
+
}
|
|
282
|
+
return c.json({
|
|
283
|
+
error: "invalid_json_body",
|
|
284
|
+
message: `Body must be a JSON object starting with '{' — received '${trimmed.slice(0, 32)}…'`,
|
|
285
|
+
expectedShape: RECORD_EXPECTED_SHAPE,
|
|
286
|
+
example: RECORD_EXAMPLE,
|
|
287
|
+
}, 400);
|
|
288
|
+
}
|
|
289
|
+
let body;
|
|
290
|
+
try {
|
|
291
|
+
body = trimmed.length === 0 ? null : JSON.parse(trimmed);
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
295
|
+
return c.json({
|
|
296
|
+
error: "invalid_json_body",
|
|
297
|
+
message: detail,
|
|
298
|
+
expectedShape: RECORD_EXPECTED_SHAPE,
|
|
299
|
+
example: RECORD_EXAMPLE,
|
|
300
|
+
}, 400);
|
|
301
|
+
}
|
|
302
|
+
const issues = [];
|
|
303
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
304
|
+
return c.json({
|
|
305
|
+
error: "validation_error",
|
|
306
|
+
message: "Body must be a JSON object",
|
|
307
|
+
expectedShape: RECORD_EXPECTED_SHAPE,
|
|
308
|
+
example: RECORD_EXAMPLE,
|
|
309
|
+
}, 400);
|
|
310
|
+
}
|
|
311
|
+
if (typeof body.source !== "string" || body.source.length === 0) {
|
|
312
|
+
issues.push({
|
|
313
|
+
field: "source",
|
|
314
|
+
expected: "non-empty string",
|
|
315
|
+
got: body.source === undefined ? "missing" : typeof body.source,
|
|
316
|
+
hint: "Use a registry-aware prefix like 'roadmap_candidate:<subkind>' or an integration key like 'gmail:<account>'",
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (typeof body.ref !== "string" || body.ref.length === 0) {
|
|
320
|
+
issues.push({
|
|
321
|
+
field: "ref",
|
|
322
|
+
expected: "non-empty string",
|
|
323
|
+
got: body.ref === undefined ? "missing" : typeof body.ref,
|
|
324
|
+
hint: "A stable identifier within the source — e.g. message id, file path, candidate slug",
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
if (typeof body.changeType !== "string" &&
|
|
328
|
+
body.changeType !== undefined) {
|
|
329
|
+
issues.push({
|
|
330
|
+
field: "changeType",
|
|
331
|
+
expected: "'created' | 'modified' | 'deleted' (optional, defaults to 'created')",
|
|
332
|
+
got: typeof body.changeType,
|
|
333
|
+
});
|
|
236
334
|
}
|
|
335
|
+
if (issues.length > 0) {
|
|
336
|
+
return c.json({
|
|
337
|
+
error: "validation_error",
|
|
338
|
+
message: "Request body failed schema validation",
|
|
339
|
+
expectedShape: RECORD_EXPECTED_SHAPE,
|
|
340
|
+
example: RECORD_EXAMPLE,
|
|
341
|
+
issues,
|
|
342
|
+
}, 400);
|
|
343
|
+
}
|
|
344
|
+
// The `issues` array above guarantees `body.source` and `body.ref`
|
|
345
|
+
// are non-empty strings; cast for the rest of the handler.
|
|
346
|
+
const source = body.source;
|
|
347
|
+
const ref = body.ref;
|
|
237
348
|
const changeType = typeof body.changeType === "string" ? body.changeType : "created";
|
|
238
349
|
if (!["created", "modified", "deleted"].includes(changeType)) {
|
|
239
|
-
return c.json({
|
|
350
|
+
return c.json({
|
|
351
|
+
error: "invalid_change_type",
|
|
352
|
+
message: `'changeType' must be one of 'created', 'modified', 'deleted' — received '${changeType}'`,
|
|
353
|
+
hint: "Omit the field to default to 'created'",
|
|
354
|
+
}, 400);
|
|
240
355
|
}
|
|
241
356
|
const actor = typeof body.actor === "string" ? body.actor : "agent";
|
|
242
357
|
if (!["agent", "system"].includes(actor)) {
|
|
243
|
-
return c.json({
|
|
358
|
+
return c.json({
|
|
359
|
+
error: "invalid_actor",
|
|
360
|
+
message: `'actor' must be 'agent' or 'system' — received '${actor}'`,
|
|
361
|
+
hint: "User-originated observations come in through the vault / mail watchers; this endpoint only accepts agent/system writes",
|
|
362
|
+
}, 400);
|
|
244
363
|
}
|
|
245
364
|
// §11.3.1 — defensive flip-lock check. The integration key inferred
|
|
246
365
|
// from the source is the same key the PATCH route would have locked.
|
|
247
366
|
// If a flip is mid-flight, return 409 so the agent retries after the
|
|
248
367
|
// drain completes. Sources that don't map to a registered integration
|
|
249
368
|
// are pass-through (Obsidian / Git / messaging never lock).
|
|
250
|
-
const lockedKey = inferIntegrationKeyFromSource(
|
|
369
|
+
const lockedKey = inferIntegrationKeyFromSource(source);
|
|
251
370
|
if (lockedKey) {
|
|
252
371
|
const lock = readIntegrationFlipLock(db, lockedKey);
|
|
253
372
|
if (lock) {
|
|
@@ -261,15 +380,15 @@ export function createObservationRoutes(deps) {
|
|
|
261
380
|
}
|
|
262
381
|
}
|
|
263
382
|
const result = recordObservation(db, {
|
|
264
|
-
source
|
|
265
|
-
ref
|
|
383
|
+
source,
|
|
384
|
+
ref,
|
|
266
385
|
changeType: changeType,
|
|
267
386
|
actor: actor,
|
|
268
387
|
payload: body.payload,
|
|
269
388
|
});
|
|
270
389
|
logger.info({
|
|
271
|
-
source
|
|
272
|
-
ref
|
|
390
|
+
source,
|
|
391
|
+
ref,
|
|
273
392
|
actor,
|
|
274
393
|
contentHash: result.contentHash,
|
|
275
394
|
action: result.action,
|
|
@@ -296,22 +415,416 @@ export function createObservationRoutes(deps) {
|
|
|
296
415
|
action: result.action,
|
|
297
416
|
});
|
|
298
417
|
});
|
|
418
|
+
/**
|
|
419
|
+
* POST /observations/batch — record many agent-originated observations in a
|
|
420
|
+
* single transaction.
|
|
421
|
+
*
|
|
422
|
+
* Reason this exists: the routine.fetch_window pre-pass on Haiku posts
|
|
423
|
+
* many observations per integration window (~20 mail messages, ~6 calendar
|
|
424
|
+
* events). Calling `POST /observations` once per item collided with two
|
|
425
|
+
* orthogonal safety layers in `claude-tool-collection.ts:bashCurlHook`:
|
|
426
|
+
*
|
|
427
|
+
* 1. The "one curl per Bash invocation" cap blocks `cat | bash`, chained
|
|
428
|
+
* `curl … ; curl …`, and `for` loops containing curl.
|
|
429
|
+
* 2. URL extraction strips heredoc bodies before validation, so a
|
|
430
|
+
* `cat > /tmp/script.sh << 'EOF' … curl http://localhost:8321/… EOF`
|
|
431
|
+
* batching shape blocks with "curl command must contain an explicit
|
|
432
|
+
* localhost URL" — the URL lives in the stdin payload, not argv.
|
|
433
|
+
*
|
|
434
|
+
* Production telemetry on 2026-05-13 morning routine showed Haiku
|
|
435
|
+
* burning four budget cycles and posting zero observations because every
|
|
436
|
+
* batching shape it tried was blocked, leaving `today.md` empty. The
|
|
437
|
+
* single-curl-with-array endpoint resolves the cardinality mismatch
|
|
438
|
+
* without weakening either hook.
|
|
439
|
+
*
|
|
440
|
+
* Body: `{ "observations": [...] }` with up to 200 entries per call.
|
|
441
|
+
* Per-item validation mirrors POST /observations exactly. The whole
|
|
442
|
+
* batch executes inside one `db.transaction()`; any per-item failure is
|
|
443
|
+
* recorded in the response and the rest of the batch proceeds.
|
|
444
|
+
*
|
|
445
|
+
* Response is always 200 (or 400 for a malformed envelope). Per-item
|
|
446
|
+
* outcomes live in `results[*].status` so the agent doesn't retry the
|
|
447
|
+
* whole batch on a partial failure.
|
|
448
|
+
*/
|
|
449
|
+
const BATCH_MAX_OBSERVATIONS = 200;
|
|
450
|
+
const BATCH_EXPECTED_SHAPE = '{"observations": [{"source": string, "ref": string, "changeType"?: "created"|"modified"|"deleted", "actor"?: "agent"|"system", "payload"?: unknown}, ...]}';
|
|
451
|
+
const BATCH_EXAMPLE = '{"observations":[{"source":"google_calendar:primary","ref":"evt-1","payload":{"kind":"calendar","providerId":"primary","raw":{"title":"…"}}},{"source":"google_calendar:primary","ref":"evt-2","payload":{"kind":"calendar","providerId":"primary","raw":{"title":"…"}}}]}';
|
|
452
|
+
function validateBatchItem(item, index) {
|
|
453
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
454
|
+
return {
|
|
455
|
+
ok: false,
|
|
456
|
+
result: {
|
|
457
|
+
index,
|
|
458
|
+
status: "validation_error",
|
|
459
|
+
error: "item must be a JSON object",
|
|
460
|
+
hint: BATCH_EXPECTED_SHAPE,
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const obj = item;
|
|
465
|
+
if (typeof obj.source !== "string" || obj.source.length === 0) {
|
|
466
|
+
return {
|
|
467
|
+
ok: false,
|
|
468
|
+
result: {
|
|
469
|
+
index,
|
|
470
|
+
status: "validation_error",
|
|
471
|
+
error: "'source' must be a non-empty string",
|
|
472
|
+
hint: "Use 'gmail:<account>', 'google_calendar:<calendarId>', 'notion:<dbId>', etc.",
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
if (typeof obj.ref !== "string" || obj.ref.length === 0) {
|
|
477
|
+
return {
|
|
478
|
+
ok: false,
|
|
479
|
+
result: {
|
|
480
|
+
index,
|
|
481
|
+
status: "validation_error",
|
|
482
|
+
source: obj.source,
|
|
483
|
+
error: "'ref' must be a non-empty string",
|
|
484
|
+
hint: "Stable id within the source — e.g. message id, event id",
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const changeType = typeof obj.changeType === "string" ? obj.changeType : "created";
|
|
489
|
+
if (!["created", "modified", "deleted"].includes(changeType)) {
|
|
490
|
+
return {
|
|
491
|
+
ok: false,
|
|
492
|
+
result: {
|
|
493
|
+
index,
|
|
494
|
+
status: "validation_error",
|
|
495
|
+
source: obj.source,
|
|
496
|
+
ref: obj.ref,
|
|
497
|
+
error: `'changeType' must be 'created'|'modified'|'deleted' — received '${changeType}'`,
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
const actor = typeof obj.actor === "string" ? obj.actor : "agent";
|
|
502
|
+
if (!["agent", "system"].includes(actor)) {
|
|
503
|
+
return {
|
|
504
|
+
ok: false,
|
|
505
|
+
result: {
|
|
506
|
+
index,
|
|
507
|
+
status: "validation_error",
|
|
508
|
+
source: obj.source,
|
|
509
|
+
ref: obj.ref,
|
|
510
|
+
error: `'actor' must be 'agent' or 'system' — received '${actor}'`,
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
ok: true,
|
|
516
|
+
source: obj.source,
|
|
517
|
+
ref: obj.ref,
|
|
518
|
+
changeType: changeType,
|
|
519
|
+
actor: actor,
|
|
520
|
+
payload: obj.payload,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
app.post("/observations/batch", async (c) => {
|
|
524
|
+
// Size-cap defense mirrors POST /observations.
|
|
525
|
+
const declared = c.req.header("content-length");
|
|
526
|
+
if (declared !== undefined) {
|
|
527
|
+
const declaredN = Number.parseInt(declared, 10);
|
|
528
|
+
if (Number.isFinite(declaredN) && declaredN > DEFAULT_JSON_BODY_MAX_BYTES) {
|
|
529
|
+
return c.json({
|
|
530
|
+
error: "body_too_large",
|
|
531
|
+
maxBytes: DEFAULT_JSON_BODY_MAX_BYTES,
|
|
532
|
+
actualBytes: declaredN,
|
|
533
|
+
}, 413);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
let raw;
|
|
537
|
+
try {
|
|
538
|
+
raw = await c.req.text();
|
|
539
|
+
}
|
|
540
|
+
catch (err) {
|
|
541
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
542
|
+
return c.json({ error: "invalid_json_body", message: detail }, 400);
|
|
543
|
+
}
|
|
544
|
+
const actualBytes = Buffer.byteLength(raw, "utf-8");
|
|
545
|
+
if (actualBytes > DEFAULT_JSON_BODY_MAX_BYTES) {
|
|
546
|
+
return c.json({
|
|
547
|
+
error: "body_too_large",
|
|
548
|
+
maxBytes: DEFAULT_JSON_BODY_MAX_BYTES,
|
|
549
|
+
actualBytes,
|
|
550
|
+
}, 413);
|
|
551
|
+
}
|
|
552
|
+
let envelope;
|
|
553
|
+
try {
|
|
554
|
+
envelope = raw.trim().length === 0 ? null : JSON.parse(raw);
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
558
|
+
return c.json({
|
|
559
|
+
error: "invalid_json_body",
|
|
560
|
+
message: detail,
|
|
561
|
+
expectedShape: BATCH_EXPECTED_SHAPE,
|
|
562
|
+
example: BATCH_EXAMPLE,
|
|
563
|
+
}, 400);
|
|
564
|
+
}
|
|
565
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
|
|
566
|
+
return c.json({
|
|
567
|
+
error: "validation_error",
|
|
568
|
+
message: "Body must be a JSON object with an 'observations' array",
|
|
569
|
+
expectedShape: BATCH_EXPECTED_SHAPE,
|
|
570
|
+
example: BATCH_EXAMPLE,
|
|
571
|
+
}, 400);
|
|
572
|
+
}
|
|
573
|
+
if (!Array.isArray(envelope.observations)) {
|
|
574
|
+
return c.json({
|
|
575
|
+
error: "validation_error",
|
|
576
|
+
message: "'observations' must be an array",
|
|
577
|
+
expectedShape: BATCH_EXPECTED_SHAPE,
|
|
578
|
+
example: BATCH_EXAMPLE,
|
|
579
|
+
hint: "Wrap your observation objects in an 'observations' array — POST {\"observations\":[…]}",
|
|
580
|
+
}, 400);
|
|
581
|
+
}
|
|
582
|
+
if (envelope.observations.length === 0) {
|
|
583
|
+
// Empty batch is a documented no-op so the pre-pass can emit a
|
|
584
|
+
// zero-event window without a 400 stutter.
|
|
585
|
+
return c.json({
|
|
586
|
+
results: [],
|
|
587
|
+
fetched: 0,
|
|
588
|
+
posted: 0,
|
|
589
|
+
duplicates: 0,
|
|
590
|
+
errors: 0,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
if (envelope.observations.length > BATCH_MAX_OBSERVATIONS) {
|
|
594
|
+
return c.json({
|
|
595
|
+
error: "batch_too_large",
|
|
596
|
+
message: `Batch size ${envelope.observations.length} exceeds maximum ${BATCH_MAX_OBSERVATIONS}`,
|
|
597
|
+
maxItems: BATCH_MAX_OBSERVATIONS,
|
|
598
|
+
hint: "Split the batch into chunks of at most 200 items.",
|
|
599
|
+
}, 400);
|
|
600
|
+
}
|
|
601
|
+
const results = [];
|
|
602
|
+
let posted = 0;
|
|
603
|
+
let duplicates = 0;
|
|
604
|
+
let errorCount = 0;
|
|
605
|
+
// Single explicit transaction wraps the whole batch — better-sqlite3
|
|
606
|
+
// transactions amortise the per-statement overhead to ~30-100x for
|
|
607
|
+
// bulk inserts, which is the primary perf win this endpoint offers
|
|
608
|
+
// beyond bypassing the bashCurlHook one-curl cap. The flip-lock check
|
|
609
|
+
// stays per-item so a mixed-integration batch (defensive — the
|
|
610
|
+
// pre-pass scopes each sub-session to one integration, but the
|
|
611
|
+
// endpoint must hold under any caller) does not block on the first
|
|
612
|
+
// unrelated lock.
|
|
613
|
+
const writeBatch = db.transaction((items) => {
|
|
614
|
+
for (let i = 0; i < items.length; i++) {
|
|
615
|
+
const validated = validateBatchItem(items[i], i);
|
|
616
|
+
if (!validated.ok) {
|
|
617
|
+
results.push(validated.result);
|
|
618
|
+
errorCount += 1;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const lockedKey = inferIntegrationKeyFromSource(validated.source);
|
|
622
|
+
if (lockedKey) {
|
|
623
|
+
const lock = readIntegrationFlipLock(db, lockedKey);
|
|
624
|
+
if (lock) {
|
|
625
|
+
logger.warn({ source: validated.source, ref: validated.ref, lockedKey, lock }, "Batch observation write rejected — integration flip lock held");
|
|
626
|
+
results.push({
|
|
627
|
+
index: i,
|
|
628
|
+
status: "flip_locked",
|
|
629
|
+
source: validated.source,
|
|
630
|
+
ref: validated.ref,
|
|
631
|
+
error: `Integration '${lockedKey}' flip in progress`,
|
|
632
|
+
});
|
|
633
|
+
errorCount += 1;
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const result = recordObservation(db, {
|
|
638
|
+
source: validated.source,
|
|
639
|
+
ref: validated.ref,
|
|
640
|
+
changeType: validated.changeType,
|
|
641
|
+
actor: validated.actor,
|
|
642
|
+
payload: validated.payload,
|
|
643
|
+
});
|
|
644
|
+
if (result.action === "duplicate") {
|
|
645
|
+
duplicates += 1;
|
|
646
|
+
results.push({
|
|
647
|
+
index: i,
|
|
648
|
+
status: "duplicate",
|
|
649
|
+
source: validated.source,
|
|
650
|
+
ref: validated.ref,
|
|
651
|
+
contentHash: result.contentHash,
|
|
652
|
+
id: result.id,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
posted += 1;
|
|
657
|
+
results.push({
|
|
658
|
+
index: i,
|
|
659
|
+
status: result.action,
|
|
660
|
+
source: validated.source,
|
|
661
|
+
ref: validated.ref,
|
|
662
|
+
contentHash: result.contentHash,
|
|
663
|
+
id: result.id,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
writeBatch(envelope.observations);
|
|
669
|
+
logger.info({
|
|
670
|
+
count: envelope.observations.length,
|
|
671
|
+
posted,
|
|
672
|
+
duplicates,
|
|
673
|
+
errors: errorCount,
|
|
674
|
+
}, "Observations batch recorded via API");
|
|
675
|
+
return c.json({
|
|
676
|
+
results,
|
|
677
|
+
fetched: envelope.observations.length,
|
|
678
|
+
posted,
|
|
679
|
+
duplicates,
|
|
680
|
+
errors: errorCount,
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
/**
|
|
684
|
+
* Field-level validation contract for `POST /observations/consume`.
|
|
685
|
+
*
|
|
686
|
+
* Production telemetry (2026-05) showed a single Stage-3 hourly_check
|
|
687
|
+
* burning $0.58 / 25 turns retrying this endpoint with shape variants
|
|
688
|
+
* (`correlation_id` snake_case, stringified ids, the angle-bracket
|
|
689
|
+
* placeholder copied verbatim, per-id paths, etc.). The legacy
|
|
690
|
+
* `{ error: "validation_error" }` response gave the agent zero signal
|
|
691
|
+
* about which field was wrong, so it would mutate a random field and
|
|
692
|
+
* retry. Returning the full schema + the specific issue + a one-line
|
|
693
|
+
* hint lets the agent self-correct on the next turn instead of the
|
|
694
|
+
* eighth.
|
|
695
|
+
*/
|
|
696
|
+
const CONSUME_EXPECTED_SHAPE = '{"ids": number[], "correlationId": string}';
|
|
697
|
+
const CONSUME_EXAMPLE = '{"ids":[14,17],"correlationId":"hourly-2026-04-23T15:00:00Z-7af3"}';
|
|
299
698
|
app.post("/observations/consume", async (c) => {
|
|
300
699
|
const parsedBody = await readJsonBody(c);
|
|
301
700
|
if (!parsedBody.ok)
|
|
302
701
|
return parsedBody.response;
|
|
303
702
|
const body = parsedBody.body;
|
|
304
|
-
if (!body ||
|
|
305
|
-
return c.json({
|
|
703
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
704
|
+
return c.json({
|
|
705
|
+
error: "validation_error",
|
|
706
|
+
message: "Body must be a JSON object",
|
|
707
|
+
expectedShape: CONSUME_EXPECTED_SHAPE,
|
|
708
|
+
example: CONSUME_EXAMPLE,
|
|
709
|
+
}, 400);
|
|
710
|
+
}
|
|
711
|
+
const issues = [];
|
|
712
|
+
if (body.correlation_id !== undefined &&
|
|
713
|
+
body.correlationId === undefined) {
|
|
714
|
+
issues.push({
|
|
715
|
+
field: "correlationId",
|
|
716
|
+
expected: "string (camelCase)",
|
|
717
|
+
got: "received 'correlation_id' (snake_case) instead",
|
|
718
|
+
hint: "Rename the field to camelCase 'correlationId' — value is the verbatim id from the <event_correlation_id> tag in your prompt context",
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
else if (typeof body.correlationId !== "string") {
|
|
722
|
+
issues.push({
|
|
723
|
+
field: "correlationId",
|
|
724
|
+
expected: "string",
|
|
725
|
+
got: body.correlationId === undefined
|
|
726
|
+
? "missing"
|
|
727
|
+
: typeof body.correlationId,
|
|
728
|
+
hint: "Copy verbatim from <event_correlation_id>…</event_correlation_id> in your prompt context",
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
else if (body.correlationId.trim().length === 0) {
|
|
732
|
+
issues.push({
|
|
733
|
+
field: "correlationId",
|
|
734
|
+
expected: "non-empty string",
|
|
735
|
+
got: "empty string",
|
|
736
|
+
hint: "Copy verbatim from <event_correlation_id>…</event_correlation_id> in your prompt context",
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
else if (body.correlationId.startsWith("<") &&
|
|
740
|
+
body.correlationId.endsWith(">")) {
|
|
741
|
+
issues.push({
|
|
742
|
+
field: "correlationId",
|
|
743
|
+
expected: "verbatim correlation id",
|
|
744
|
+
got: `placeholder text '${body.correlationId}'`,
|
|
745
|
+
hint: "Use the real id from <event_correlation_id>…</event_correlation_id>, not the angle-bracket placeholder",
|
|
746
|
+
});
|
|
306
747
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
748
|
+
if (!Array.isArray(body.ids)) {
|
|
749
|
+
issues.push({
|
|
750
|
+
field: "ids",
|
|
751
|
+
expected: "number[]",
|
|
752
|
+
got: body.ids === undefined ? "missing" : typeof body.ids,
|
|
753
|
+
hint: "Array of integer observation row ids — e.g. [14, 17]",
|
|
754
|
+
});
|
|
310
755
|
}
|
|
311
|
-
|
|
312
|
-
|
|
756
|
+
else if (body.ids.length > 0) {
|
|
757
|
+
// Empty array is a documented no-op (preserves the legacy
|
|
758
|
+
// contract: `consumeObservations` returns `{consumed:0,notFound:[]}`
|
|
759
|
+
// for an empty list). Only validate element shape when the array
|
|
760
|
+
// actually has content.
|
|
761
|
+
const stringIds = body.ids.filter((id) => typeof id === "string");
|
|
762
|
+
if (stringIds.length > 0) {
|
|
763
|
+
issues.push({
|
|
764
|
+
field: "ids",
|
|
765
|
+
expected: "number[]",
|
|
766
|
+
got: `array contains strings (e.g. ${JSON.stringify(stringIds.slice(0, 3))})`,
|
|
767
|
+
hint: 'Use integers, not strings — [14, 17] not ["14", "17"]',
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
const nonInt = body.ids.find((id) => typeof id !== "number" || !Number.isInteger(id));
|
|
772
|
+
if (nonInt !== undefined) {
|
|
773
|
+
issues.push({
|
|
774
|
+
field: "ids",
|
|
775
|
+
expected: "number[] (integers)",
|
|
776
|
+
got: `array contains non-integer value ${JSON.stringify(nonInt)}`,
|
|
777
|
+
hint: "ids must be integer observation row ids returned by GET /api/observations",
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (issues.length > 0) {
|
|
783
|
+
return c.json({
|
|
784
|
+
error: "validation_error",
|
|
785
|
+
message: "Request body failed schema validation",
|
|
786
|
+
expectedShape: CONSUME_EXPECTED_SHAPE,
|
|
787
|
+
example: CONSUME_EXAMPLE,
|
|
788
|
+
issues,
|
|
789
|
+
}, 400);
|
|
790
|
+
}
|
|
791
|
+
const ids = body.ids;
|
|
792
|
+
const correlationId = body.correlationId;
|
|
793
|
+
const result = consumeObservations(db, ids, correlationId);
|
|
794
|
+
logger.info({ consumed: result.consumed, correlationId }, "Observations consumed");
|
|
313
795
|
return c.json(result);
|
|
314
796
|
});
|
|
797
|
+
/**
|
|
798
|
+
* Helpful 405 for the per-id consume shape the agent has reached for in
|
|
799
|
+
* production (`POST /api/observations/:id/consume`). Without an explicit
|
|
800
|
+
* handler this path falls through to Hono's 404 with body
|
|
801
|
+
* `"404 Not Found"`, which gives the agent nothing to act on. Returning
|
|
802
|
+
* a 405 with the canonical bulk-endpoint hint pulls the agent back onto
|
|
803
|
+
* the correct shape on the next turn.
|
|
804
|
+
*/
|
|
805
|
+
app.all("/observations/:id/consume", (c) => {
|
|
806
|
+
const id = c.req.param("id");
|
|
807
|
+
return c.json({
|
|
808
|
+
error: "use_bulk_endpoint",
|
|
809
|
+
message: "Per-id consume is not supported. Use the bulk endpoint with a single-element ids array.",
|
|
810
|
+
expectedShape: CONSUME_EXPECTED_SHAPE,
|
|
811
|
+
example: CONSUME_EXAMPLE,
|
|
812
|
+
hint: `POST /api/observations/consume with body {"ids":[${id}],"correlationId":"<copy from <event_correlation_id>>"}`,
|
|
813
|
+
}, 405);
|
|
814
|
+
});
|
|
815
|
+
/**
|
|
816
|
+
* Helpful 405 for `GET /api/observations/consume`. The bulk consume is
|
|
817
|
+
* POST-only — without this handler the request 404s with no actionable
|
|
818
|
+
* detail, and the agent's recovery loop produced 8x retries in one
|
|
819
|
+
* routine.hourly_check session.
|
|
820
|
+
*/
|
|
821
|
+
app.get("/observations/consume", (c) => c.json({
|
|
822
|
+
error: "method_not_allowed",
|
|
823
|
+
message: "GET is not supported on /api/observations/consume — use POST.",
|
|
824
|
+
expectedShape: CONSUME_EXPECTED_SHAPE,
|
|
825
|
+
example: CONSUME_EXAMPLE,
|
|
826
|
+
hint: `POST /api/observations/consume with body ${CONSUME_EXAMPLE}`,
|
|
827
|
+
}, 405, { Allow: "POST" }));
|
|
315
828
|
app.get("/observations/stats", (c) => {
|
|
316
829
|
const stats = getObservationStats(db);
|
|
317
830
|
// cost-reduction-structural §A telemetry — surface summarizer health
|