reflectt-node 0.1.8 → 0.1.12
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/defaults/TEAM-ROLES.yaml +317 -5
- package/dist/agent-config.d.ts +51 -0
- package/dist/agent-config.d.ts.map +1 -0
- package/dist/agent-config.js +129 -0
- package/dist/agent-config.js.map +1 -0
- package/dist/agent-config.test.d.ts +2 -0
- package/dist/agent-config.test.d.ts.map +1 -0
- package/dist/agent-config.test.js +91 -0
- package/dist/agent-config.test.js.map +1 -0
- package/dist/agent-memories.d.ts +58 -0
- package/dist/agent-memories.d.ts.map +1 -0
- package/dist/agent-memories.js +168 -0
- package/dist/agent-memories.js.map +1 -0
- package/dist/agent-memories.test.d.ts +2 -0
- package/dist/agent-memories.test.d.ts.map +1 -0
- package/dist/agent-memories.test.js +327 -0
- package/dist/agent-memories.test.js.map +1 -0
- package/dist/agent-messaging.d.ts +50 -0
- package/dist/agent-messaging.d.ts.map +1 -0
- package/dist/agent-messaging.js +103 -0
- package/dist/agent-messaging.js.map +1 -0
- package/dist/agent-messaging.test.d.ts +2 -0
- package/dist/agent-messaging.test.d.ts.map +1 -0
- package/dist/agent-messaging.test.js +105 -0
- package/dist/agent-messaging.test.js.map +1 -0
- package/dist/agent-runs.d.ts +158 -0
- package/dist/agent-runs.d.ts.map +1 -0
- package/dist/agent-runs.js +514 -0
- package/dist/agent-runs.js.map +1 -0
- package/dist/agent-runs.test.d.ts +2 -0
- package/dist/agent-runs.test.d.ts.map +1 -0
- package/dist/agent-runs.test.js +386 -0
- package/dist/agent-runs.test.js.map +1 -0
- package/dist/approval-queue.test.d.ts +2 -0
- package/dist/approval-queue.test.d.ts.map +1 -0
- package/dist/approval-queue.test.js +118 -0
- package/dist/approval-queue.test.js.map +1 -0
- package/dist/artifact-store.d.ts +55 -0
- package/dist/artifact-store.d.ts.map +1 -0
- package/dist/artifact-store.js +128 -0
- package/dist/artifact-store.js.map +1 -0
- package/dist/artifact-store.test.d.ts +2 -0
- package/dist/artifact-store.test.d.ts.map +1 -0
- package/dist/artifact-store.test.js +119 -0
- package/dist/artifact-store.test.js.map +1 -0
- package/dist/boardHealthWorker.d.ts +28 -0
- package/dist/boardHealthWorker.d.ts.map +1 -1
- package/dist/boardHealthWorker.js +33 -1
- package/dist/boardHealthWorker.js.map +1 -1
- package/dist/canvas-input.test.d.ts +2 -0
- package/dist/canvas-input.test.d.ts.map +1 -0
- package/dist/canvas-input.test.js +96 -0
- package/dist/canvas-input.test.js.map +1 -0
- package/dist/canvas-render.test.d.ts +2 -0
- package/dist/canvas-render.test.d.ts.map +1 -0
- package/dist/canvas-render.test.js +95 -0
- package/dist/canvas-render.test.js.map +1 -0
- package/dist/capabilities/browser.d.ts +75 -0
- package/dist/capabilities/browser.d.ts.map +1 -0
- package/dist/capabilities/browser.js +172 -0
- package/dist/capabilities/browser.js.map +1 -0
- package/dist/channels.d.ts +1 -1
- package/dist/cli.js +4 -2
- package/dist/cli.js.map +1 -1
- package/dist/cloud.d.ts +2 -0
- package/dist/cloud.d.ts.map +1 -1
- package/dist/cloud.js +135 -3
- package/dist/cloud.js.map +1 -1
- package/dist/cost-enforcement.d.ts +38 -0
- package/dist/cost-enforcement.d.ts.map +1 -0
- package/dist/cost-enforcement.js +84 -0
- package/dist/cost-enforcement.js.map +1 -0
- package/dist/dashboard.d.ts.map +1 -1
- package/dist/dashboard.js +8 -0
- package/dist/dashboard.js.map +1 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +131 -0
- package/dist/db.js.map +1 -1
- package/dist/e2e-loop-proof.test.d.ts +2 -0
- package/dist/e2e-loop-proof.test.d.ts.map +1 -0
- package/dist/e2e-loop-proof.test.js +104 -0
- package/dist/e2e-loop-proof.test.js.map +1 -0
- package/dist/email-sms-send.test.d.ts +2 -0
- package/dist/email-sms-send.test.d.ts.map +1 -0
- package/dist/email-sms-send.test.js +96 -0
- package/dist/email-sms-send.test.js.map +1 -0
- package/dist/events.d.ts +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +2 -0
- package/dist/events.js.map +1 -1
- package/dist/fingerprint.d.ts.map +1 -1
- package/dist/fingerprint.js +5 -10
- package/dist/fingerprint.js.map +1 -1
- package/dist/github-webhook-chat.d.ts +75 -0
- package/dist/github-webhook-chat.d.ts.map +1 -0
- package/dist/github-webhook-chat.js +108 -0
- package/dist/github-webhook-chat.js.map +1 -0
- package/dist/handoff-state.test.d.ts +2 -0
- package/dist/handoff-state.test.d.ts.map +1 -0
- package/dist/handoff-state.test.js +102 -0
- package/dist/handoff-state.test.js.map +1 -0
- package/dist/health.d.ts +9 -0
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +18 -0
- package/dist/health.js.map +1 -1
- package/dist/host-error-correlation.d.ts +65 -0
- package/dist/host-error-correlation.d.ts.map +1 -0
- package/dist/host-error-correlation.js +123 -0
- package/dist/host-error-correlation.js.map +1 -0
- package/dist/index.js +39 -10
- package/dist/index.js.map +1 -1
- package/dist/notificationDedupeGuard.d.ts +4 -0
- package/dist/notificationDedupeGuard.d.ts.map +1 -1
- package/dist/notificationDedupeGuard.js +8 -4
- package/dist/notificationDedupeGuard.js.map +1 -1
- package/dist/presence.d.ts +37 -5
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +127 -16
- package/dist/presence.js.map +1 -1
- package/dist/review-sla.d.ts +9 -0
- package/dist/review-sla.d.ts.map +1 -0
- package/dist/review-sla.js +51 -0
- package/dist/review-sla.js.map +1 -0
- package/dist/routing-enforcement.test.d.ts +2 -0
- package/dist/routing-enforcement.test.d.ts.map +1 -0
- package/dist/routing-enforcement.test.js +86 -0
- package/dist/routing-enforcement.test.js.map +1 -0
- package/dist/run-retention.test.d.ts +2 -0
- package/dist/run-retention.test.d.ts.map +1 -0
- package/dist/run-retention.test.js +57 -0
- package/dist/run-retention.test.js.map +1 -0
- package/dist/run-stream.test.d.ts +2 -0
- package/dist/run-stream.test.d.ts.map +1 -0
- package/dist/run-stream.test.js +70 -0
- package/dist/run-stream.test.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1301 -75
- package/dist/server.js.map +1 -1
- package/dist/tasks.d.ts.map +1 -1
- package/dist/tasks.js +45 -0
- package/dist/tasks.js.map +1 -1
- package/dist/todoHoardingGuard.d.ts +17 -0
- package/dist/todoHoardingGuard.d.ts.map +1 -1
- package/dist/todoHoardingGuard.js +25 -2
- package/dist/todoHoardingGuard.js.map +1 -1
- package/dist/webhook-storage.d.ts +50 -0
- package/dist/webhook-storage.d.ts.map +1 -0
- package/dist/webhook-storage.js +102 -0
- package/dist/webhook-storage.js.map +1 -0
- package/dist/webhook-storage.test.d.ts +2 -0
- package/dist/webhook-storage.test.d.ts.map +1 -0
- package/dist/webhook-storage.test.js +86 -0
- package/dist/webhook-storage.test.js.map +1 -0
- package/dist/workflow-templates.d.ts +44 -0
- package/dist/workflow-templates.d.ts.map +1 -0
- package/dist/workflow-templates.js +154 -0
- package/dist/workflow-templates.js.map +1 -0
- package/dist/workflow-templates.test.d.ts +2 -0
- package/dist/workflow-templates.test.d.ts.map +1 -0
- package/dist/workflow-templates.test.js +76 -0
- package/dist/workflow-templates.test.js.map +1 -0
- package/package.json +3 -1
- package/public/dashboard.js +76 -1
- package/public/design-tokens-platform.md +118 -0
- package/public/design-tokens.css +195 -0
- package/public/docs.md +131 -2
- package/public/presence-loop-demo.html +473 -0
package/dist/server.js
CHANGED
|
@@ -69,9 +69,9 @@ import { researchManager } from './research.js';
|
|
|
69
69
|
import { wsHeartbeat } from './ws-heartbeat.js';
|
|
70
70
|
import { getBuildInfo } from './buildInfo.js';
|
|
71
71
|
import { appendStoredLog, readStoredLogs, getStoredLogPath } from './logStore.js';
|
|
72
|
-
import { getAgentRoles, getAgentRolesSource, loadAgentRoles, startConfigWatch, suggestAssignee, suggestReviewer, checkWipCap, saveAgentRoles,
|
|
72
|
+
import { getAgentRoles, getAgentRolesSource, loadAgentRoles, startConfigWatch, suggestAssignee, suggestReviewer, checkWipCap, saveAgentRoles, getAgentRole, getAgentAliases, setAgentDisplayName, resolveAgentMention } from './assignment.js';
|
|
73
73
|
import { initTelemetry, trackRequest as trackTelemetryRequest, trackError as trackTelemetryError, trackTaskEvent, getSnapshot as getTelemetrySnapshot, getTelemetryConfig } from './telemetry.js';
|
|
74
|
-
import {
|
|
74
|
+
import { recordUsageBatch, getUsageSummary, getUsageByAgent, getUsageByModel, getUsageByTask, getDailySpendByModel, getAvgCostByLane, getAvgCostByAgent, setCap, listCaps, deleteCap, checkCaps, getRoutingSuggestions, estimateCost, ensureUsageTables } from './usage-tracking.js';
|
|
75
75
|
import { getTeamConfigHealth } from './team-config.js';
|
|
76
76
|
import { SecretVault } from './secrets.js';
|
|
77
77
|
import { initGitHubActorAuth, resolveGitHubTokenForActor } from './github-actor-auth.js';
|
|
@@ -81,6 +81,7 @@ import { createGitHubIdentityProvider } from './github-identity.js';
|
|
|
81
81
|
import { getProvisioningManager } from './provisioning.js';
|
|
82
82
|
import { getWebhookDeliveryManager } from './webhooks.js';
|
|
83
83
|
import { enrichWebhookPayload } from './github-webhook-attribution.js';
|
|
84
|
+
import { formatGitHubEvent } from './github-webhook-chat.js';
|
|
84
85
|
import { exportBundle, importBundle } from './portability.js';
|
|
85
86
|
import { getNotificationManager } from './notifications.js';
|
|
86
87
|
import { getConnectivityManager } from './connectivity.js';
|
|
@@ -275,6 +276,13 @@ function normalizeConfiguredModel(value) {
|
|
|
275
276
|
error: `Unknown model identifier "${raw}". Allowed aliases: ${Object.keys(MODEL_ALIASES).join(', ')} or provider/model format.`,
|
|
276
277
|
};
|
|
277
278
|
}
|
|
279
|
+
// ── Handoff state schema (max 3 columns per COO rule) ─────────────
|
|
280
|
+
const VALID_HANDOFF_DECISIONS = ['approved', 'rejected', 'needs_changes', 'escalated'];
|
|
281
|
+
const HandoffStateSchema = z.object({
|
|
282
|
+
reviewed_by: z.string().min(1),
|
|
283
|
+
decision: z.enum(VALID_HANDOFF_DECISIONS),
|
|
284
|
+
next_owner: z.string().min(1).optional(),
|
|
285
|
+
}).strict();
|
|
278
286
|
const UpdateTaskSchema = z.object({
|
|
279
287
|
title: z.string().min(1).optional(),
|
|
280
288
|
description: z.string().optional(),
|
|
@@ -3000,6 +3008,22 @@ export async function createServer() {
|
|
|
3000
3008
|
reply.code(500).send({ error: 'Failed to load UI kit page' });
|
|
3001
3009
|
}
|
|
3002
3010
|
});
|
|
3011
|
+
// Presence loop demo — live end-to-end ambient→run→approve→result→collapse
|
|
3012
|
+
app.get('/presence-loop', async (_request, reply) => {
|
|
3013
|
+
try {
|
|
3014
|
+
const { promises: fs } = await import('fs');
|
|
3015
|
+
const { join } = await import('path');
|
|
3016
|
+
const { fileURLToPath } = await import('url');
|
|
3017
|
+
const { dirname } = await import('path');
|
|
3018
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
3019
|
+
const __dirname = dirname(__filename);
|
|
3020
|
+
const html = await fs.readFile(join(__dirname, '..', 'public', 'presence-loop-demo.html'), 'utf-8');
|
|
3021
|
+
reply.type('text/html; charset=utf-8').send(html);
|
|
3022
|
+
}
|
|
3023
|
+
catch (err) {
|
|
3024
|
+
reply.code(500).send({ error: 'Failed to load presence loop demo' });
|
|
3025
|
+
}
|
|
3026
|
+
});
|
|
3003
3027
|
app.get('/docs', async (_request, reply) => {
|
|
3004
3028
|
try {
|
|
3005
3029
|
const { promises: fs } = await import('fs');
|
|
@@ -4506,6 +4530,46 @@ export async function createServer() {
|
|
|
4506
4530
|
matchType: resolved.matchType,
|
|
4507
4531
|
};
|
|
4508
4532
|
});
|
|
4533
|
+
// ── Task handoff state ─────────────────────────────────────────────
|
|
4534
|
+
app.get('/tasks/:id/handoff', async (request, reply) => {
|
|
4535
|
+
const resolved = taskManager.resolveTaskId(request.params.id);
|
|
4536
|
+
if (!resolved.task) {
|
|
4537
|
+
reply.code(404);
|
|
4538
|
+
return { error: 'Task not found' };
|
|
4539
|
+
}
|
|
4540
|
+
const meta = resolved.task.metadata;
|
|
4541
|
+
const handoff = meta?.handoff_state ?? null;
|
|
4542
|
+
return {
|
|
4543
|
+
taskId: resolved.resolvedId,
|
|
4544
|
+
status: resolved.task.status,
|
|
4545
|
+
handoff_state: handoff,
|
|
4546
|
+
};
|
|
4547
|
+
});
|
|
4548
|
+
app.put('/tasks/:id/handoff', async (request, reply) => {
|
|
4549
|
+
const resolved = taskManager.resolveTaskId(request.params.id);
|
|
4550
|
+
if (!resolved.task || !resolved.resolvedId) {
|
|
4551
|
+
reply.code(404);
|
|
4552
|
+
return { error: 'Task not found' };
|
|
4553
|
+
}
|
|
4554
|
+
const body = request.body;
|
|
4555
|
+
const result = HandoffStateSchema.safeParse(body);
|
|
4556
|
+
if (!result.success) {
|
|
4557
|
+
reply.code(422);
|
|
4558
|
+
return {
|
|
4559
|
+
error: `Invalid handoff_state: ${result.error.issues.map(i => i.message).join(', ')}`,
|
|
4560
|
+
hint: 'Required: reviewed_by (string), decision (approved|rejected|needs_changes|escalated). Optional: next_owner (string).',
|
|
4561
|
+
};
|
|
4562
|
+
}
|
|
4563
|
+
const existingMeta = (resolved.task.metadata || {});
|
|
4564
|
+
taskManager.updateTask(resolved.resolvedId, {
|
|
4565
|
+
metadata: { ...existingMeta, handoff_state: result.data },
|
|
4566
|
+
});
|
|
4567
|
+
return {
|
|
4568
|
+
success: true,
|
|
4569
|
+
taskId: resolved.resolvedId,
|
|
4570
|
+
handoff_state: result.data,
|
|
4571
|
+
};
|
|
4572
|
+
});
|
|
4509
4573
|
// Task artifact visibility — resolves artifact paths and checks accessibility
|
|
4510
4574
|
app.get('/tasks/:id/artifacts', async (request, reply) => {
|
|
4511
4575
|
const resolved = resolveTaskFromParam(request.params.id, reply);
|
|
@@ -5672,7 +5736,9 @@ export async function createServer() {
|
|
|
5672
5736
|
|| data.metadata?.skip_dedup === true
|
|
5673
5737
|
|| data.metadata?.is_test === true;
|
|
5674
5738
|
if (!skipDedup && data.assignee) {
|
|
5675
|
-
|
|
5739
|
+
// 60-second window targets gateway reconnect double-fire (typical gap: <10s)
|
|
5740
|
+
// without blocking legitimate same-title task creation later in the day.
|
|
5741
|
+
const TASK_DEDUP_WINDOW_MS = 60_000; // 60 seconds
|
|
5676
5742
|
const cutoff = Date.now() - TASK_DEDUP_WINDOW_MS;
|
|
5677
5743
|
const normalizedTitle = data.title.trim().toLowerCase();
|
|
5678
5744
|
const activeTasks = taskManager.listTasks({ includeTest: true }).filter(t => t.status !== 'done'
|
|
@@ -5680,16 +5746,15 @@ export async function createServer() {
|
|
|
5680
5746
|
&& t.createdAt >= cutoff
|
|
5681
5747
|
&& t.title.trim().toLowerCase() === normalizedTitle);
|
|
5682
5748
|
if (activeTasks.length > 0) {
|
|
5749
|
+
// Return 200 with the existing task (collapse, not reject).
|
|
5750
|
+
// Agents that create-on-reconnect receive a success response identical
|
|
5751
|
+
// to what they'd get from a new creation — no retry loop triggered.
|
|
5683
5752
|
const existing = activeTasks[0];
|
|
5684
|
-
reply.code(409);
|
|
5685
5753
|
return {
|
|
5686
|
-
success:
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
status:
|
|
5690
|
-
existing_id: existing.id,
|
|
5691
|
-
existing_status: existing.status,
|
|
5692
|
-
hint: `Task "${existing.title}" already exists for ${data.assignee} (${existing.id}, status: ${existing.status}). Created ${Math.round((Date.now() - existing.createdAt) / 60000)}m ago.`,
|
|
5754
|
+
success: true,
|
|
5755
|
+
task: existing,
|
|
5756
|
+
deduplicated: true,
|
|
5757
|
+
hint: `Duplicate suppressed — task "${existing.title}" already exists for ${data.assignee} (${existing.id}, status: ${existing.status}, created ${Math.round((Date.now() - existing.createdAt) / 1000)}s ago).`,
|
|
5693
5758
|
};
|
|
5694
5759
|
}
|
|
5695
5760
|
}
|
|
@@ -6082,6 +6147,14 @@ export async function createServer() {
|
|
|
6082
6147
|
const pruned = boardHealthWorker.pruneAuditLog(maxAgeDays);
|
|
6083
6148
|
return { success: true, pruned };
|
|
6084
6149
|
});
|
|
6150
|
+
app.post('/board-health/quiet-window', async () => {
|
|
6151
|
+
boardHealthWorker.resetQuietWindow();
|
|
6152
|
+
return {
|
|
6153
|
+
success: true,
|
|
6154
|
+
quietUntil: Date.now() + (boardHealthWorker.getStatus().config?.restartQuietWindowMs ?? 300_000),
|
|
6155
|
+
message: 'Quiet window reset — ready-queue alerts suppressed for restart window',
|
|
6156
|
+
};
|
|
6157
|
+
});
|
|
6085
6158
|
// ── Agent change feed ─────────────────────────────────────────────────
|
|
6086
6159
|
app.get('/feed/:agent', async (request) => {
|
|
6087
6160
|
const { agent } = request.params;
|
|
@@ -6366,6 +6439,22 @@ export async function createServer() {
|
|
|
6366
6439
|
mergedMeta.reopened_from = existing.status;
|
|
6367
6440
|
}
|
|
6368
6441
|
}
|
|
6442
|
+
// ── Handoff state validation ──
|
|
6443
|
+
if (mergedMeta.handoff_state && typeof mergedMeta.handoff_state === 'object') {
|
|
6444
|
+
const handoffResult = HandoffStateSchema.safeParse(mergedMeta.handoff_state);
|
|
6445
|
+
if (!handoffResult.success) {
|
|
6446
|
+
reply.code(422);
|
|
6447
|
+
return {
|
|
6448
|
+
success: false,
|
|
6449
|
+
error: `Invalid handoff_state: ${handoffResult.error.issues.map(i => i.message).join(', ')}`,
|
|
6450
|
+
code: 'INVALID_HANDOFF_STATE',
|
|
6451
|
+
hint: 'handoff_state must have: reviewed_by (string), decision (approved|rejected|needs_changes|escalated), optional next_owner (string). Max 3 fields per COO rule.',
|
|
6452
|
+
gate: 'handoff_state',
|
|
6453
|
+
};
|
|
6454
|
+
}
|
|
6455
|
+
// Stamp validated handoff
|
|
6456
|
+
mergedMeta.handoff_state = handoffResult.data;
|
|
6457
|
+
}
|
|
6369
6458
|
// ── Cancel reason gate: require cancel_reason when transitioning to cancelled ──
|
|
6370
6459
|
if (parsed.status === 'cancelled') {
|
|
6371
6460
|
const meta = (incomingMeta ?? {});
|
|
@@ -6818,20 +6907,24 @@ export async function createServer() {
|
|
|
6818
6907
|
if (task.assignee) {
|
|
6819
6908
|
if (parsed.status === 'done') {
|
|
6820
6909
|
presenceManager.recordActivity(task.assignee, 'task_completed');
|
|
6821
|
-
presenceManager.updatePresence(task.assignee, 'working');
|
|
6910
|
+
presenceManager.updatePresence(task.assignee, 'working', null);
|
|
6822
6911
|
trackTaskEvent('completed');
|
|
6823
6912
|
}
|
|
6824
6913
|
else if (parsed.status === 'doing') {
|
|
6825
|
-
presenceManager.updatePresence(task.assignee, 'working');
|
|
6914
|
+
presenceManager.updatePresence(task.assignee, 'working', task.id);
|
|
6826
6915
|
}
|
|
6827
6916
|
else if (parsed.status === 'blocked') {
|
|
6828
|
-
presenceManager.updatePresence(task.assignee, 'blocked');
|
|
6917
|
+
presenceManager.updatePresence(task.assignee, 'blocked', task.id);
|
|
6829
6918
|
}
|
|
6830
6919
|
else if (parsed.status === 'validating') {
|
|
6831
|
-
presenceManager.updatePresence(task.assignee, 'reviewing');
|
|
6920
|
+
presenceManager.updatePresence(task.assignee, 'reviewing', task.id);
|
|
6832
6921
|
}
|
|
6833
6922
|
}
|
|
6834
6923
|
// ── Reviewer notification: @mention reviewer when task enters validating ──
|
|
6924
|
+
// NOTE: A dedup_key is set here so the inline chat dedup guard suppresses
|
|
6925
|
+
// any duplicate reviewRequested send that may arrive via the statusNotifTargets
|
|
6926
|
+
// loop below for the same task+transition. Without it, two messages fire for
|
|
6927
|
+
// every todo→validating transition (this direct send + the loop send).
|
|
6835
6928
|
if (parsed.status === 'validating' && existing.status !== 'validating' && existing.reviewer) {
|
|
6836
6929
|
const taskMeta = task.metadata;
|
|
6837
6930
|
const prUrl = taskMeta?.review_handoff?.pr_url
|
|
@@ -6848,6 +6941,7 @@ export async function createServer() {
|
|
|
6848
6941
|
taskId: task.id,
|
|
6849
6942
|
reviewer: existing.reviewer,
|
|
6850
6943
|
prUrl: prUrl || undefined,
|
|
6944
|
+
dedup_key: `review-requested:${task.id}:${task.updatedAt}`,
|
|
6851
6945
|
},
|
|
6852
6946
|
}).catch(() => { }); // Non-blocking
|
|
6853
6947
|
}
|
|
@@ -6971,13 +7065,17 @@ export async function createServer() {
|
|
|
6971
7065
|
// Dedupe guard: prevent stale/out-of-order notification events
|
|
6972
7066
|
const { shouldEmitNotification } = await import('./notificationDedupeGuard.js');
|
|
6973
7067
|
for (const target of statusNotifTargets) {
|
|
6974
|
-
// Check dedupe guard before emitting
|
|
7068
|
+
// Check dedupe guard before emitting.
|
|
7069
|
+
// Pass targetAgent so each recipient gets an independent cursor — prevents
|
|
7070
|
+
// the first recipient's cursor update from suppressing later recipients for
|
|
7071
|
+
// the same event (e.g. assignee + reviewer both getting taskCompleted on 'done').
|
|
6975
7072
|
const dedupeCheck = shouldEmitNotification({
|
|
6976
7073
|
taskId: task.id,
|
|
6977
7074
|
eventUpdatedAt: task.updatedAt,
|
|
6978
7075
|
eventStatus: parsed.status,
|
|
6979
7076
|
currentTaskStatus: task.status,
|
|
6980
7077
|
currentTaskUpdatedAt: task.updatedAt,
|
|
7078
|
+
targetAgent: target.agent,
|
|
6981
7079
|
});
|
|
6982
7080
|
if (!dedupeCheck.emit) {
|
|
6983
7081
|
console.log(`[NotifDedupe] Suppressed: ${dedupeCheck.reason}`);
|
|
@@ -6990,7 +7088,13 @@ export async function createServer() {
|
|
|
6990
7088
|
message: `Task ${task.id} → ${parsed.status}`,
|
|
6991
7089
|
});
|
|
6992
7090
|
if (routing.shouldNotify) {
|
|
6993
|
-
// Route through inbox/chat based on delivery method preference
|
|
7091
|
+
// Route through inbox/chat based on delivery method preference.
|
|
7092
|
+
// For reviewRequested, set a dedup_key matching the direct send above so the
|
|
7093
|
+
// inline chat dedup suppresses this copy (the direct send fires first with a
|
|
7094
|
+
// richer payload including PR URL and `to:` routing).
|
|
7095
|
+
const dedupKey = target.type === 'reviewRequested'
|
|
7096
|
+
? `review-requested:${task.id}:${task.updatedAt}`
|
|
7097
|
+
: undefined;
|
|
6994
7098
|
chatManager.sendMessage({
|
|
6995
7099
|
from: 'system',
|
|
6996
7100
|
content: `@${target.agent} [${target.type}:${task.id}] ${task.title} → ${parsed.status}`,
|
|
@@ -7001,6 +7105,7 @@ export async function createServer() {
|
|
|
7001
7105
|
status: parsed.status,
|
|
7002
7106
|
updatedAt: task.updatedAt,
|
|
7003
7107
|
deliveryMethod: routing.deliveryMethod,
|
|
7108
|
+
...(dedupKey ? { dedup_key: dedupKey } : {}),
|
|
7004
7109
|
},
|
|
7005
7110
|
}).catch(() => { }); // Non-blocking
|
|
7006
7111
|
}
|
|
@@ -7071,6 +7176,27 @@ export async function createServer() {
|
|
|
7071
7176
|
};
|
|
7072
7177
|
app.get('/agents', async () => buildRoleRegistryPayload());
|
|
7073
7178
|
app.get('/agents/roles', async () => buildRoleRegistryPayload());
|
|
7179
|
+
// Host-native identity resolution — resolves agent by name, alias, or display name
|
|
7180
|
+
// without requiring the OpenClaw gateway. Merges YAML roles + agent_config table.
|
|
7181
|
+
app.get('/agents/:name/identity', async (request) => {
|
|
7182
|
+
const { name } = request.params;
|
|
7183
|
+
const resolved = resolveAgentMention(name);
|
|
7184
|
+
const role = resolved ? getAgentRole(resolved) : getAgentRole(name);
|
|
7185
|
+
if (!role) {
|
|
7186
|
+
return { found: false, query: name, hint: 'Agent not found in YAML roles or config' };
|
|
7187
|
+
}
|
|
7188
|
+
return {
|
|
7189
|
+
found: true,
|
|
7190
|
+
agentId: role.name,
|
|
7191
|
+
displayName: role.displayName ?? role.name,
|
|
7192
|
+
role: role.role,
|
|
7193
|
+
description: role.description ?? null,
|
|
7194
|
+
aliases: role.aliases ?? [],
|
|
7195
|
+
affinityTags: role.affinityTags ?? [],
|
|
7196
|
+
wipCap: role.wipCap,
|
|
7197
|
+
source: 'yaml',
|
|
7198
|
+
};
|
|
7199
|
+
});
|
|
7074
7200
|
// Team-scoped alias for assignment-engine consumers
|
|
7075
7201
|
app.get('/team/roles', async () => {
|
|
7076
7202
|
const payload = buildRoleRegistryPayload();
|
|
@@ -7494,55 +7620,7 @@ export async function createServer() {
|
|
|
7494
7620
|
};
|
|
7495
7621
|
});
|
|
7496
7622
|
// ── Approval Queue ──────────────────────────────────────────────────
|
|
7497
|
-
|
|
7498
|
-
// Tasks in 'todo' that were auto-assigned (have suggestedAgent in metadata) or need assignment review
|
|
7499
|
-
const allTasks = taskManager.listTasks({});
|
|
7500
|
-
const todoTasks = allTasks.filter(t => t.status === 'todo');
|
|
7501
|
-
const items = todoTasks.map(t => {
|
|
7502
|
-
const task = t;
|
|
7503
|
-
const meta = task.metadata || {};
|
|
7504
|
-
const title = task.title || '';
|
|
7505
|
-
const tags = Array.isArray(task.tags) ? task.tags : [];
|
|
7506
|
-
const doneCriteria = Array.isArray(task.done_criteria) ? task.done_criteria : [];
|
|
7507
|
-
// Score all agents for this task
|
|
7508
|
-
const roles = getAgentRoles();
|
|
7509
|
-
const agentOptions = roles.map(agent => {
|
|
7510
|
-
const wipCount = allTasks.filter(at => at.status === 'doing' && (at.assignee || '').toLowerCase() === agent.name).length;
|
|
7511
|
-
const s = scoreAssignment(agent, { title, tags, done_criteria: doneCriteria }, wipCount);
|
|
7512
|
-
return {
|
|
7513
|
-
agentId: agent.name,
|
|
7514
|
-
name: agent.name,
|
|
7515
|
-
confidenceScore: Math.max(0, Math.min(1, s.score)),
|
|
7516
|
-
affinityTags: agent.affinityTags,
|
|
7517
|
-
};
|
|
7518
|
-
}).sort((a, b) => b.confidenceScore - a.confidenceScore);
|
|
7519
|
-
const topAgent = agentOptions[0];
|
|
7520
|
-
const suggestedAgent = task.assignee || topAgent?.agentId || null;
|
|
7521
|
-
const confidenceScore = topAgent?.confidenceScore || 0;
|
|
7522
|
-
const confidenceReason = topAgent && topAgent.confidenceScore > 0
|
|
7523
|
-
? `${topAgent.name}: affinity match on ${topAgent.affinityTags.slice(0, 3).join(', ')}`
|
|
7524
|
-
: 'No strong affinity match';
|
|
7525
|
-
return {
|
|
7526
|
-
taskId: task.id,
|
|
7527
|
-
title,
|
|
7528
|
-
description: task.description || '',
|
|
7529
|
-
priority: task.priority || 'P3',
|
|
7530
|
-
suggestedAgent,
|
|
7531
|
-
confidenceScore,
|
|
7532
|
-
confidenceReason,
|
|
7533
|
-
agentOptions,
|
|
7534
|
-
status: 'pending',
|
|
7535
|
-
};
|
|
7536
|
-
});
|
|
7537
|
-
const highConfidence = items.filter(i => i.confidenceScore >= 0.85);
|
|
7538
|
-
const needsReview = items.filter(i => i.confidenceScore < 0.85);
|
|
7539
|
-
return {
|
|
7540
|
-
items: [...highConfidence, ...needsReview],
|
|
7541
|
-
total: items.length,
|
|
7542
|
-
highConfidenceCount: highConfidence.length,
|
|
7543
|
-
needsReviewCount: needsReview.length,
|
|
7544
|
-
};
|
|
7545
|
-
});
|
|
7623
|
+
// Note: GET /approval-queue is defined below near /approval-queue/:approvalId/decide
|
|
7546
7624
|
app.post('/approval-queue/:taskId/approve', async (request, reply) => {
|
|
7547
7625
|
const body = request.body;
|
|
7548
7626
|
const taskId = request.params.taskId;
|
|
@@ -7698,6 +7776,88 @@ export async function createServer() {
|
|
|
7698
7776
|
warnings: result.warnings,
|
|
7699
7777
|
};
|
|
7700
7778
|
});
|
|
7779
|
+
// ── Presence Layer canvas state ─────────────────────────────────────
|
|
7780
|
+
// Agent emits canvas_render state transitions for the Presence Layer.
|
|
7781
|
+
// Deterministic event types. No "AI can emit anything" protocol.
|
|
7782
|
+
const CANVAS_STATES = ['floor', 'listening', 'thinking', 'rendering', 'ambient', 'decision', 'urgent', 'handoff'];
|
|
7783
|
+
const SENSOR_VALUES = [null, 'mic', 'camera', 'mic+camera'];
|
|
7784
|
+
const CanvasRenderSchema = z.object({
|
|
7785
|
+
state: z.enum(CANVAS_STATES),
|
|
7786
|
+
sensors: z.enum(['mic', 'camera', 'mic+camera']).nullable().default(null),
|
|
7787
|
+
agentId: z.string().min(1),
|
|
7788
|
+
payload: z.object({
|
|
7789
|
+
text: z.string().optional(),
|
|
7790
|
+
media: z.unknown().optional(),
|
|
7791
|
+
decision: z.object({
|
|
7792
|
+
question: z.string(),
|
|
7793
|
+
context: z.string().optional(),
|
|
7794
|
+
decisionId: z.string(),
|
|
7795
|
+
expiresAt: z.number().optional(),
|
|
7796
|
+
autoAction: z.string().optional(),
|
|
7797
|
+
}).optional(),
|
|
7798
|
+
agents: z.array(z.object({
|
|
7799
|
+
name: z.string(),
|
|
7800
|
+
state: z.string(),
|
|
7801
|
+
task: z.string().optional(),
|
|
7802
|
+
})).optional(),
|
|
7803
|
+
summary: z.object({
|
|
7804
|
+
headline: z.string(),
|
|
7805
|
+
items: z.array(z.string()).optional(),
|
|
7806
|
+
cost: z.string().optional(),
|
|
7807
|
+
duration: z.string().optional(),
|
|
7808
|
+
}).optional(),
|
|
7809
|
+
}).default({}),
|
|
7810
|
+
});
|
|
7811
|
+
// Current state per agent — in-memory, not persisted
|
|
7812
|
+
const canvasStateMap = new Map();
|
|
7813
|
+
// POST /canvas/state — agent emits a state transition
|
|
7814
|
+
app.post('/canvas/state', async (request, reply) => {
|
|
7815
|
+
const result = CanvasRenderSchema.safeParse(request.body);
|
|
7816
|
+
if (!result.success) {
|
|
7817
|
+
reply.code(422);
|
|
7818
|
+
return {
|
|
7819
|
+
error: `Invalid canvas state: ${result.error.issues.map(i => i.message).join(', ')}`,
|
|
7820
|
+
hint: `state must be one of: ${CANVAS_STATES.join(', ')}`,
|
|
7821
|
+
validStates: CANVAS_STATES,
|
|
7822
|
+
};
|
|
7823
|
+
}
|
|
7824
|
+
const { state, sensors, agentId, payload } = result.data;
|
|
7825
|
+
const now = Date.now();
|
|
7826
|
+
// Store current state
|
|
7827
|
+
canvasStateMap.set(agentId, { state, sensors, payload, updatedAt: now });
|
|
7828
|
+
// Emit canvas_render event over SSE
|
|
7829
|
+
eventBus.emit({
|
|
7830
|
+
id: `crender-${now}-${Math.random().toString(36).slice(2, 8)}`,
|
|
7831
|
+
type: 'canvas_render',
|
|
7832
|
+
timestamp: now,
|
|
7833
|
+
data: { state, sensors, agentId, payload },
|
|
7834
|
+
});
|
|
7835
|
+
return { success: true, state, agentId, timestamp: now };
|
|
7836
|
+
});
|
|
7837
|
+
// GET /canvas/state — current state for all agents (or one)
|
|
7838
|
+
app.get('/canvas/state', async (request) => {
|
|
7839
|
+
const query = request.query;
|
|
7840
|
+
if (query.agentId) {
|
|
7841
|
+
const entry = canvasStateMap.get(query.agentId);
|
|
7842
|
+
return entry ?? { state: 'floor', sensors: null, payload: {}, updatedAt: null };
|
|
7843
|
+
}
|
|
7844
|
+
const all = {};
|
|
7845
|
+
for (const [id, entry] of canvasStateMap) {
|
|
7846
|
+
all[id] = entry;
|
|
7847
|
+
}
|
|
7848
|
+
return { agents: all, count: canvasStateMap.size };
|
|
7849
|
+
});
|
|
7850
|
+
// GET /canvas/states — valid state + sensor values (discovery)
|
|
7851
|
+
app.get('/canvas/states', async () => ({
|
|
7852
|
+
states: CANVAS_STATES,
|
|
7853
|
+
sensors: SENSOR_VALUES,
|
|
7854
|
+
schema: {
|
|
7855
|
+
state: 'floor | listening | thinking | rendering | ambient | decision | urgent | handoff',
|
|
7856
|
+
sensors: 'null | mic | camera | mic+camera (non-dismissable trust indicator)',
|
|
7857
|
+
agentId: 'required — which agent is driving the canvas',
|
|
7858
|
+
payload: 'optional — text, media, decision, agents, summary',
|
|
7859
|
+
},
|
|
7860
|
+
}));
|
|
7701
7861
|
// GET /canvas/slots — current active slots
|
|
7702
7862
|
app.get('/canvas/slots', async () => {
|
|
7703
7863
|
return {
|
|
@@ -9272,6 +9432,26 @@ export async function createServer() {
|
|
|
9272
9432
|
const allDrops = chatManager.getDropStats();
|
|
9273
9433
|
const agentDrops = allDrops[agent];
|
|
9274
9434
|
const focusSummary = getFocusSummary();
|
|
9435
|
+
// Boot context: recent memories + active run (survives restart)
|
|
9436
|
+
let bootMemories = [];
|
|
9437
|
+
let activeRun = null;
|
|
9438
|
+
try {
|
|
9439
|
+
const { listMemories } = await import('./agent-memories.js');
|
|
9440
|
+
const memories = listMemories({ agentId: agent, limit: 5 });
|
|
9441
|
+
bootMemories = memories.map(m => ({
|
|
9442
|
+
key: m.key, content: m.content.slice(0, 200),
|
|
9443
|
+
namespace: m.namespace, updatedAt: m.updatedAt,
|
|
9444
|
+
}));
|
|
9445
|
+
}
|
|
9446
|
+
catch { /* agent-memories not available */ }
|
|
9447
|
+
try {
|
|
9448
|
+
const { getActiveAgentRun } = await import('./agent-runs.js');
|
|
9449
|
+
const run = getActiveAgentRun(agent, 'default');
|
|
9450
|
+
if (run) {
|
|
9451
|
+
activeRun = { id: run.id, objective: run.objective, status: run.status, startedAt: run.startedAt };
|
|
9452
|
+
}
|
|
9453
|
+
}
|
|
9454
|
+
catch { /* agent-runs not available */ }
|
|
9275
9455
|
return {
|
|
9276
9456
|
agent, ts: Date.now(),
|
|
9277
9457
|
active: slim(activeTask), next: pauseStatus.paused ? null : slim(nextTask),
|
|
@@ -9281,6 +9461,12 @@ export async function createServer() {
|
|
|
9281
9461
|
...(focusSummary ? { focus: focusSummary } : {}),
|
|
9282
9462
|
...(agentDrops ? { drops: { total: agentDrops.total, rolling_1h: agentDrops.rolling_1h } } : {}),
|
|
9283
9463
|
...(pauseStatus.paused ? { paused: true, pauseMessage: pauseStatus.message, resumesAt: pauseStatus.entry?.pausedUntil ?? null } : {}),
|
|
9464
|
+
...(bootMemories.length > 0 ? { memories: bootMemories } : {}),
|
|
9465
|
+
...(activeRun ? { run: activeRun } : {}),
|
|
9466
|
+
...(() => {
|
|
9467
|
+
const p = presenceManager.getAllPresence().find(p => p.agent === agent);
|
|
9468
|
+
return p?.waiting ? { waiting: p.waiting } : {};
|
|
9469
|
+
})(),
|
|
9284
9470
|
action: pauseStatus.paused ? `PAUSED: ${pauseStatus.message}`
|
|
9285
9471
|
: activeTask ? `Continue ${activeTask.id}`
|
|
9286
9472
|
: nextTask ? `Claim ${nextTask.id}`
|
|
@@ -9288,6 +9474,21 @@ export async function createServer() {
|
|
|
9288
9474
|
: 'HEARTBEAT_OK',
|
|
9289
9475
|
};
|
|
9290
9476
|
});
|
|
9477
|
+
// ── Agent Waiting State ──────────────────────────────────────────────
|
|
9478
|
+
// Agents signal they're blocked on human input. Shows in heartbeat + presence.
|
|
9479
|
+
app.post('/agents/:agent/waiting', async (request, reply) => {
|
|
9480
|
+
const agent = String(request.params.agent || '').trim().toLowerCase();
|
|
9481
|
+
const body = request.body ?? {};
|
|
9482
|
+
if (!body.reason)
|
|
9483
|
+
return reply.code(400).send({ error: 'reason is required' });
|
|
9484
|
+
presenceManager.setWaiting(agent, { reason: body.reason, waitingFor: body.waitingFor, taskId: body.taskId, expiresAt: body.expiresAt });
|
|
9485
|
+
return { success: true, agent, status: 'waiting', waiting: { reason: body.reason, waitingFor: body.waitingFor, taskId: body.taskId, expiresAt: body.expiresAt } };
|
|
9486
|
+
});
|
|
9487
|
+
app.delete('/agents/:agent/waiting', async (request) => {
|
|
9488
|
+
const agent = String(request.params.agent || '').trim().toLowerCase();
|
|
9489
|
+
presenceManager.clearWaiting(agent);
|
|
9490
|
+
return { success: true, agent, status: 'idle' };
|
|
9491
|
+
});
|
|
9291
9492
|
// ── Bootstrap: dynamic agent config generation ──────────────────────
|
|
9292
9493
|
app.get('/bootstrap/heartbeat/:agent', async (request) => {
|
|
9293
9494
|
const agent = String(request.params.agent || '').trim().toLowerCase();
|
|
@@ -9351,6 +9552,7 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
9351
9552
|
- Do not load full chat history.
|
|
9352
9553
|
- Do not post plan-only updates.
|
|
9353
9554
|
- If nothing changed and no direct action is required, reply \`HEARTBEAT_OK\`.
|
|
9555
|
+
- **Decision authority:** Team owns product/arch/process decisions. Escalate credentials, legal, and vision decisions to the admin/owner. See \`decision_authority\` block in \`defaults/TEAM-ROLES.yaml\` for the full list.
|
|
9354
9556
|
`;
|
|
9355
9557
|
// Stable hash for change detection (agents can cache and compare)
|
|
9356
9558
|
const { createHash } = await import('node:crypto');
|
|
@@ -10455,17 +10657,12 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
10455
10657
|
return { success: false, error: 'agent and model are required' };
|
|
10456
10658
|
}
|
|
10457
10659
|
const event = recordUsage({
|
|
10458
|
-
|
|
10459
|
-
task_id: body.task_id,
|
|
10660
|
+
agentId: body.agent,
|
|
10460
10661
|
model: body.model,
|
|
10461
|
-
|
|
10462
|
-
|
|
10463
|
-
|
|
10464
|
-
estimated_cost_usd: body.estimated_cost_usd != null ? Number(body.estimated_cost_usd) : undefined,
|
|
10465
|
-
category: body.category || 'other',
|
|
10662
|
+
inputTokens: Number(body.input_tokens) || 0,
|
|
10663
|
+
outputTokens: Number(body.output_tokens) || 0,
|
|
10664
|
+
cost: body.estimated_cost_usd != null ? Number(body.estimated_cost_usd) : 0,
|
|
10466
10665
|
timestamp: Number(body.timestamp) || Date.now(),
|
|
10467
|
-
team_id: body.team_id,
|
|
10468
|
-
metadata: body.metadata,
|
|
10469
10666
|
});
|
|
10470
10667
|
return { success: true, event };
|
|
10471
10668
|
});
|
|
@@ -11027,6 +11224,21 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
11027
11224
|
});
|
|
11028
11225
|
events.push(event);
|
|
11029
11226
|
}
|
|
11227
|
+
// Post GitHub events to the 'github' chat channel with remapped mentions.
|
|
11228
|
+
// Pass enrichedBody (not body) so formatGitHubEvent has access to
|
|
11229
|
+
// _reflectt_attribution and can mention the correct agent (@link not @kai).
|
|
11230
|
+
if (provider === 'github') {
|
|
11231
|
+
const ghEventType = request.headers['x-github-event'] || eventType;
|
|
11232
|
+
const chatMessage = formatGitHubEvent(ghEventType, enrichedBody);
|
|
11233
|
+
if (chatMessage) {
|
|
11234
|
+
chatManager.sendMessage({
|
|
11235
|
+
from: 'github',
|
|
11236
|
+
content: chatMessage,
|
|
11237
|
+
channel: 'github',
|
|
11238
|
+
metadata: { source: 'github-webhook', eventType: ghEventType, delivery: request.headers['x-github-delivery'] },
|
|
11239
|
+
}).catch(() => { }); // non-blocking
|
|
11240
|
+
}
|
|
11241
|
+
}
|
|
11030
11242
|
reply.code(202);
|
|
11031
11243
|
return { success: true, accepted: events.length, events: events.map(e => ({ id: e.id, idempotencyKey: e.idempotencyKey, status: e.status })) };
|
|
11032
11244
|
});
|
|
@@ -11989,6 +12201,1020 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
11989
12201
|
});
|
|
11990
12202
|
// Start hourly auto-snapshot for alert-preflight daily metrics
|
|
11991
12203
|
startAutoSnapshot();
|
|
12204
|
+
// ─── Browser capability routes ───────────────────────────────────────────────
|
|
12205
|
+
const browser = await import('./capabilities/browser.js');
|
|
12206
|
+
app.get('/browser/config', async () => {
|
|
12207
|
+
return browser.getBrowserConfig();
|
|
12208
|
+
});
|
|
12209
|
+
app.post('/browser/sessions', async (request, reply) => {
|
|
12210
|
+
try {
|
|
12211
|
+
const body = request.body;
|
|
12212
|
+
if (!body?.agent)
|
|
12213
|
+
return reply.code(400).send({ error: 'agent is required' });
|
|
12214
|
+
const session = await browser.createSession({
|
|
12215
|
+
agent: body.agent,
|
|
12216
|
+
url: body.url,
|
|
12217
|
+
headless: body.headless,
|
|
12218
|
+
viewport: body.viewport,
|
|
12219
|
+
});
|
|
12220
|
+
const { _stagehand, _page, _idleTimer, ...safe } = session;
|
|
12221
|
+
return reply.code(201).send(safe);
|
|
12222
|
+
}
|
|
12223
|
+
catch (err) {
|
|
12224
|
+
const status = err.message?.includes('Max concurrent') || err.message?.includes('exceeded max') ? 429 : 500;
|
|
12225
|
+
return reply.code(status).send({ error: err.message });
|
|
12226
|
+
}
|
|
12227
|
+
});
|
|
12228
|
+
app.get('/browser/sessions', async () => {
|
|
12229
|
+
return { sessions: browser.listSessions() };
|
|
12230
|
+
});
|
|
12231
|
+
app.get('/browser/sessions/:id', async (request, reply) => {
|
|
12232
|
+
const session = browser.getSession(request.params.id);
|
|
12233
|
+
if (!session)
|
|
12234
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
12235
|
+
const { _stagehand, _page, _idleTimer, ...safe } = session;
|
|
12236
|
+
return safe;
|
|
12237
|
+
});
|
|
12238
|
+
app.delete('/browser/sessions/:id', async (request, reply) => {
|
|
12239
|
+
await browser.closeSession(request.params.id);
|
|
12240
|
+
return { ok: true };
|
|
12241
|
+
});
|
|
12242
|
+
app.post('/browser/sessions/:id/act', async (request, reply) => {
|
|
12243
|
+
try {
|
|
12244
|
+
const body = request.body;
|
|
12245
|
+
if (!body?.instruction)
|
|
12246
|
+
return reply.code(400).send({ error: 'instruction is required' });
|
|
12247
|
+
const result = await browser.act(request.params.id, body.instruction);
|
|
12248
|
+
return result;
|
|
12249
|
+
}
|
|
12250
|
+
catch (err) {
|
|
12251
|
+
return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
|
|
12252
|
+
}
|
|
12253
|
+
});
|
|
12254
|
+
app.post('/browser/sessions/:id/extract', async (request, reply) => {
|
|
12255
|
+
try {
|
|
12256
|
+
const body = request.body;
|
|
12257
|
+
if (!body?.instruction)
|
|
12258
|
+
return reply.code(400).send({ error: 'instruction is required' });
|
|
12259
|
+
const result = await browser.extract(request.params.id, body.instruction, body.schema);
|
|
12260
|
+
return result;
|
|
12261
|
+
}
|
|
12262
|
+
catch (err) {
|
|
12263
|
+
return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
|
|
12264
|
+
}
|
|
12265
|
+
});
|
|
12266
|
+
app.post('/browser/sessions/:id/observe', async (request, reply) => {
|
|
12267
|
+
try {
|
|
12268
|
+
const body = request.body;
|
|
12269
|
+
if (!body?.instruction)
|
|
12270
|
+
return reply.code(400).send({ error: 'instruction is required' });
|
|
12271
|
+
const result = await browser.observe(request.params.id, body.instruction);
|
|
12272
|
+
return result;
|
|
12273
|
+
}
|
|
12274
|
+
catch (err) {
|
|
12275
|
+
return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
|
|
12276
|
+
}
|
|
12277
|
+
});
|
|
12278
|
+
app.post('/browser/sessions/:id/navigate', async (request, reply) => {
|
|
12279
|
+
try {
|
|
12280
|
+
const body = request.body;
|
|
12281
|
+
if (!body?.url)
|
|
12282
|
+
return reply.code(400).send({ error: 'url is required' });
|
|
12283
|
+
const result = await browser.navigate(request.params.id, body.url);
|
|
12284
|
+
return result;
|
|
12285
|
+
}
|
|
12286
|
+
catch (err) {
|
|
12287
|
+
return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
|
|
12288
|
+
}
|
|
12289
|
+
});
|
|
12290
|
+
app.get('/browser/sessions/:id/screenshot', async (request, reply) => {
|
|
12291
|
+
try {
|
|
12292
|
+
const result = await browser.screenshot(request.params.id);
|
|
12293
|
+
return result;
|
|
12294
|
+
}
|
|
12295
|
+
catch (err) {
|
|
12296
|
+
return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
|
|
12297
|
+
}
|
|
12298
|
+
});
|
|
12299
|
+
// ── Agent Runs & Events ──────────────────────────────────────────────────
|
|
12300
|
+
const { createAgentRun, updateAgentRun, getAgentRun, getActiveAgentRun, listAgentRuns, appendAgentEvent, listAgentEvents, VALID_RUN_STATUSES, } = await import('./agent-runs.js');
|
|
12301
|
+
// Create a new agent run
|
|
12302
|
+
app.post('/agents/:agentId/runs', async (request, reply) => {
|
|
12303
|
+
const { agentId } = request.params;
|
|
12304
|
+
const body = request.body;
|
|
12305
|
+
if (!body?.objective)
|
|
12306
|
+
return reply.code(400).send({ error: 'objective is required' });
|
|
12307
|
+
const teamId = body.teamId ?? 'default';
|
|
12308
|
+
try {
|
|
12309
|
+
const run = createAgentRun(agentId, teamId, body.objective, {
|
|
12310
|
+
taskId: body.taskId,
|
|
12311
|
+
parentRunId: body.parentRunId,
|
|
12312
|
+
});
|
|
12313
|
+
return reply.code(201).send(run);
|
|
12314
|
+
}
|
|
12315
|
+
catch (err) {
|
|
12316
|
+
return reply.code(500).send({ error: err.message });
|
|
12317
|
+
}
|
|
12318
|
+
});
|
|
12319
|
+
// Update an agent run (status, context, artifacts)
|
|
12320
|
+
app.patch('/agents/:agentId/runs/:runId', async (request, reply) => {
|
|
12321
|
+
const { runId } = request.params;
|
|
12322
|
+
const body = request.body;
|
|
12323
|
+
if (body?.status && !VALID_RUN_STATUSES.includes(body.status)) {
|
|
12324
|
+
return reply.code(400).send({ error: `Invalid status. Valid: ${VALID_RUN_STATUSES.join(', ')}` });
|
|
12325
|
+
}
|
|
12326
|
+
try {
|
|
12327
|
+
const run = updateAgentRun(runId, {
|
|
12328
|
+
status: body?.status,
|
|
12329
|
+
contextSnapshot: body?.contextSnapshot,
|
|
12330
|
+
artifacts: body?.artifacts,
|
|
12331
|
+
});
|
|
12332
|
+
if (!run)
|
|
12333
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
12334
|
+
return run;
|
|
12335
|
+
}
|
|
12336
|
+
catch (err) {
|
|
12337
|
+
return reply.code(500).send({ error: err.message });
|
|
12338
|
+
}
|
|
12339
|
+
});
|
|
12340
|
+
// List agent runs
|
|
12341
|
+
app.get('/agents/:agentId/runs', async (request, reply) => {
|
|
12342
|
+
const { agentId } = request.params;
|
|
12343
|
+
const query = request.query;
|
|
12344
|
+
const teamId = query.teamId ?? 'default';
|
|
12345
|
+
const limit = query.limit ? parseInt(query.limit, 10) : undefined;
|
|
12346
|
+
return listAgentRuns(agentId, teamId, { status: query.status, limit });
|
|
12347
|
+
});
|
|
12348
|
+
// Get active run for an agent
|
|
12349
|
+
app.get('/agents/:agentId/runs/current', async (request, reply) => {
|
|
12350
|
+
const { agentId } = request.params;
|
|
12351
|
+
const query = request.query;
|
|
12352
|
+
const teamId = query.teamId ?? 'default';
|
|
12353
|
+
const run = getActiveAgentRun(agentId, teamId);
|
|
12354
|
+
if (!run)
|
|
12355
|
+
return reply.code(404).send({ error: 'No active run' });
|
|
12356
|
+
return run;
|
|
12357
|
+
});
|
|
12358
|
+
// Append an event
|
|
12359
|
+
const { validateRoutingSemantics } = await import('./agent-runs.js');
|
|
12360
|
+
// GET /events/routing/validate — check if a payload passes routing semantics
|
|
12361
|
+
app.post('/events/routing/validate', async (request) => {
|
|
12362
|
+
const body = request.body;
|
|
12363
|
+
if (!body?.eventType)
|
|
12364
|
+
return { valid: false, errors: ['eventType is required'], warnings: [] };
|
|
12365
|
+
return validateRoutingSemantics(body.eventType, body.payload ?? {});
|
|
12366
|
+
});
|
|
12367
|
+
app.post('/agents/:agentId/events', async (request, reply) => {
|
|
12368
|
+
const { agentId } = request.params;
|
|
12369
|
+
const body = request.body;
|
|
12370
|
+
if (!body?.eventType)
|
|
12371
|
+
return reply.code(400).send({ error: 'eventType is required' });
|
|
12372
|
+
try {
|
|
12373
|
+
const event = appendAgentEvent({
|
|
12374
|
+
agentId,
|
|
12375
|
+
runId: body.runId,
|
|
12376
|
+
eventType: body.eventType,
|
|
12377
|
+
payload: body.payload,
|
|
12378
|
+
enforceRouting: body.enforceRouting,
|
|
12379
|
+
});
|
|
12380
|
+
return reply.code(201).send(event);
|
|
12381
|
+
}
|
|
12382
|
+
catch (err) {
|
|
12383
|
+
if (err.message.includes('Routing semantics violation')) {
|
|
12384
|
+
return reply.code(422).send({ error: err.message, hint: 'Actionable events require: action_required (string), urgency (low|normal|high|critical), owner (string). Optional: expires_at (number).' });
|
|
12385
|
+
}
|
|
12386
|
+
return reply.code(500).send({ error: err.message });
|
|
12387
|
+
const message = String(err?.message || err);
|
|
12388
|
+
if (message.includes('rationale')) {
|
|
12389
|
+
return reply.code(400).send({ error: message });
|
|
12390
|
+
}
|
|
12391
|
+
return reply.code(500).send({ error: message });
|
|
12392
|
+
}
|
|
12393
|
+
});
|
|
12394
|
+
// List agent events
|
|
12395
|
+
app.get('/agents/:agentId/events', async (request, reply) => {
|
|
12396
|
+
const { agentId } = request.params;
|
|
12397
|
+
const query = request.query;
|
|
12398
|
+
return listAgentEvents({
|
|
12399
|
+
agentId,
|
|
12400
|
+
runId: query.runId,
|
|
12401
|
+
eventType: query.type,
|
|
12402
|
+
since: query.since ? parseInt(query.since, 10) : undefined,
|
|
12403
|
+
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
12404
|
+
});
|
|
12405
|
+
});
|
|
12406
|
+
// ── Run Event Stream (SSE) ─────────────────────────────────────────────
|
|
12407
|
+
// Real-time SSE stream for run events. Canvas subscribes here instead of polling.
|
|
12408
|
+
// GET /agents/:agentId/runs/:runId/stream — stream events for a specific run
|
|
12409
|
+
// GET /agents/:agentId/stream — stream all events for an agent
|
|
12410
|
+
app.get('/agents/:agentId/runs/:runId/stream', async (request, reply) => {
|
|
12411
|
+
const { agentId, runId } = request.params;
|
|
12412
|
+
const run = getAgentRun(runId);
|
|
12413
|
+
if (!run) {
|
|
12414
|
+
reply.code(404);
|
|
12415
|
+
return { error: 'Run not found' };
|
|
12416
|
+
}
|
|
12417
|
+
reply.raw.writeHead(200, {
|
|
12418
|
+
'Content-Type': 'text/event-stream',
|
|
12419
|
+
'Cache-Control': 'no-cache',
|
|
12420
|
+
'Connection': 'keep-alive',
|
|
12421
|
+
'X-Accel-Buffering': 'no',
|
|
12422
|
+
});
|
|
12423
|
+
// Send current run state as initial snapshot
|
|
12424
|
+
reply.raw.write(`event: snapshot\ndata: ${JSON.stringify({ run, events: listAgentEvents({ runId, limit: 20 }) })}\n\n`);
|
|
12425
|
+
// Subscribe to eventBus for this run's events
|
|
12426
|
+
const listenerId = `run-stream-${runId}-${Date.now()}`;
|
|
12427
|
+
let closed = false;
|
|
12428
|
+
eventBus.on(listenerId, (event) => {
|
|
12429
|
+
if (closed)
|
|
12430
|
+
return;
|
|
12431
|
+
const data = event.data;
|
|
12432
|
+
// Forward events that match this agent or run
|
|
12433
|
+
if (data && (data.runId === runId || data.agentId === agentId)) {
|
|
12434
|
+
try {
|
|
12435
|
+
reply.raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
12436
|
+
}
|
|
12437
|
+
catch { /* connection closed */ }
|
|
12438
|
+
}
|
|
12439
|
+
});
|
|
12440
|
+
// Heartbeat
|
|
12441
|
+
const heartbeat = setInterval(() => {
|
|
12442
|
+
if (closed) {
|
|
12443
|
+
clearInterval(heartbeat);
|
|
12444
|
+
return;
|
|
12445
|
+
}
|
|
12446
|
+
try {
|
|
12447
|
+
reply.raw.write(`:heartbeat\n\n`);
|
|
12448
|
+
}
|
|
12449
|
+
catch {
|
|
12450
|
+
clearInterval(heartbeat);
|
|
12451
|
+
}
|
|
12452
|
+
}, 15_000);
|
|
12453
|
+
// Cleanup
|
|
12454
|
+
request.raw.on('close', () => {
|
|
12455
|
+
closed = true;
|
|
12456
|
+
eventBus.off(listenerId);
|
|
12457
|
+
clearInterval(heartbeat);
|
|
12458
|
+
});
|
|
12459
|
+
});
|
|
12460
|
+
// Stream all events for an agent
|
|
12461
|
+
app.get('/agents/:agentId/stream', async (request, reply) => {
|
|
12462
|
+
const { agentId } = request.params;
|
|
12463
|
+
reply.raw.writeHead(200, {
|
|
12464
|
+
'Content-Type': 'text/event-stream',
|
|
12465
|
+
'Cache-Control': 'no-cache',
|
|
12466
|
+
'Connection': 'keep-alive',
|
|
12467
|
+
'X-Accel-Buffering': 'no',
|
|
12468
|
+
});
|
|
12469
|
+
// Send recent events as snapshot
|
|
12470
|
+
const recentEvents = listAgentEvents({ agentId, limit: 20 });
|
|
12471
|
+
const activeRun = getActiveAgentRun(agentId, 'default');
|
|
12472
|
+
reply.raw.write(`event: snapshot\ndata: ${JSON.stringify({ activeRun, events: recentEvents })}\n\n`);
|
|
12473
|
+
const listenerId = `agent-stream-${agentId}-${Date.now()}`;
|
|
12474
|
+
let closed = false;
|
|
12475
|
+
eventBus.on(listenerId, (event) => {
|
|
12476
|
+
if (closed)
|
|
12477
|
+
return;
|
|
12478
|
+
const data = event.data;
|
|
12479
|
+
if (data && data.agentId === agentId) {
|
|
12480
|
+
try {
|
|
12481
|
+
reply.raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
12482
|
+
}
|
|
12483
|
+
catch { /* connection closed */ }
|
|
12484
|
+
}
|
|
12485
|
+
});
|
|
12486
|
+
const heartbeat = setInterval(() => {
|
|
12487
|
+
if (closed) {
|
|
12488
|
+
clearInterval(heartbeat);
|
|
12489
|
+
return;
|
|
12490
|
+
}
|
|
12491
|
+
try {
|
|
12492
|
+
reply.raw.write(`:heartbeat\n\n`);
|
|
12493
|
+
}
|
|
12494
|
+
catch {
|
|
12495
|
+
clearInterval(heartbeat);
|
|
12496
|
+
}
|
|
12497
|
+
}, 15_000);
|
|
12498
|
+
request.raw.on('close', () => {
|
|
12499
|
+
closed = true;
|
|
12500
|
+
eventBus.off(listenerId);
|
|
12501
|
+
clearInterval(heartbeat);
|
|
12502
|
+
});
|
|
12503
|
+
});
|
|
12504
|
+
// ── Run Stream (by run ID only) ──────────────────────────────────────
|
|
12505
|
+
// GET /runs/:runId/stream — SSE stream for a run without requiring agentId.
|
|
12506
|
+
// Cloud Presence surface subscribes here to show live run activity.
|
|
12507
|
+
app.get('/runs/:runId/stream', async (request, reply) => {
|
|
12508
|
+
const { runId } = request.params;
|
|
12509
|
+
const run = getAgentRun(runId);
|
|
12510
|
+
if (!run) {
|
|
12511
|
+
reply.code(404);
|
|
12512
|
+
return { error: 'Run not found' };
|
|
12513
|
+
}
|
|
12514
|
+
reply.raw.writeHead(200, {
|
|
12515
|
+
'Content-Type': 'text/event-stream',
|
|
12516
|
+
'Cache-Control': 'no-cache',
|
|
12517
|
+
'Connection': 'keep-alive',
|
|
12518
|
+
'X-Accel-Buffering': 'no',
|
|
12519
|
+
});
|
|
12520
|
+
// Initial snapshot: run state + recent events
|
|
12521
|
+
reply.raw.write(`event: snapshot\ndata: ${JSON.stringify({ run, events: listAgentEvents({ runId, limit: 20 }) })}\n\n`);
|
|
12522
|
+
const listenerId = `run-direct-stream-${runId}-${Date.now()}`;
|
|
12523
|
+
let closed = false;
|
|
12524
|
+
eventBus.on(listenerId, (event) => {
|
|
12525
|
+
if (closed)
|
|
12526
|
+
return;
|
|
12527
|
+
const data = event.data;
|
|
12528
|
+
if (data && (data.runId === runId || data.agentId === run.agentId)) {
|
|
12529
|
+
try {
|
|
12530
|
+
reply.raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
12531
|
+
}
|
|
12532
|
+
catch { /* connection closed */ }
|
|
12533
|
+
}
|
|
12534
|
+
});
|
|
12535
|
+
const heartbeat = setInterval(() => {
|
|
12536
|
+
if (closed) {
|
|
12537
|
+
clearInterval(heartbeat);
|
|
12538
|
+
return;
|
|
12539
|
+
}
|
|
12540
|
+
try {
|
|
12541
|
+
reply.raw.write(`:heartbeat\n\n`);
|
|
12542
|
+
}
|
|
12543
|
+
catch {
|
|
12544
|
+
clearInterval(heartbeat);
|
|
12545
|
+
}
|
|
12546
|
+
}, 15_000);
|
|
12547
|
+
request.raw.on('close', () => {
|
|
12548
|
+
closed = true;
|
|
12549
|
+
eventBus.off(listenerId);
|
|
12550
|
+
clearInterval(heartbeat);
|
|
12551
|
+
});
|
|
12552
|
+
});
|
|
12553
|
+
// ── Workflow Templates ─────────────────────────────────────────────────
|
|
12554
|
+
const { listWorkflowTemplates, getWorkflowTemplate, runWorkflow } = await import('./workflow-templates.js');
|
|
12555
|
+
// GET /workflows — list available workflow templates
|
|
12556
|
+
app.get('/workflows', async () => ({ templates: listWorkflowTemplates() }));
|
|
12557
|
+
// GET /workflows/:id — get template details
|
|
12558
|
+
app.get('/workflows/:id', async (request, reply) => {
|
|
12559
|
+
const template = getWorkflowTemplate(request.params.id);
|
|
12560
|
+
if (!template) {
|
|
12561
|
+
reply.code(404);
|
|
12562
|
+
return { error: 'Template not found' };
|
|
12563
|
+
}
|
|
12564
|
+
return {
|
|
12565
|
+
id: template.id,
|
|
12566
|
+
name: template.name,
|
|
12567
|
+
description: template.description,
|
|
12568
|
+
steps: template.steps.map(s => ({ name: s.name, description: s.description })),
|
|
12569
|
+
};
|
|
12570
|
+
});
|
|
12571
|
+
// POST /workflows/:id/run — execute a workflow
|
|
12572
|
+
app.post('/workflows/:id/run', async (request, reply) => {
|
|
12573
|
+
const template = getWorkflowTemplate(request.params.id);
|
|
12574
|
+
if (!template) {
|
|
12575
|
+
reply.code(404);
|
|
12576
|
+
return { error: 'Template not found' };
|
|
12577
|
+
}
|
|
12578
|
+
const body = request.body ?? {};
|
|
12579
|
+
const agentId = body.agentId ?? 'link';
|
|
12580
|
+
const teamId = body.teamId ?? 'default';
|
|
12581
|
+
const result = await runWorkflow(template, agentId, teamId, body);
|
|
12582
|
+
return result;
|
|
12583
|
+
});
|
|
12584
|
+
// ── Agent Messaging (Host-native) ─────────────────────────────────────
|
|
12585
|
+
// Local agent-to-agent messaging. Replaces gateway for same-Host agents.
|
|
12586
|
+
const { sendAgentMessage, listAgentMessages, listSentMessages, markMessagesRead, getUnreadCount, listChannelMessages } = await import('./agent-messaging.js');
|
|
12587
|
+
// Send message
|
|
12588
|
+
app.post('/agents/:agentId/messages/send', async (request, reply) => {
|
|
12589
|
+
const { agentId } = request.params;
|
|
12590
|
+
const body = request.body;
|
|
12591
|
+
if (!body?.to)
|
|
12592
|
+
return reply.code(400).send({ error: 'to (recipient agent) is required' });
|
|
12593
|
+
if (!body?.content)
|
|
12594
|
+
return reply.code(400).send({ error: 'content is required' });
|
|
12595
|
+
const msg = sendAgentMessage({
|
|
12596
|
+
fromAgent: agentId,
|
|
12597
|
+
toAgent: body.to,
|
|
12598
|
+
channel: body.channel,
|
|
12599
|
+
content: body.content,
|
|
12600
|
+
metadata: body.metadata,
|
|
12601
|
+
});
|
|
12602
|
+
// Emit event for SSE subscribers
|
|
12603
|
+
eventBus.emit({
|
|
12604
|
+
id: `amsg-evt-${Date.now()}`,
|
|
12605
|
+
type: 'message_posted',
|
|
12606
|
+
timestamp: Date.now(),
|
|
12607
|
+
data: { messageId: msg.id, from: agentId, to: body.to, channel: msg.channel },
|
|
12608
|
+
});
|
|
12609
|
+
return reply.code(201).send(msg);
|
|
12610
|
+
});
|
|
12611
|
+
// Inbox
|
|
12612
|
+
app.get('/agents/:agentId/messages', async (request) => {
|
|
12613
|
+
const { agentId } = request.params;
|
|
12614
|
+
const query = request.query;
|
|
12615
|
+
return {
|
|
12616
|
+
messages: listAgentMessages({
|
|
12617
|
+
agentId,
|
|
12618
|
+
channel: query.channel,
|
|
12619
|
+
unreadOnly: query.unread === 'true',
|
|
12620
|
+
since: query.since ? parseInt(query.since, 10) : undefined,
|
|
12621
|
+
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
12622
|
+
}),
|
|
12623
|
+
unreadCount: getUnreadCount(agentId),
|
|
12624
|
+
};
|
|
12625
|
+
});
|
|
12626
|
+
// Sent
|
|
12627
|
+
app.get('/agents/:agentId/messages/sent', async (request) => {
|
|
12628
|
+
const { agentId } = request.params;
|
|
12629
|
+
const query = request.query;
|
|
12630
|
+
return { messages: listSentMessages(agentId, query.limit ? parseInt(query.limit, 10) : undefined) };
|
|
12631
|
+
});
|
|
12632
|
+
// Mark read
|
|
12633
|
+
app.post('/agents/:agentId/messages/read', async (request) => {
|
|
12634
|
+
const { agentId } = request.params;
|
|
12635
|
+
const body = request.body ?? {};
|
|
12636
|
+
const marked = markMessagesRead(agentId, body.messageIds);
|
|
12637
|
+
return { marked };
|
|
12638
|
+
});
|
|
12639
|
+
// Channel messages
|
|
12640
|
+
app.get('/messages/channel/:channel', async (request) => {
|
|
12641
|
+
const { channel } = request.params;
|
|
12642
|
+
const query = request.query;
|
|
12643
|
+
return {
|
|
12644
|
+
messages: listChannelMessages(channel, {
|
|
12645
|
+
since: query.since ? parseInt(query.since, 10) : undefined,
|
|
12646
|
+
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
12647
|
+
}),
|
|
12648
|
+
};
|
|
12649
|
+
});
|
|
12650
|
+
// ── Run Retention / Archive ────────────────────────────────────────────
|
|
12651
|
+
const { applyRunRetention, getRetentionStats } = await import('./agent-runs.js');
|
|
12652
|
+
// GET /runs/retention/stats — preview what retention policy would do
|
|
12653
|
+
app.get('/runs/retention/stats', async (request) => {
|
|
12654
|
+
const query = request.query;
|
|
12655
|
+
return getRetentionStats({
|
|
12656
|
+
maxAgeDays: query.maxAgeDays ? parseInt(query.maxAgeDays, 10) : undefined,
|
|
12657
|
+
maxCompletedRuns: query.maxCompletedRuns ? parseInt(query.maxCompletedRuns, 10) : undefined,
|
|
12658
|
+
});
|
|
12659
|
+
});
|
|
12660
|
+
// POST /runs/retention/apply — apply retention policy
|
|
12661
|
+
app.post('/runs/retention/apply', async (request) => {
|
|
12662
|
+
const body = request.body ?? {};
|
|
12663
|
+
return applyRunRetention({
|
|
12664
|
+
policy: {
|
|
12665
|
+
maxAgeDays: body.maxAgeDays,
|
|
12666
|
+
maxCompletedRuns: body.maxCompletedRuns,
|
|
12667
|
+
deleteArchived: body.deleteArchived,
|
|
12668
|
+
},
|
|
12669
|
+
agentId: body.agentId,
|
|
12670
|
+
dryRun: body.dryRun,
|
|
12671
|
+
});
|
|
12672
|
+
});
|
|
12673
|
+
// ── Artifact Store (Host-native) ──────────────────────────────────────
|
|
12674
|
+
const { storeArtifact, getArtifact, readArtifactContent, listArtifacts, deleteArtifact, getStorageUsage } = await import('./artifact-store.js');
|
|
12675
|
+
// Upload artifact
|
|
12676
|
+
app.post('/agents/:agentId/artifacts', async (request, reply) => {
|
|
12677
|
+
const { agentId } = request.params;
|
|
12678
|
+
const body = request.body;
|
|
12679
|
+
if (!body?.name)
|
|
12680
|
+
return reply.code(400).send({ error: 'name is required' });
|
|
12681
|
+
if (!body?.content)
|
|
12682
|
+
return reply.code(400).send({ error: 'content is required' });
|
|
12683
|
+
const contentBuf = body.encoding === 'base64' ? Buffer.from(body.content, 'base64') : Buffer.from(body.content);
|
|
12684
|
+
const art = storeArtifact({ agentId, name: body.name, content: contentBuf, mimeType: body.mimeType, runId: body.runId, taskId: body.taskId, metadata: body.metadata });
|
|
12685
|
+
return reply.code(201).send(art);
|
|
12686
|
+
});
|
|
12687
|
+
// List artifacts
|
|
12688
|
+
app.get('/agents/:agentId/artifacts', async (request) => {
|
|
12689
|
+
const { agentId } = request.params;
|
|
12690
|
+
const query = request.query;
|
|
12691
|
+
return {
|
|
12692
|
+
artifacts: listArtifacts({ agentId, runId: query.runId, taskId: query.taskId, limit: query.limit ? parseInt(query.limit, 10) : undefined }),
|
|
12693
|
+
usage: getStorageUsage(agentId),
|
|
12694
|
+
};
|
|
12695
|
+
});
|
|
12696
|
+
// Get artifact metadata
|
|
12697
|
+
app.get('/artifacts/:artifactId', async (request, reply) => {
|
|
12698
|
+
const { artifactId } = request.params;
|
|
12699
|
+
const art = getArtifact(artifactId);
|
|
12700
|
+
if (!art)
|
|
12701
|
+
return reply.code(404).send({ error: 'Artifact not found' });
|
|
12702
|
+
return art;
|
|
12703
|
+
});
|
|
12704
|
+
// Download artifact content
|
|
12705
|
+
app.get('/artifacts/:artifactId/content', async (request, reply) => {
|
|
12706
|
+
const { artifactId } = request.params;
|
|
12707
|
+
const content = readArtifactContent(artifactId);
|
|
12708
|
+
if (!content)
|
|
12709
|
+
return reply.code(404).send({ error: 'Artifact not found or file missing' });
|
|
12710
|
+
const art = getArtifact(artifactId);
|
|
12711
|
+
return reply.type(art.mimeType).send(content);
|
|
12712
|
+
});
|
|
12713
|
+
// Delete artifact
|
|
12714
|
+
app.delete('/artifacts/:artifactId', async (request, reply) => {
|
|
12715
|
+
const { artifactId } = request.params;
|
|
12716
|
+
const deleted = deleteArtifact(artifactId);
|
|
12717
|
+
if (!deleted)
|
|
12718
|
+
return reply.code(404).send({ error: 'Artifact not found' });
|
|
12719
|
+
return { deleted: true };
|
|
12720
|
+
});
|
|
12721
|
+
// Storage usage
|
|
12722
|
+
app.get('/agents/:agentId/storage', async (request) => {
|
|
12723
|
+
const { agentId } = request.params;
|
|
12724
|
+
return getStorageUsage(agentId);
|
|
12725
|
+
});
|
|
12726
|
+
// ── Webhook Storage ──────────────────────────────────────────────────
|
|
12727
|
+
const { storeWebhookPayload, getWebhookPayload, listWebhookPayloads, markPayloadProcessed, getUnprocessedCount, purgeOldPayloads } = await import('./webhook-storage.js');
|
|
12728
|
+
// Ingest webhook payload
|
|
12729
|
+
app.post('/webhooks/ingest', async (request, reply) => {
|
|
12730
|
+
const body = request.body;
|
|
12731
|
+
if (!body?.source)
|
|
12732
|
+
return reply.code(400).send({ error: 'source is required' });
|
|
12733
|
+
if (!body?.eventType)
|
|
12734
|
+
return reply.code(400).send({ error: 'eventType is required' });
|
|
12735
|
+
if (!body?.body)
|
|
12736
|
+
return reply.code(400).send({ error: 'body (payload) is required' });
|
|
12737
|
+
const headers = {};
|
|
12738
|
+
for (const [k, v] of Object.entries(request.headers)) {
|
|
12739
|
+
if (typeof v === 'string')
|
|
12740
|
+
headers[k] = v;
|
|
12741
|
+
}
|
|
12742
|
+
const payload = storeWebhookPayload({ source: body.source, eventType: body.eventType, agentId: body.agentId, body: body.body, headers });
|
|
12743
|
+
return reply.code(201).send(payload);
|
|
12744
|
+
});
|
|
12745
|
+
// List payloads
|
|
12746
|
+
app.get('/webhooks/payloads', async (request) => {
|
|
12747
|
+
const query = request.query;
|
|
12748
|
+
return {
|
|
12749
|
+
payloads: listWebhookPayloads({
|
|
12750
|
+
source: query.source,
|
|
12751
|
+
agentId: query.agentId,
|
|
12752
|
+
unprocessedOnly: query.unprocessed === 'true',
|
|
12753
|
+
since: query.since ? parseInt(query.since, 10) : undefined,
|
|
12754
|
+
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
12755
|
+
}),
|
|
12756
|
+
unprocessedCount: getUnprocessedCount({ source: query.source, agentId: query.agentId }),
|
|
12757
|
+
};
|
|
12758
|
+
});
|
|
12759
|
+
// Get single payload
|
|
12760
|
+
app.get('/webhooks/payloads/:payloadId', async (request, reply) => {
|
|
12761
|
+
const { payloadId } = request.params;
|
|
12762
|
+
const payload = getWebhookPayload(payloadId);
|
|
12763
|
+
if (!payload)
|
|
12764
|
+
return reply.code(404).send({ error: 'Payload not found' });
|
|
12765
|
+
return payload;
|
|
12766
|
+
});
|
|
12767
|
+
// Mark processed
|
|
12768
|
+
app.post('/webhooks/payloads/:payloadId/process', async (request, reply) => {
|
|
12769
|
+
const { payloadId } = request.params;
|
|
12770
|
+
const marked = markPayloadProcessed(payloadId);
|
|
12771
|
+
if (!marked)
|
|
12772
|
+
return reply.code(404).send({ error: 'Payload not found or already processed' });
|
|
12773
|
+
return { processed: true };
|
|
12774
|
+
});
|
|
12775
|
+
// Purge old processed payloads
|
|
12776
|
+
app.post('/webhooks/purge', async (request) => {
|
|
12777
|
+
const body = request.body ?? {};
|
|
12778
|
+
const deleted = purgeOldPayloads(body.maxAgeDays ?? 30);
|
|
12779
|
+
return { deleted };
|
|
12780
|
+
});
|
|
12781
|
+
// ── Approval Routing ────────────────────────────────────────────────────
|
|
12782
|
+
const { listPendingApprovals, listApprovalQueue, submitApprovalDecision, } = await import('./agent-runs.js');
|
|
12783
|
+
// List pending approvals (review_requested events needing action)
|
|
12784
|
+
app.get('/approvals/pending', async (request) => {
|
|
12785
|
+
const query = request.query;
|
|
12786
|
+
return listPendingApprovals({
|
|
12787
|
+
agentId: query.agentId,
|
|
12788
|
+
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
12789
|
+
});
|
|
12790
|
+
});
|
|
12791
|
+
// Dedicated approval queue — unified view of everything needing human decision.
|
|
12792
|
+
// Answers: what needs decision, who owns it, when it expires, what happens if ignored.
|
|
12793
|
+
app.get('/approval-queue', async (request) => {
|
|
12794
|
+
const query = request.query;
|
|
12795
|
+
const items = listApprovalQueue({
|
|
12796
|
+
agentId: query.agentId,
|
|
12797
|
+
category: query.category === 'review' || query.category === 'agent_action' ? query.category : undefined,
|
|
12798
|
+
includeExpired: query.includeExpired === 'true',
|
|
12799
|
+
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
12800
|
+
});
|
|
12801
|
+
return {
|
|
12802
|
+
items,
|
|
12803
|
+
count: items.length,
|
|
12804
|
+
hasExpired: items.some(i => i.isExpired),
|
|
12805
|
+
};
|
|
12806
|
+
});
|
|
12807
|
+
// Submit agent-action approval (approve_requested events)
|
|
12808
|
+
app.post('/approval-queue/:approvalId/decide', async (request, reply) => {
|
|
12809
|
+
const { approvalId } = request.params;
|
|
12810
|
+
const body = request.body;
|
|
12811
|
+
if (!body?.decision || !['approve', 'reject', 'defer'].includes(body.decision)) {
|
|
12812
|
+
return reply.code(400).send({ error: 'decision must be "approve", "reject", or "defer"' });
|
|
12813
|
+
}
|
|
12814
|
+
if (!body?.actor) {
|
|
12815
|
+
return reply.code(400).send({ error: 'actor is required' });
|
|
12816
|
+
}
|
|
12817
|
+
try {
|
|
12818
|
+
// Auto-supply minimal rationale if omitted — humans approving via UI won't know to send it
|
|
12819
|
+
const rationale = body.rationale ?? {
|
|
12820
|
+
choice: `${body.decision === 'approve' ? 'Approved' : 'Rejected'} by ${body.actor}`,
|
|
12821
|
+
considered: ['approve', 'reject'],
|
|
12822
|
+
constraint: 'Human decision via approval queue',
|
|
12823
|
+
};
|
|
12824
|
+
const result = submitApprovalDecision({
|
|
12825
|
+
eventId: approvalId,
|
|
12826
|
+
decision: body.decision,
|
|
12827
|
+
reviewer: body.actor,
|
|
12828
|
+
comment: body.comment,
|
|
12829
|
+
rationale,
|
|
12830
|
+
});
|
|
12831
|
+
// Emit canvas_input event so Presence Layer updates
|
|
12832
|
+
eventBus.emit({
|
|
12833
|
+
id: `aq-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
12834
|
+
type: 'canvas_input',
|
|
12835
|
+
timestamp: Date.now(),
|
|
12836
|
+
data: {
|
|
12837
|
+
action: 'decision',
|
|
12838
|
+
approvalId,
|
|
12839
|
+
decision: body.decision,
|
|
12840
|
+
actor: body.actor,
|
|
12841
|
+
},
|
|
12842
|
+
});
|
|
12843
|
+
return result;
|
|
12844
|
+
}
|
|
12845
|
+
catch (err) {
|
|
12846
|
+
return reply.code(err.message.includes('not found') ? 404 : 400).send({ error: err.message });
|
|
12847
|
+
}
|
|
12848
|
+
});
|
|
12849
|
+
// Submit approval decision
|
|
12850
|
+
app.post('/approvals/:eventId/decide', async (request, reply) => {
|
|
12851
|
+
const { eventId } = request.params;
|
|
12852
|
+
const body = request.body;
|
|
12853
|
+
if (!body?.decision || !['approve', 'reject'].includes(body.decision)) {
|
|
12854
|
+
return reply.code(400).send({ error: 'decision must be "approve" or "reject"' });
|
|
12855
|
+
}
|
|
12856
|
+
if (!body?.reviewer) {
|
|
12857
|
+
return reply.code(400).send({ error: 'reviewer is required' });
|
|
12858
|
+
}
|
|
12859
|
+
try {
|
|
12860
|
+
const result = submitApprovalDecision({
|
|
12861
|
+
eventId,
|
|
12862
|
+
decision: body.decision,
|
|
12863
|
+
reviewer: body.reviewer,
|
|
12864
|
+
comment: body.comment,
|
|
12865
|
+
rationale: body.rationale,
|
|
12866
|
+
});
|
|
12867
|
+
return result;
|
|
12868
|
+
}
|
|
12869
|
+
catch (err) {
|
|
12870
|
+
return reply.code(err.message.includes('not found') ? 404 : 400).send({ error: err.message });
|
|
12871
|
+
}
|
|
12872
|
+
});
|
|
12873
|
+
// ── Canvas Input ──────────────────────────────────────────────────────
|
|
12874
|
+
// Human → agent control seam for the Presence Layer.
|
|
12875
|
+
// Payload is intentionally small per COO spec: action + target + actor.
|
|
12876
|
+
const CANVAS_INPUT_ACTIONS = ['decision', 'interrupt', 'pause', 'resume', 'mute', 'unmute'];
|
|
12877
|
+
const CanvasInputSchema = z.object({
|
|
12878
|
+
action: z.enum(CANVAS_INPUT_ACTIONS),
|
|
12879
|
+
targetRunId: z.string().optional(), // which run to act on
|
|
12880
|
+
decisionId: z.string().optional(), // for decision actions
|
|
12881
|
+
choice: z.enum(['approve', 'deny', 'defer']).optional(), // for decision actions
|
|
12882
|
+
actor: z.string().min(1), // who made this input
|
|
12883
|
+
comment: z.string().optional(), // optional rationale
|
|
12884
|
+
});
|
|
12885
|
+
app.post('/canvas/input', async (request, reply) => {
|
|
12886
|
+
const body = request.body;
|
|
12887
|
+
const result = CanvasInputSchema.safeParse(body);
|
|
12888
|
+
if (!result.success) {
|
|
12889
|
+
reply.code(422);
|
|
12890
|
+
return {
|
|
12891
|
+
error: `Invalid canvas input: ${result.error.issues.map(i => i.message).join(', ')}`,
|
|
12892
|
+
hint: 'Required: action (decision|interrupt|pause|resume|mute|unmute), actor. Optional: targetRunId, decisionId, choice, comment.',
|
|
12893
|
+
};
|
|
12894
|
+
}
|
|
12895
|
+
const input = result.data;
|
|
12896
|
+
const now = Date.now();
|
|
12897
|
+
// Route by action type
|
|
12898
|
+
if (input.action === 'decision') {
|
|
12899
|
+
if (!input.decisionId || !input.choice) {
|
|
12900
|
+
reply.code(422);
|
|
12901
|
+
return { error: 'Decision action requires decisionId and choice (approve|deny|defer)' };
|
|
12902
|
+
}
|
|
12903
|
+
// Emit canvas_input event for SSE subscribers
|
|
12904
|
+
eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
|
|
12905
|
+
action: input.action,
|
|
12906
|
+
decisionId: input.decisionId,
|
|
12907
|
+
choice: input.choice,
|
|
12908
|
+
actor: input.actor,
|
|
12909
|
+
comment: input.comment,
|
|
12910
|
+
timestamp: now,
|
|
12911
|
+
} });
|
|
12912
|
+
return {
|
|
12913
|
+
success: true,
|
|
12914
|
+
action: 'decision',
|
|
12915
|
+
decisionId: input.decisionId,
|
|
12916
|
+
choice: input.choice,
|
|
12917
|
+
actor: input.actor,
|
|
12918
|
+
timestamp: now,
|
|
12919
|
+
};
|
|
12920
|
+
}
|
|
12921
|
+
if (input.action === 'interrupt' || input.action === 'pause') {
|
|
12922
|
+
// Update active run if specified
|
|
12923
|
+
const runId = input.targetRunId;
|
|
12924
|
+
if (runId) {
|
|
12925
|
+
try {
|
|
12926
|
+
updateAgentRun(runId, {
|
|
12927
|
+
status: input.action === 'interrupt' ? 'cancelled' : 'blocked',
|
|
12928
|
+
});
|
|
12929
|
+
}
|
|
12930
|
+
catch { /* run may not exist — still emit event */ }
|
|
12931
|
+
}
|
|
12932
|
+
eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
|
|
12933
|
+
action: input.action,
|
|
12934
|
+
targetRunId: runId || null,
|
|
12935
|
+
actor: input.actor,
|
|
12936
|
+
timestamp: now,
|
|
12937
|
+
} });
|
|
12938
|
+
return {
|
|
12939
|
+
success: true,
|
|
12940
|
+
action: input.action,
|
|
12941
|
+
targetRunId: runId || null,
|
|
12942
|
+
actor: input.actor,
|
|
12943
|
+
timestamp: now,
|
|
12944
|
+
};
|
|
12945
|
+
}
|
|
12946
|
+
if (input.action === 'resume') {
|
|
12947
|
+
const runId = input.targetRunId;
|
|
12948
|
+
if (runId) {
|
|
12949
|
+
try {
|
|
12950
|
+
updateAgentRun(runId, { status: 'working' });
|
|
12951
|
+
}
|
|
12952
|
+
catch { /* run may not exist */ }
|
|
12953
|
+
}
|
|
12954
|
+
eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
|
|
12955
|
+
action: 'resume',
|
|
12956
|
+
targetRunId: runId || null,
|
|
12957
|
+
actor: input.actor,
|
|
12958
|
+
timestamp: now,
|
|
12959
|
+
} });
|
|
12960
|
+
return { success: true, action: 'resume', targetRunId: runId || null, actor: input.actor, timestamp: now };
|
|
12961
|
+
}
|
|
12962
|
+
// Mute/unmute — emit event only, no state change needed
|
|
12963
|
+
eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
|
|
12964
|
+
action: input.action,
|
|
12965
|
+
actor: input.actor,
|
|
12966
|
+
timestamp: now,
|
|
12967
|
+
} });
|
|
12968
|
+
return { success: true, action: input.action, actor: input.actor, timestamp: now };
|
|
12969
|
+
});
|
|
12970
|
+
// GET /canvas/input/schema — discovery endpoint
|
|
12971
|
+
app.get('/canvas/input/schema', async () => ({
|
|
12972
|
+
actions: CANVAS_INPUT_ACTIONS,
|
|
12973
|
+
schema: {
|
|
12974
|
+
action: 'decision | interrupt | pause | resume | mute | unmute',
|
|
12975
|
+
targetRunId: 'optional — which run to act on',
|
|
12976
|
+
decisionId: 'required for decision action — approval event ID',
|
|
12977
|
+
choice: 'required for decision — approve | deny | defer',
|
|
12978
|
+
actor: 'required — who made this input',
|
|
12979
|
+
comment: 'optional — rationale',
|
|
12980
|
+
},
|
|
12981
|
+
}));
|
|
12982
|
+
// ── Email / SMS relay ──────────────────────────────────────────────────
|
|
12983
|
+
async function cloudRelay(path, body, reply) {
|
|
12984
|
+
const cloudUrl = process.env.REFLECTT_CLOUD_URL;
|
|
12985
|
+
const hostToken = process.env.REFLECTT_HOST_TOKEN;
|
|
12986
|
+
if (!cloudUrl || !hostToken) {
|
|
12987
|
+
reply.code(503);
|
|
12988
|
+
return { error: 'Not connected to cloud. Configure REFLECTT_CLOUD_URL and REFLECTT_HOST_TOKEN.' };
|
|
12989
|
+
}
|
|
12990
|
+
try {
|
|
12991
|
+
const res = await fetch(`${cloudUrl}${path}`, {
|
|
12992
|
+
method: 'POST',
|
|
12993
|
+
headers: {
|
|
12994
|
+
'Content-Type': 'application/json',
|
|
12995
|
+
Authorization: `Bearer ${hostToken}`,
|
|
12996
|
+
},
|
|
12997
|
+
body: JSON.stringify(body),
|
|
12998
|
+
});
|
|
12999
|
+
const data = await res.json().catch(() => ({}));
|
|
13000
|
+
if (!res.ok) {
|
|
13001
|
+
reply.code(res.status);
|
|
13002
|
+
return data;
|
|
13003
|
+
}
|
|
13004
|
+
return data;
|
|
13005
|
+
}
|
|
13006
|
+
catch (err) {
|
|
13007
|
+
reply.code(502);
|
|
13008
|
+
return { error: `Cloud relay failed: ${err.message}` };
|
|
13009
|
+
}
|
|
13010
|
+
}
|
|
13011
|
+
// Send email via cloud relay
|
|
13012
|
+
app.post('/email/send', async (request, reply) => {
|
|
13013
|
+
const body = request.body;
|
|
13014
|
+
const from = typeof body.from === 'string' ? body.from.trim() : '';
|
|
13015
|
+
const to = body.to;
|
|
13016
|
+
const subject = typeof body.subject === 'string' ? body.subject.trim() : '';
|
|
13017
|
+
if (!from)
|
|
13018
|
+
return reply.code(400).send({ error: 'from is required' });
|
|
13019
|
+
if (!to)
|
|
13020
|
+
return reply.code(400).send({ error: 'to is required' });
|
|
13021
|
+
if (!subject)
|
|
13022
|
+
return reply.code(400).send({ error: 'subject is required' });
|
|
13023
|
+
if (!body.html && !body.text)
|
|
13024
|
+
return reply.code(400).send({ error: 'html or text body is required' });
|
|
13025
|
+
// Use host-relay endpoint — authenticates with host credential, uses host's own teamId server-side
|
|
13026
|
+
const hostId = process.env.REFLECTT_HOST_ID;
|
|
13027
|
+
const relayPath = hostId ? `/api/hosts/${encodeURIComponent(hostId)}/relay/email` : '/api/hosts/relay/email';
|
|
13028
|
+
return cloudRelay(relayPath, {
|
|
13029
|
+
from,
|
|
13030
|
+
to,
|
|
13031
|
+
subject,
|
|
13032
|
+
html: body.html,
|
|
13033
|
+
text: body.text,
|
|
13034
|
+
replyTo: body.replyTo,
|
|
13035
|
+
cc: body.cc,
|
|
13036
|
+
bcc: body.bcc,
|
|
13037
|
+
agent: body.agentId || body.agent || 'unknown',
|
|
13038
|
+
}, reply);
|
|
13039
|
+
});
|
|
13040
|
+
// Send SMS via cloud relay
|
|
13041
|
+
app.post('/sms/send', async (request, reply) => {
|
|
13042
|
+
const body = request.body;
|
|
13043
|
+
const to = typeof body.to === 'string' ? body.to.trim() : '';
|
|
13044
|
+
const msgBody = typeof body.body === 'string' ? body.body.trim() : '';
|
|
13045
|
+
if (!to)
|
|
13046
|
+
return reply.code(400).send({ error: 'to is required (phone number)' });
|
|
13047
|
+
if (!msgBody)
|
|
13048
|
+
return reply.code(400).send({ error: 'body is required' });
|
|
13049
|
+
const hostIdSms = process.env.REFLECTT_HOST_ID;
|
|
13050
|
+
const smsRelayPath = hostIdSms ? `/api/hosts/${encodeURIComponent(hostIdSms)}/relay/sms` : '/api/hosts/relay/sms';
|
|
13051
|
+
return cloudRelay(smsRelayPath, {
|
|
13052
|
+
to,
|
|
13053
|
+
body: msgBody,
|
|
13054
|
+
from: body.from,
|
|
13055
|
+
agent: body.agentId || body.agent || 'unknown',
|
|
13056
|
+
}, reply);
|
|
13057
|
+
});
|
|
13058
|
+
// ── Agent Config ──────────────────────────────────────────────────────
|
|
13059
|
+
// Per-agent model preference, cost cap, and settings.
|
|
13060
|
+
// This is the policy anchor for cost enforcement.
|
|
13061
|
+
const { getAgentConfig, listAgentConfigs, setAgentConfig, deleteAgentConfig, checkCostCap } = await import('./agent-config.js');
|
|
13062
|
+
// GET /agents/:agentId/config — get config for an agent
|
|
13063
|
+
app.get('/agents/:agentId/config', async (request) => {
|
|
13064
|
+
const config = getAgentConfig(request.params.agentId);
|
|
13065
|
+
return config ?? { agentId: request.params.agentId, configured: false };
|
|
13066
|
+
});
|
|
13067
|
+
// PUT /agents/:agentId/config — upsert config for an agent
|
|
13068
|
+
app.put('/agents/:agentId/config', async (request, reply) => {
|
|
13069
|
+
const body = request.body ?? {};
|
|
13070
|
+
try {
|
|
13071
|
+
const config = setAgentConfig(request.params.agentId, {
|
|
13072
|
+
teamId: typeof body.teamId === 'string' ? body.teamId : undefined,
|
|
13073
|
+
model: body.model !== undefined ? body.model : undefined,
|
|
13074
|
+
fallbackModel: body.fallbackModel !== undefined ? body.fallbackModel : undefined,
|
|
13075
|
+
costCapDaily: body.costCapDaily !== undefined ? body.costCapDaily : undefined,
|
|
13076
|
+
costCapMonthly: body.costCapMonthly !== undefined ? body.costCapMonthly : undefined,
|
|
13077
|
+
maxTokensPerCall: body.maxTokensPerCall !== undefined ? body.maxTokensPerCall : undefined,
|
|
13078
|
+
settings: body.settings !== undefined ? body.settings : undefined,
|
|
13079
|
+
});
|
|
13080
|
+
return config;
|
|
13081
|
+
}
|
|
13082
|
+
catch (err) {
|
|
13083
|
+
reply.code(400);
|
|
13084
|
+
return { error: err.message };
|
|
13085
|
+
}
|
|
13086
|
+
});
|
|
13087
|
+
// DELETE /agents/:agentId/config — remove config for an agent
|
|
13088
|
+
app.delete('/agents/:agentId/config', async (request, reply) => {
|
|
13089
|
+
const deleted = deleteAgentConfig(request.params.agentId);
|
|
13090
|
+
if (!deleted) {
|
|
13091
|
+
reply.code(404);
|
|
13092
|
+
return { error: 'Config not found' };
|
|
13093
|
+
}
|
|
13094
|
+
return { success: true };
|
|
13095
|
+
});
|
|
13096
|
+
// GET /agent-configs — list all agent configs
|
|
13097
|
+
app.get('/agent-configs', async (request) => {
|
|
13098
|
+
const query = request.query;
|
|
13099
|
+
return { configs: listAgentConfigs({ teamId: query.teamId }) };
|
|
13100
|
+
});
|
|
13101
|
+
// GET /agents/:agentId/cost-check — runtime cost enforcement check
|
|
13102
|
+
// Used by the runtime before making model calls.
|
|
13103
|
+
app.get('/agents/:agentId/cost-check', async (request) => {
|
|
13104
|
+
const query = request.query;
|
|
13105
|
+
const dailySpend = query.dailySpend ? parseFloat(query.dailySpend) : 0;
|
|
13106
|
+
const monthlySpend = query.monthlySpend ? parseFloat(query.monthlySpend) : 0;
|
|
13107
|
+
return checkCostCap(request.params.agentId, dailySpend, monthlySpend);
|
|
13108
|
+
});
|
|
13109
|
+
// ── Cost-Policy Enforcement Middleware ──────────────────────────────────
|
|
13110
|
+
const { enforcePolicy, recordUsage, getDailySpend, getMonthlySpend, purgeUsageLog, ensureUsageLogTable, } = await import('./cost-enforcement.js');
|
|
13111
|
+
ensureUsageLogTable();
|
|
13112
|
+
// POST /agents/:agentId/enforce-cost — runtime enforcement before model calls
|
|
13113
|
+
app.post('/agents/:agentId/enforce-cost', async (request, reply) => {
|
|
13114
|
+
const result = enforcePolicy(request.params.agentId);
|
|
13115
|
+
const status = result.action === 'deny' ? 403 : 200;
|
|
13116
|
+
return reply.code(status).send(result);
|
|
13117
|
+
});
|
|
13118
|
+
// GET /agents/:agentId/spend — current daily + monthly spend
|
|
13119
|
+
app.get('/agents/:agentId/spend', async (request) => {
|
|
13120
|
+
const { agentId } = request.params;
|
|
13121
|
+
return {
|
|
13122
|
+
agentId,
|
|
13123
|
+
dailySpend: getDailySpend(agentId),
|
|
13124
|
+
monthlySpend: getMonthlySpend(agentId),
|
|
13125
|
+
};
|
|
13126
|
+
});
|
|
13127
|
+
// POST /usage/record — record a usage event
|
|
13128
|
+
app.post('/usage/record', async (request, reply) => {
|
|
13129
|
+
const body = request.body;
|
|
13130
|
+
if (!body?.agentId)
|
|
13131
|
+
return reply.code(400).send({ error: 'agentId is required' });
|
|
13132
|
+
if (!body?.model)
|
|
13133
|
+
return reply.code(400).send({ error: 'model is required' });
|
|
13134
|
+
if (typeof body.cost !== 'number')
|
|
13135
|
+
return reply.code(400).send({ error: 'cost is required (number)' });
|
|
13136
|
+
recordUsage({
|
|
13137
|
+
agentId: body.agentId,
|
|
13138
|
+
model: body.model,
|
|
13139
|
+
inputTokens: body.inputTokens ?? 0,
|
|
13140
|
+
outputTokens: body.outputTokens ?? 0,
|
|
13141
|
+
cost: body.cost,
|
|
13142
|
+
timestamp: Date.now(),
|
|
13143
|
+
});
|
|
13144
|
+
return reply.code(201).send({ ok: true });
|
|
13145
|
+
});
|
|
13146
|
+
// POST /usage/purge — purge old usage records
|
|
13147
|
+
app.post('/usage/purge', async (request) => {
|
|
13148
|
+
const body = request.body;
|
|
13149
|
+
const deleted = purgeUsageLog(body?.maxAgeDays ?? 90);
|
|
13150
|
+
return { deleted };
|
|
13151
|
+
});
|
|
13152
|
+
// ── Agent Memories ─────────────────────────────────────────────────────
|
|
13153
|
+
const { setMemory, getMemory, listMemories, deleteMemory, deleteMemoryById, purgeExpiredMemories, countMemories, } = await import('./agent-memories.js');
|
|
13154
|
+
// Set (create or update) a memory
|
|
13155
|
+
app.put('/agents/:agentId/memories', async (request, reply) => {
|
|
13156
|
+
const { agentId } = request.params;
|
|
13157
|
+
const body = request.body;
|
|
13158
|
+
if (!body?.key)
|
|
13159
|
+
return reply.code(400).send({ error: 'key is required' });
|
|
13160
|
+
if (body.content === undefined || body.content === null)
|
|
13161
|
+
return reply.code(400).send({ error: 'content is required' });
|
|
13162
|
+
try {
|
|
13163
|
+
const memory = setMemory({
|
|
13164
|
+
agentId,
|
|
13165
|
+
namespace: body.namespace,
|
|
13166
|
+
key: body.key,
|
|
13167
|
+
content: body.content,
|
|
13168
|
+
tags: body.tags,
|
|
13169
|
+
expiresAt: body.expiresAt,
|
|
13170
|
+
});
|
|
13171
|
+
return reply.code(200).send(memory);
|
|
13172
|
+
}
|
|
13173
|
+
catch (err) {
|
|
13174
|
+
return reply.code(500).send({ error: err.message });
|
|
13175
|
+
}
|
|
13176
|
+
});
|
|
13177
|
+
// Get a specific memory by key
|
|
13178
|
+
app.get('/agents/:agentId/memories/:key', async (request, reply) => {
|
|
13179
|
+
const { agentId, key } = request.params;
|
|
13180
|
+
const query = request.query;
|
|
13181
|
+
const memory = getMemory(agentId, key, query.namespace);
|
|
13182
|
+
if (!memory)
|
|
13183
|
+
return reply.code(404).send({ error: 'Memory not found' });
|
|
13184
|
+
return memory;
|
|
13185
|
+
});
|
|
13186
|
+
// List memories for an agent
|
|
13187
|
+
app.get('/agents/:agentId/memories', async (request, reply) => {
|
|
13188
|
+
const { agentId } = request.params;
|
|
13189
|
+
const query = request.query;
|
|
13190
|
+
return listMemories({
|
|
13191
|
+
agentId,
|
|
13192
|
+
namespace: query.namespace,
|
|
13193
|
+
tag: query.tag,
|
|
13194
|
+
search: query.search,
|
|
13195
|
+
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
13196
|
+
});
|
|
13197
|
+
});
|
|
13198
|
+
// Delete a memory by key
|
|
13199
|
+
app.delete('/agents/:agentId/memories/:key', async (request, reply) => {
|
|
13200
|
+
const { agentId, key } = request.params;
|
|
13201
|
+
const query = request.query;
|
|
13202
|
+
const deleted = deleteMemory(agentId, key, query.namespace);
|
|
13203
|
+
if (!deleted)
|
|
13204
|
+
return reply.code(404).send({ error: 'Memory not found' });
|
|
13205
|
+
return { deleted: true };
|
|
13206
|
+
});
|
|
13207
|
+
// Count memories
|
|
13208
|
+
app.get('/agents/:agentId/memories/count', async (request, reply) => {
|
|
13209
|
+
const { agentId } = request.params;
|
|
13210
|
+
const query = request.query;
|
|
13211
|
+
return { count: countMemories(agentId, query.namespace) };
|
|
13212
|
+
});
|
|
13213
|
+
// Purge expired memories (housekeeping)
|
|
13214
|
+
app.post('/agents/memories/purge', async (_request, reply) => {
|
|
13215
|
+
const purged = purgeExpiredMemories();
|
|
13216
|
+
return { purged };
|
|
13217
|
+
});
|
|
11992
13218
|
return app;
|
|
11993
13219
|
}
|
|
11994
13220
|
//# sourceMappingURL=server.js.map
|