reflectt-node 0.1.7 → 0.1.11
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/README.md +13 -0
- package/defaults/TEAM-ROLES.yaml +317 -5
- package/defaults/gitignore.template +23 -0
- 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 +32 -0
- package/dist/boardHealthWorker.d.ts.map +1 -1
- package/dist/boardHealthWorker.js +69 -2
- package/dist/boardHealthWorker.js.map +1 -1
- package/dist/buildInfo.d.ts.map +1 -1
- package/dist/buildInfo.js +47 -10
- package/dist/buildInfo.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/chat.d.ts +4 -0
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +6 -2
- package/dist/chat.js.map +1 -1
- package/dist/cli.js +41 -14
- 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 +151 -64
- package/dist/cloud.js.map +1 -1
- package/dist/continuity-loop.d.ts.map +1 -1
- package/dist/continuity-loop.js +297 -29
- package/dist/continuity-loop.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/db.d.ts.map +1 -1
- package/dist/db.js +131 -0
- package/dist/db.js.map +1 -1
- package/dist/deploy-monitor.d.ts +18 -0
- package/dist/deploy-monitor.d.ts.map +1 -0
- package/dist/deploy-monitor.js +165 -0
- package/dist/deploy-monitor.js.map +1 -0
- 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/executionSweeper.d.ts +1 -0
- package/dist/executionSweeper.d.ts.map +1 -1
- package/dist/executionSweeper.js +43 -7
- package/dist/executionSweeper.js.map +1 -1
- package/dist/files.d.ts.map +1 -1
- package/dist/files.js +17 -3
- package/dist/files.js.map +1 -1
- package/dist/fingerprint.d.ts +30 -0
- package/dist/fingerprint.d.ts.map +1 -0
- package/dist/fingerprint.js +117 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/github-webhook-attribution.d.ts +38 -0
- package/dist/github-webhook-attribution.d.ts.map +1 -0
- package/dist/github-webhook-attribution.js +123 -0
- package/dist/github-webhook-attribution.js.map +1 -0
- 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/inbox.d.ts.map +1 -1
- package/dist/inbox.js +4 -0
- package/dist/inbox.js.map +1 -1
- package/dist/index.js +76 -11
- 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/pulse.d.ts +7 -0
- package/dist/pulse.d.ts.map +1 -1
- package/dist/pulse.js +15 -0
- package/dist/pulse.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/review-state.d.ts +9 -0
- package/dist/review-state.d.ts.map +1 -0
- package/dist/review-state.js +17 -0
- package/dist/review-state.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/schedule.d.ts +60 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +176 -0
- package/dist/schedule.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1714 -88
- package/dist/server.js.map +1 -1
- package/dist/suppression-ledger.d.ts.map +1 -1
- package/dist/suppression-ledger.js +12 -3
- package/dist/suppression-ledger.js.map +1 -1
- package/dist/system-loop-state.d.ts +1 -1
- package/dist/system-loop-state.d.ts.map +1 -1
- package/dist/system-loop-state.js +1 -0
- package/dist/system-loop-state.js.map +1 -1
- package/dist/tasks.d.ts +9 -1
- package/dist/tasks.d.ts.map +1 -1
- package/dist/tasks.js +238 -41
- 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/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/usage-tracking.d.ts +26 -0
- package/dist/usage-tracking.d.ts.map +1 -1
- package/dist/usage-tracking.js +91 -4
- package/dist/usage-tracking.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 +130 -37
- package/public/design-tokens-platform.md +118 -0
- package/public/design-tokens.css +195 -0
- package/public/docs.md +145 -2
package/dist/server.js
CHANGED
|
@@ -53,7 +53,7 @@ import { autoPopulateCloseGate, tryAutoCloseTask, getMergeAttemptLog } from './p
|
|
|
53
53
|
import { getDuplicateClosureCanonicalRefError } from './duplicateClosureGuard.js';
|
|
54
54
|
import { recordReviewMutation, diffReviewFields, getAuditEntries, loadAuditLedger } from './auditLedger.js';
|
|
55
55
|
import { listSharedFiles, readSharedFile, resolveTaskArtifact } from './shared-workspace-api.js';
|
|
56
|
-
import { normalizeTaskArtifactPaths, buildGitHubBlobUrl, buildGitHubRawUrl } from './artifact-resolver.js';
|
|
56
|
+
import { normalizeArtifactPath, normalizeTaskArtifactPaths, buildGitHubBlobUrl, buildGitHubRawUrl } from './artifact-resolver.js';
|
|
57
57
|
import { emitActivationEvent, getUserFunnelState, getFunnelSummary, hasCompletedEvent, isDay2Eligible, loadActivationFunnel, getConversionFunnel, getFailureDistribution, getWeeklyTrends, getOnboardingDashboard, } from './activationEvents.js';
|
|
58
58
|
import { alertUnauthorizedApproval, alertFlipAttempt, getMutationAlertStatus, pruneOldAttempts } from './mutationAlert.js';
|
|
59
59
|
import { mentionAckTracker } from './mention-ack.js';
|
|
@@ -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';
|
|
@@ -80,6 +80,8 @@ import { computeCiFromCheckRuns, computeCiFromCombinedStatus } from './github-ci
|
|
|
80
80
|
import { createGitHubIdentityProvider } from './github-identity.js';
|
|
81
81
|
import { getProvisioningManager } from './provisioning.js';
|
|
82
82
|
import { getWebhookDeliveryManager } from './webhooks.js';
|
|
83
|
+
import { enrichWebhookPayload } from './github-webhook-attribution.js';
|
|
84
|
+
import { formatGitHubEvent } from './github-webhook-chat.js';
|
|
83
85
|
import { exportBundle, importBundle } from './portability.js';
|
|
84
86
|
import { getNotificationManager } from './notifications.js';
|
|
85
87
|
import { getConnectivityManager } from './connectivity.js';
|
|
@@ -115,7 +117,9 @@ import { getRoutingApprovalQueue, getRoutingSuggestion, buildApprovalPatch, buil
|
|
|
115
117
|
import { calendarManager } from './calendar.js';
|
|
116
118
|
import { calendarEvents } from './calendar-events.js';
|
|
117
119
|
import { startReminderEngine, stopReminderEngine, getReminderEngineStats } from './calendar-reminder-engine.js';
|
|
120
|
+
import { startDeployMonitor, stopDeployMonitor } from './deploy-monitor.js';
|
|
118
121
|
import { exportICS, exportEventICS, importICS } from './calendar-ical.js';
|
|
122
|
+
import { createScheduleEntry, getScheduleEntry, updateScheduleEntry, deleteScheduleEntry, getScheduleFeed } from './schedule.js';
|
|
119
123
|
import { createDoc, getDoc, listDocs, updateDoc, deleteDoc } from './knowledge-docs.js';
|
|
120
124
|
import { onTaskShipped, onDecisionComment, isDecisionComment } from './knowledge-auto-index.js';
|
|
121
125
|
import { upsertHostHeartbeat, getHost, listHosts, removeHost } from './host-registry.js';
|
|
@@ -272,6 +276,13 @@ function normalizeConfiguredModel(value) {
|
|
|
272
276
|
error: `Unknown model identifier "${raw}". Allowed aliases: ${Object.keys(MODEL_ALIASES).join(', ')} or provider/model format.`,
|
|
273
277
|
};
|
|
274
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();
|
|
275
286
|
const UpdateTaskSchema = z.object({
|
|
276
287
|
title: z.string().min(1).optional(),
|
|
277
288
|
description: z.string().optional(),
|
|
@@ -426,6 +437,9 @@ const QaBundleSchema = z.object({
|
|
|
426
437
|
});
|
|
427
438
|
const ReviewHandoffSchema = z.object({
|
|
428
439
|
task_id: z.string().trim().regex(/^task-[a-zA-Z0-9-]+$/),
|
|
440
|
+
// Stored transactionally (server-side) from POST /tasks/:id/comments.
|
|
441
|
+
// This must always resolve via GET /tasks/:id/comments.
|
|
442
|
+
comment_id: z.string().trim().regex(/^tcomment-\d+-[a-z0-9]+$/i).optional(),
|
|
429
443
|
repo: z.string().trim().min(1).optional(), // optional for config_only tasks
|
|
430
444
|
artifact_path: z.string().trim().min(1), // relaxed: accepts any path (process/, ~/.reflectt/, etc.)
|
|
431
445
|
test_proof: z.string().trim().min(1).optional(), // optional for non-code tasks
|
|
@@ -621,6 +635,29 @@ function enforceQaBundleGateForValidating(status, metadata, expectedTaskId) {
|
|
|
621
635
|
hint: 'Use the same canonical process/... artifact path in both fields.',
|
|
622
636
|
};
|
|
623
637
|
}
|
|
638
|
+
// Canonical artifact reference (until central storage exists):
|
|
639
|
+
// For code tasks, artifact paths must be repo-relative under process/ (or a URL).
|
|
640
|
+
if (reviewPacket && !nonCodeLane) {
|
|
641
|
+
const packetArtifact = typeof reviewPacket.artifact_path === 'string' ? reviewPacket.artifact_path.trim() : '';
|
|
642
|
+
const packetIsUrl = /^https?:\/\//i.test(packetArtifact);
|
|
643
|
+
const packetIsProcess = packetArtifact.startsWith('process/');
|
|
644
|
+
if (packetArtifact && !packetIsUrl && !packetIsProcess) {
|
|
645
|
+
return {
|
|
646
|
+
ok: false,
|
|
647
|
+
error: 'Validating gate: metadata.qa_bundle.review_packet.artifact_path must be under process/ (repo-relative) or a URL',
|
|
648
|
+
hint: 'Set review_packet.artifact_path to process/TASK-...md (committed in the PR) or a PR/GitHub URL.',
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
const metaIsUrl = /^https?:\/\//i.test(artifactPath);
|
|
652
|
+
const metaIsProcess = artifactPath.startsWith('process/');
|
|
653
|
+
if (artifactPath && !metaIsUrl && !metaIsProcess) {
|
|
654
|
+
return {
|
|
655
|
+
ok: false,
|
|
656
|
+
error: 'Validating gate: metadata.artifact_path must be under process/ (repo-relative) or a URL',
|
|
657
|
+
hint: 'Set metadata.artifact_path to process/TASK-...md (committed in the PR) or a PR/GitHub URL.',
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
}
|
|
624
661
|
// PR integrity: validate commit SHA + changed_files against live PR head
|
|
625
662
|
if (!nonCodeLane && reviewPacket?.pr_url) {
|
|
626
663
|
const overrideFlag = metadataObj.pr_integrity_override === true;
|
|
@@ -742,6 +779,40 @@ function applyReviewStateMetadata(existing, parsed, mergedMeta, now) {
|
|
|
742
779
|
};
|
|
743
780
|
console.log(`[ArtifactNormalize] task ${existing.id}: normalized`, normResult.warnings);
|
|
744
781
|
}
|
|
782
|
+
// ── Review handoff comment pointer repair/fill ──
|
|
783
|
+
// If review_handoff exists, ensure comment_id points to a real comment.
|
|
784
|
+
// We do this *server-side* to avoid phantom/unresolvable pointers.
|
|
785
|
+
const rh = metadata.review_handoff;
|
|
786
|
+
if (rh && typeof rh === 'object' && !Array.isArray(rh)) {
|
|
787
|
+
const rhAny = rh;
|
|
788
|
+
const commentId = typeof rhAny.comment_id === 'string' ? rhAny.comment_id.trim() : '';
|
|
789
|
+
const all = taskManager.getTaskComments(existing.id, { includeSuppressed: true });
|
|
790
|
+
const resolves = commentId ? all.some(c => c.id === commentId) : false;
|
|
791
|
+
if (!resolves) {
|
|
792
|
+
// Prefer an explicit category tag; fallback to most recent comment by assignee.
|
|
793
|
+
const assignee = (existing.assignee || '').trim().toLowerCase();
|
|
794
|
+
const byHandoffCategory = all
|
|
795
|
+
.filter(c => {
|
|
796
|
+
const cat = String(c.category || '').toLowerCase();
|
|
797
|
+
return cat === 'review_handoff' || cat === 'handoff';
|
|
798
|
+
});
|
|
799
|
+
const byAssignee = assignee
|
|
800
|
+
? all.filter(c => String(c.author || '').trim().toLowerCase() === assignee)
|
|
801
|
+
: [];
|
|
802
|
+
const candidate = (byHandoffCategory.length > 0
|
|
803
|
+
? byHandoffCategory[byHandoffCategory.length - 1]
|
|
804
|
+
: (byAssignee.length > 0 ? byAssignee[byAssignee.length - 1] : (all.length > 0 ? all[all.length - 1] : null)));
|
|
805
|
+
if (candidate) {
|
|
806
|
+
metadata.review_handoff = { ...rhAny, comment_id: candidate.id };
|
|
807
|
+
metadata.review_handoff_comment_id_autofilled = {
|
|
808
|
+
previous: commentId || null,
|
|
809
|
+
next: candidate.id,
|
|
810
|
+
at: now,
|
|
811
|
+
strategy: byHandoffCategory.length > 0 ? 'category:review_handoff' : (byAssignee.length > 0 ? 'latest_assignee_comment' : 'latest_comment'),
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
745
816
|
}
|
|
746
817
|
if (previousStatus === 'validating' && nextStatus === 'doing' && !incomingReviewState) {
|
|
747
818
|
metadata.review_state = 'needs_author';
|
|
@@ -859,7 +930,13 @@ function isEchoOutOfLaneTask(task) {
|
|
|
859
930
|
// For Echo, anything classified outside content/docs voice lane gets flagged unless reassigned.
|
|
860
931
|
return true;
|
|
861
932
|
}
|
|
862
|
-
|
|
933
|
+
const reviewHandoffValidationStats = {
|
|
934
|
+
failures: 0,
|
|
935
|
+
lastFailureAt: 0,
|
|
936
|
+
lastFailureTaskId: '',
|
|
937
|
+
lastFailureError: '',
|
|
938
|
+
};
|
|
939
|
+
async function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
|
|
863
940
|
if (status !== 'validating')
|
|
864
941
|
return { ok: true };
|
|
865
942
|
if (isTaskAutomatedRecurring(metadata))
|
|
@@ -874,7 +951,7 @@ function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
|
|
|
874
951
|
return {
|
|
875
952
|
ok: false,
|
|
876
953
|
error: 'Review handoff required: metadata.review_handoff must include task_id, artifact_path, known_caveats (and pr_url + commit_sha unless doc_only=true, config_only=true, or non_code=true).',
|
|
877
|
-
hint: 'Example: { "review_handoff": { "task_id":"task-...", "artifact_path":"process/TASK-...md", "known_caveats":"none" } }. For non-code tasks
|
|
954
|
+
hint: 'Example: { "review_handoff": { "task_id":"task-...", "artifact_path":"process/TASK-...md", "known_caveats":"none" } }. For non-code tasks: set non_code=true. Recommended: post the handoff comment with category="review_handoff" so the server stamps comment_id automatically.',
|
|
878
955
|
};
|
|
879
956
|
}
|
|
880
957
|
const handoff = parsed.data;
|
|
@@ -885,6 +962,38 @@ function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
|
|
|
885
962
|
hint: 'Set metadata.review_handoff.task_id to the exact task being transitioned.',
|
|
886
963
|
};
|
|
887
964
|
}
|
|
965
|
+
// Ensure review_handoff.comment_id resolves to a real comment.
|
|
966
|
+
// If missing (or stale), we repair it from existing comments; if none exist,
|
|
967
|
+
// we create a server-authored pointer comment so reviewers always have a stable anchor.
|
|
968
|
+
const commentsAll = taskManager.getTaskComments(taskId, { includeSuppressed: true });
|
|
969
|
+
let commentId = typeof handoff.comment_id === 'string' ? handoff.comment_id.trim() : '';
|
|
970
|
+
let handoffComment = commentId ? (commentsAll.find(c => c.id === commentId) || null) : null;
|
|
971
|
+
if (!handoffComment) {
|
|
972
|
+
const byCategory = commentsAll.filter(c => {
|
|
973
|
+
const cat = String(c.category || '').toLowerCase();
|
|
974
|
+
return cat === 'review_handoff' || cat === 'handoff';
|
|
975
|
+
});
|
|
976
|
+
const candidate = byCategory.length > 0
|
|
977
|
+
? byCategory[byCategory.length - 1]
|
|
978
|
+
: (commentsAll.length > 0 ? commentsAll[commentsAll.length - 1] : null);
|
|
979
|
+
if (candidate) {
|
|
980
|
+
commentId = candidate.id;
|
|
981
|
+
handoffComment = candidate;
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
// No comments exist — create a stable anchor comment.
|
|
985
|
+
const created = await taskManager.addTaskComment(taskId, 'system', 'Auto-handoff: review_handoff is recorded in metadata.review_handoff (no explicit handoff comment was posted).', { category: 'review_handoff', provenance: { kind: 'auto_review_handoff', source: 'validating_gate' } });
|
|
986
|
+
commentId = created.id;
|
|
987
|
+
handoffComment = created;
|
|
988
|
+
}
|
|
989
|
+
// Persist repaired comment_id into the handoff metadata (server-side).
|
|
990
|
+
;
|
|
991
|
+
handoff.comment_id = commentId;
|
|
992
|
+
const rhObj = root.review_handoff;
|
|
993
|
+
if (rhObj && typeof rhObj === 'object' && !Array.isArray(rhObj)) {
|
|
994
|
+
rhObj.comment_id = commentId;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
888
997
|
// config_only: artifacts live in ~/.reflectt/, no repo/PR required
|
|
889
998
|
// doc_only/non_code/design/docs lanes: no PR/commit required
|
|
890
999
|
const nonCodeLane = handoff.non_code === true || isDesignOrDocsLane(root);
|
|
@@ -904,7 +1013,43 @@ function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
|
|
|
904
1013
|
};
|
|
905
1014
|
}
|
|
906
1015
|
}
|
|
907
|
-
|
|
1016
|
+
// Artifact retrievability gate.
|
|
1017
|
+
// If the artifact isn't accessible from this node (repo / shared-workspace / GitHub fallback),
|
|
1018
|
+
// a reviewer on another host will almost certainly be blocked.
|
|
1019
|
+
const artifactPath = typeof handoff.artifact_path === 'string' ? handoff.artifact_path.trim() : '';
|
|
1020
|
+
const norm = normalizeArtifactPath(artifactPath);
|
|
1021
|
+
if (norm.rejected || !norm.normalized) {
|
|
1022
|
+
return {
|
|
1023
|
+
ok: false,
|
|
1024
|
+
error: `Validating gate: review_handoff.artifact_path is not a valid retrievable reference (${norm.rejectReason || 'invalid path'}).`,
|
|
1025
|
+
hint: 'Use either (a) a PR/GitHub URL, or (b) a repo-relative path (e.g. process/TASK-...md) that exists on the referenced PR/commit, or (c) put the full spec in the handoff comment and point artifact_path at a stable URL.',
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
// URLs are assumed retrievable.
|
|
1029
|
+
if (/^https?:\/\//i.test(norm.normalized))
|
|
1030
|
+
return { ok: true };
|
|
1031
|
+
// If the file is accessible locally (repo or shared-workspace), accept.
|
|
1032
|
+
const repoRoot = resolve(import.meta.dirname || process.cwd(), '..');
|
|
1033
|
+
const resolved = await resolveTaskArtifact(norm.normalized, repoRoot);
|
|
1034
|
+
if (resolved.accessible)
|
|
1035
|
+
return { ok: true };
|
|
1036
|
+
// GitHub fallback: if PR+commit are known and artifact is process/*, we can build a stable blob URL.
|
|
1037
|
+
const prUrl = root.pr_url || root.qa_bundle?.review_packet?.pr_url || root.review_handoff?.pr_url;
|
|
1038
|
+
const commitSha = root.commit_sha || root.commit || root.qa_bundle?.review_packet?.commit || root.review_handoff?.commit_sha;
|
|
1039
|
+
if (typeof prUrl === 'string' && typeof commitSha === 'string' && norm.normalized.startsWith('process/')) {
|
|
1040
|
+
const blobUrl = buildGitHubBlobUrl(prUrl, commitSha, norm.normalized);
|
|
1041
|
+
if (blobUrl)
|
|
1042
|
+
return { ok: true };
|
|
1043
|
+
}
|
|
1044
|
+
// For non-code tasks, the handoff comment itself is considered the primary artifact.
|
|
1045
|
+
// We only require that comment_id resolves (handled above).
|
|
1046
|
+
if (nonCodeLane)
|
|
1047
|
+
return { ok: true };
|
|
1048
|
+
return {
|
|
1049
|
+
ok: false,
|
|
1050
|
+
error: 'Validating gate: review_handoff.artifact_path is not retrievable from repo/shared-workspace/GitHub fallback.',
|
|
1051
|
+
hint: 'Move the artifact into shared-workspace process/, or reference a PR+commit so GitHub blob fallback can resolve it (process/* only).',
|
|
1052
|
+
};
|
|
908
1053
|
}
|
|
909
1054
|
const DEFAULT_LIMITS = {
|
|
910
1055
|
chatMessages: 50,
|
|
@@ -1516,7 +1661,8 @@ export async function createServer() {
|
|
|
1516
1661
|
await app.register(fastifyWebsocket);
|
|
1517
1662
|
// Multipart file uploads (50MB limit)
|
|
1518
1663
|
const fastifyMultipart = await import('@fastify/multipart');
|
|
1519
|
-
|
|
1664
|
+
const { MAX_SIZE_BYTES: _multipartMax } = await import('./files.js');
|
|
1665
|
+
await app.register(fastifyMultipart.default, { limits: { fileSize: _multipartMax } });
|
|
1520
1666
|
// Normalize error responses to a consistent envelope
|
|
1521
1667
|
app.addHook('preSerialization', async (request, reply, payload) => {
|
|
1522
1668
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
@@ -1561,6 +1707,8 @@ export async function createServer() {
|
|
|
1561
1707
|
envelope.gate = body.gate;
|
|
1562
1708
|
if (body.problems !== undefined)
|
|
1563
1709
|
envelope.problems = body.problems;
|
|
1710
|
+
if (body.tombstone !== undefined)
|
|
1711
|
+
envelope.tombstone = body.tombstone;
|
|
1564
1712
|
if (alreadyEnvelope && body.data !== undefined)
|
|
1565
1713
|
envelope.data = body.data;
|
|
1566
1714
|
// Minimal persisted error log: enables /logs to return real entries.
|
|
@@ -1847,6 +1995,10 @@ export async function createServer() {
|
|
|
1847
1995
|
// Board health execution worker — config from policy
|
|
1848
1996
|
boardHealthWorker.updateConfig(policy.boardHealth);
|
|
1849
1997
|
boardHealthWorker.start();
|
|
1998
|
+
// Activate noise budget enforcement — the 24h canary period is complete.
|
|
1999
|
+
// Canary mode (log-only) is still the default in case of fresh installs,
|
|
2000
|
+
// but on a running server we want real duplicate suppression.
|
|
2001
|
+
noiseBudgetManager.activateEnforcement();
|
|
1850
2002
|
// Noise budget: wire digest flush handler to send batched messages to #ops
|
|
1851
2003
|
noiseBudgetManager.setDigestFlushHandler(async (channel, entries) => {
|
|
1852
2004
|
if (entries.length === 0)
|
|
@@ -1876,6 +2028,8 @@ export async function createServer() {
|
|
|
1876
2028
|
startShippedHeartbeat();
|
|
1877
2029
|
// Calendar reminder engine — polls for pending reminders every 30s
|
|
1878
2030
|
startReminderEngine();
|
|
2031
|
+
// Deploy monitor — alert within 5m when production deploys fail (Vercel + health URL)
|
|
2032
|
+
startDeployMonitor();
|
|
1879
2033
|
app.addHook('onClose', async () => {
|
|
1880
2034
|
clearInterval(idleNudgeTimer);
|
|
1881
2035
|
clearInterval(cadenceWatchdogTimer);
|
|
@@ -1885,6 +2039,7 @@ export async function createServer() {
|
|
|
1885
2039
|
stopShippedHeartbeat();
|
|
1886
2040
|
stopTeamPulse();
|
|
1887
2041
|
stopReminderEngine();
|
|
2042
|
+
stopDeployMonitor();
|
|
1888
2043
|
stopKeepalive();
|
|
1889
2044
|
stopSelfKeepalive();
|
|
1890
2045
|
wsHeartbeat.stop();
|
|
@@ -2703,6 +2858,9 @@ export async function createServer() {
|
|
|
2703
2858
|
reflectionPipeline: { registered: Boolean(reflectionPipelineTimer), lastTickAt: ticks.reflection_pipeline, lastTickAgeSec: ageSec(ticks.reflection_pipeline) },
|
|
2704
2859
|
boardHealthWorker: { registered: board.running, lastTickAt: ticks.board_health || board.lastTickAt, lastTickAgeSec: ageSec(ticks.board_health || board.lastTickAt) },
|
|
2705
2860
|
},
|
|
2861
|
+
reviewHandoffValidation: {
|
|
2862
|
+
...reviewHandoffValidationStats,
|
|
2863
|
+
},
|
|
2706
2864
|
reflectionPipelineHealth: {
|
|
2707
2865
|
...reflectionPipelineHealth,
|
|
2708
2866
|
},
|
|
@@ -3037,6 +3195,23 @@ export async function createServer() {
|
|
|
3037
3195
|
};
|
|
3038
3196
|
}
|
|
3039
3197
|
const data = parsedBody.data;
|
|
3198
|
+
// Reserve system sender for server-internal control-plane messages.
|
|
3199
|
+
// Prevent browser clients (dashboard.js) or external callers from spoofing system alerts.
|
|
3200
|
+
//
|
|
3201
|
+
// Allow explicit internal callers (tests/tools) via header:
|
|
3202
|
+
// x-reflectt-internal: true
|
|
3203
|
+
if (data.from === 'system') {
|
|
3204
|
+
const internal = String(request.headers['x-reflectt-internal'] || '').toLowerCase() === 'true';
|
|
3205
|
+
if (!internal) {
|
|
3206
|
+
reply.code(403);
|
|
3207
|
+
return {
|
|
3208
|
+
success: false,
|
|
3209
|
+
error: 'Sender "system" is reserved (use from="dashboard" or your agent name).',
|
|
3210
|
+
code: 'SENDER_RESERVED',
|
|
3211
|
+
hint: 'Only internal callers may emit system messages. Add header x-reflectt-internal:true for test/tooling.',
|
|
3212
|
+
};
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3040
3215
|
// Require at least content or attachments
|
|
3041
3216
|
if (!data.content && (!data.attachments || data.attachments.length === 0)) {
|
|
3042
3217
|
reply.code(400);
|
|
@@ -4306,6 +4481,24 @@ export async function createServer() {
|
|
|
4306
4481
|
};
|
|
4307
4482
|
}
|
|
4308
4483
|
if (!resolved.task || !resolved.resolvedId) {
|
|
4484
|
+
// Check if this task was deleted — return 410 Gone with tombstone metadata instead of 404.
|
|
4485
|
+
const tombstone = taskManager.getTaskDeletionTombstone(request.params.id);
|
|
4486
|
+
if (tombstone) {
|
|
4487
|
+
reply.code(410);
|
|
4488
|
+
return {
|
|
4489
|
+
success: false,
|
|
4490
|
+
error: 'Task has been deleted',
|
|
4491
|
+
code: 'TASK_DELETED',
|
|
4492
|
+
status: 410,
|
|
4493
|
+
tombstone: {
|
|
4494
|
+
taskId: tombstone.taskId,
|
|
4495
|
+
deletedAt: tombstone.deletedAt,
|
|
4496
|
+
deletedBy: tombstone.deletedBy,
|
|
4497
|
+
previousStatus: tombstone.previousStatus,
|
|
4498
|
+
title: tombstone.title,
|
|
4499
|
+
},
|
|
4500
|
+
};
|
|
4501
|
+
}
|
|
4309
4502
|
reply.code(404);
|
|
4310
4503
|
return {
|
|
4311
4504
|
error: 'Task not found',
|
|
@@ -4321,6 +4514,46 @@ export async function createServer() {
|
|
|
4321
4514
|
matchType: resolved.matchType,
|
|
4322
4515
|
};
|
|
4323
4516
|
});
|
|
4517
|
+
// ── Task handoff state ─────────────────────────────────────────────
|
|
4518
|
+
app.get('/tasks/:id/handoff', async (request, reply) => {
|
|
4519
|
+
const resolved = taskManager.resolveTaskId(request.params.id);
|
|
4520
|
+
if (!resolved.task) {
|
|
4521
|
+
reply.code(404);
|
|
4522
|
+
return { error: 'Task not found' };
|
|
4523
|
+
}
|
|
4524
|
+
const meta = resolved.task.metadata;
|
|
4525
|
+
const handoff = meta?.handoff_state ?? null;
|
|
4526
|
+
return {
|
|
4527
|
+
taskId: resolved.resolvedId,
|
|
4528
|
+
status: resolved.task.status,
|
|
4529
|
+
handoff_state: handoff,
|
|
4530
|
+
};
|
|
4531
|
+
});
|
|
4532
|
+
app.put('/tasks/:id/handoff', async (request, reply) => {
|
|
4533
|
+
const resolved = taskManager.resolveTaskId(request.params.id);
|
|
4534
|
+
if (!resolved.task || !resolved.resolvedId) {
|
|
4535
|
+
reply.code(404);
|
|
4536
|
+
return { error: 'Task not found' };
|
|
4537
|
+
}
|
|
4538
|
+
const body = request.body;
|
|
4539
|
+
const result = HandoffStateSchema.safeParse(body);
|
|
4540
|
+
if (!result.success) {
|
|
4541
|
+
reply.code(422);
|
|
4542
|
+
return {
|
|
4543
|
+
error: `Invalid handoff_state: ${result.error.issues.map(i => i.message).join(', ')}`,
|
|
4544
|
+
hint: 'Required: reviewed_by (string), decision (approved|rejected|needs_changes|escalated). Optional: next_owner (string).',
|
|
4545
|
+
};
|
|
4546
|
+
}
|
|
4547
|
+
const existingMeta = (resolved.task.metadata || {});
|
|
4548
|
+
taskManager.updateTask(resolved.resolvedId, {
|
|
4549
|
+
metadata: { ...existingMeta, handoff_state: result.data },
|
|
4550
|
+
});
|
|
4551
|
+
return {
|
|
4552
|
+
success: true,
|
|
4553
|
+
taskId: resolved.resolvedId,
|
|
4554
|
+
handoff_state: result.data,
|
|
4555
|
+
};
|
|
4556
|
+
});
|
|
4324
4557
|
// Task artifact visibility — resolves artifact paths and checks accessibility
|
|
4325
4558
|
app.get('/tasks/:id/artifacts', async (request, reply) => {
|
|
4326
4559
|
const resolved = resolveTaskFromParam(request.params.id, reply);
|
|
@@ -4987,6 +5220,24 @@ export async function createServer() {
|
|
|
4987
5220
|
// fan out inbox-visible notifications to assignee/reviewer + explicit @mentions.
|
|
4988
5221
|
// Notification routing respects per-agent preferences (quiet hours, mute, filters).
|
|
4989
5222
|
const task = taskManager.getTask(resolved.resolvedId);
|
|
5223
|
+
// ── Transactional review_handoff.comment_id stamping ──
|
|
5224
|
+
// If the author tags this comment as the handoff entrypoint, the server stamps
|
|
5225
|
+
// metadata.review_handoff.comment_id from the persisted comment ID.
|
|
5226
|
+
// This prevents clients from supplying phantom IDs.
|
|
5227
|
+
const category = typeof data.category === 'string' ? String(data.category).trim().toLowerCase() : '';
|
|
5228
|
+
if (task && (category === 'review_handoff' || category === 'handoff')) {
|
|
5229
|
+
const meta = (task.metadata || {});
|
|
5230
|
+
const rh = meta.review_handoff;
|
|
5231
|
+
if (rh && typeof rh === 'object' && !Array.isArray(rh)) {
|
|
5232
|
+
const rhAny = rh;
|
|
5233
|
+
if (rhAny.comment_id !== comment.id) {
|
|
5234
|
+
taskManager.patchTaskMetadata(task.id, {
|
|
5235
|
+
review_handoff: { ...rhAny, comment_id: comment.id },
|
|
5236
|
+
review_handoff_comment_id_stamped_at: Date.now(),
|
|
5237
|
+
});
|
|
5238
|
+
}
|
|
5239
|
+
}
|
|
5240
|
+
}
|
|
4990
5241
|
// Never fan out notifications for test-harness tasks.
|
|
4991
5242
|
// Our repo contains a few "LIVE server" tests (BASE=127.0.0.1:4445) that create
|
|
4992
5243
|
// tasks/comments with metadata.is_test=true. Without this guard, running `npm test`
|
|
@@ -5469,7 +5720,9 @@ export async function createServer() {
|
|
|
5469
5720
|
|| data.metadata?.skip_dedup === true
|
|
5470
5721
|
|| data.metadata?.is_test === true;
|
|
5471
5722
|
if (!skipDedup && data.assignee) {
|
|
5472
|
-
|
|
5723
|
+
// 60-second window targets gateway reconnect double-fire (typical gap: <10s)
|
|
5724
|
+
// without blocking legitimate same-title task creation later in the day.
|
|
5725
|
+
const TASK_DEDUP_WINDOW_MS = 60_000; // 60 seconds
|
|
5473
5726
|
const cutoff = Date.now() - TASK_DEDUP_WINDOW_MS;
|
|
5474
5727
|
const normalizedTitle = data.title.trim().toLowerCase();
|
|
5475
5728
|
const activeTasks = taskManager.listTasks({ includeTest: true }).filter(t => t.status !== 'done'
|
|
@@ -5477,16 +5730,15 @@ export async function createServer() {
|
|
|
5477
5730
|
&& t.createdAt >= cutoff
|
|
5478
5731
|
&& t.title.trim().toLowerCase() === normalizedTitle);
|
|
5479
5732
|
if (activeTasks.length > 0) {
|
|
5733
|
+
// Return 200 with the existing task (collapse, not reject).
|
|
5734
|
+
// Agents that create-on-reconnect receive a success response identical
|
|
5735
|
+
// to what they'd get from a new creation — no retry loop triggered.
|
|
5480
5736
|
const existing = activeTasks[0];
|
|
5481
|
-
reply.code(409);
|
|
5482
5737
|
return {
|
|
5483
|
-
success:
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
status:
|
|
5487
|
-
existing_id: existing.id,
|
|
5488
|
-
existing_status: existing.status,
|
|
5489
|
-
hint: `Task "${existing.title}" already exists for ${data.assignee} (${existing.id}, status: ${existing.status}). Created ${Math.round((Date.now() - existing.createdAt) / 60000)}m ago.`,
|
|
5738
|
+
success: true,
|
|
5739
|
+
task: existing,
|
|
5740
|
+
deduplicated: true,
|
|
5741
|
+
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).`,
|
|
5490
5742
|
};
|
|
5491
5743
|
}
|
|
5492
5744
|
}
|
|
@@ -5879,6 +6131,14 @@ export async function createServer() {
|
|
|
5879
6131
|
const pruned = boardHealthWorker.pruneAuditLog(maxAgeDays);
|
|
5880
6132
|
return { success: true, pruned };
|
|
5881
6133
|
});
|
|
6134
|
+
app.post('/board-health/quiet-window', async () => {
|
|
6135
|
+
boardHealthWorker.resetQuietWindow();
|
|
6136
|
+
return {
|
|
6137
|
+
success: true,
|
|
6138
|
+
quietUntil: Date.now() + (boardHealthWorker.getStatus().config?.restartQuietWindowMs ?? 300_000),
|
|
6139
|
+
message: 'Quiet window reset — ready-queue alerts suppressed for restart window',
|
|
6140
|
+
};
|
|
6141
|
+
});
|
|
5882
6142
|
// ── Agent change feed ─────────────────────────────────────────────────
|
|
5883
6143
|
app.get('/feed/:agent', async (request) => {
|
|
5884
6144
|
const { agent } = request.params;
|
|
@@ -6106,7 +6366,23 @@ export async function createServer() {
|
|
|
6106
6366
|
}
|
|
6107
6367
|
// Merge incoming metadata with existing for gate checks + persistence.
|
|
6108
6368
|
// Apply auto-defaults (ETA, artifact_path) when not explicitly provided.
|
|
6109
|
-
|
|
6369
|
+
// Do not accept caller-supplied review_handoff.comment_id (it must be stamped server-side from POST /tasks/:id/comments).
|
|
6370
|
+
// If a client tries to patch it directly, we strip it to prevent phantom pointers.
|
|
6371
|
+
const incomingMetaRaw = (parsed.metadata || {});
|
|
6372
|
+
const incomingMeta = { ...incomingMetaRaw };
|
|
6373
|
+
const incomingRh = incomingMeta.review_handoff;
|
|
6374
|
+
if (incomingRh && typeof incomingRh === 'object' && !Array.isArray(incomingRh)) {
|
|
6375
|
+
const rhAny = incomingRh;
|
|
6376
|
+
if (typeof rhAny.comment_id === 'string') {
|
|
6377
|
+
const { comment_id, ...rest } = rhAny;
|
|
6378
|
+
incomingMeta.review_handoff = rest;
|
|
6379
|
+
incomingMeta.review_handoff_comment_id_stripped = {
|
|
6380
|
+
stripped: true,
|
|
6381
|
+
attempted: comment_id,
|
|
6382
|
+
at: Date.now(),
|
|
6383
|
+
};
|
|
6384
|
+
}
|
|
6385
|
+
}
|
|
6110
6386
|
const effectiveTargetStatus = parsed.status ?? existing.status;
|
|
6111
6387
|
const autoFilledMeta = applyAutoDefaults(lookup.resolvedId, effectiveTargetStatus, incomingMeta);
|
|
6112
6388
|
const mergedRawMeta = { ...(existing.metadata || {}), ...autoFilledMeta };
|
|
@@ -6147,6 +6423,22 @@ export async function createServer() {
|
|
|
6147
6423
|
mergedMeta.reopened_from = existing.status;
|
|
6148
6424
|
}
|
|
6149
6425
|
}
|
|
6426
|
+
// ── Handoff state validation ──
|
|
6427
|
+
if (mergedMeta.handoff_state && typeof mergedMeta.handoff_state === 'object') {
|
|
6428
|
+
const handoffResult = HandoffStateSchema.safeParse(mergedMeta.handoff_state);
|
|
6429
|
+
if (!handoffResult.success) {
|
|
6430
|
+
reply.code(422);
|
|
6431
|
+
return {
|
|
6432
|
+
success: false,
|
|
6433
|
+
error: `Invalid handoff_state: ${handoffResult.error.issues.map(i => i.message).join(', ')}`,
|
|
6434
|
+
code: 'INVALID_HANDOFF_STATE',
|
|
6435
|
+
hint: 'handoff_state must have: reviewed_by (string), decision (approved|rejected|needs_changes|escalated), optional next_owner (string). Max 3 fields per COO rule.',
|
|
6436
|
+
gate: 'handoff_state',
|
|
6437
|
+
};
|
|
6438
|
+
}
|
|
6439
|
+
// Stamp validated handoff
|
|
6440
|
+
mergedMeta.handoff_state = handoffResult.data;
|
|
6441
|
+
}
|
|
6150
6442
|
// ── Cancel reason gate: require cancel_reason when transitioning to cancelled ──
|
|
6151
6443
|
if (parsed.status === 'cancelled') {
|
|
6152
6444
|
const meta = (incomingMeta ?? {});
|
|
@@ -6285,8 +6577,12 @@ export async function createServer() {
|
|
|
6285
6577
|
hint: qaGate.hint,
|
|
6286
6578
|
};
|
|
6287
6579
|
}
|
|
6288
|
-
const handoffGate = enforceReviewHandoffGateForValidating(effectiveStatus, lookup.resolvedId, mergedMeta);
|
|
6580
|
+
const handoffGate = await enforceReviewHandoffGateForValidating(effectiveStatus, lookup.resolvedId, mergedMeta);
|
|
6289
6581
|
if (!handoffGate.ok) {
|
|
6582
|
+
reviewHandoffValidationStats.failures += 1;
|
|
6583
|
+
reviewHandoffValidationStats.lastFailureAt = Date.now();
|
|
6584
|
+
reviewHandoffValidationStats.lastFailureTaskId = lookup.resolvedId;
|
|
6585
|
+
reviewHandoffValidationStats.lastFailureError = handoffGate.error;
|
|
6290
6586
|
reply.code(400);
|
|
6291
6587
|
return {
|
|
6292
6588
|
success: false,
|
|
@@ -6595,20 +6891,24 @@ export async function createServer() {
|
|
|
6595
6891
|
if (task.assignee) {
|
|
6596
6892
|
if (parsed.status === 'done') {
|
|
6597
6893
|
presenceManager.recordActivity(task.assignee, 'task_completed');
|
|
6598
|
-
presenceManager.updatePresence(task.assignee, 'working');
|
|
6894
|
+
presenceManager.updatePresence(task.assignee, 'working', null);
|
|
6599
6895
|
trackTaskEvent('completed');
|
|
6600
6896
|
}
|
|
6601
6897
|
else if (parsed.status === 'doing') {
|
|
6602
|
-
presenceManager.updatePresence(task.assignee, 'working');
|
|
6898
|
+
presenceManager.updatePresence(task.assignee, 'working', task.id);
|
|
6603
6899
|
}
|
|
6604
6900
|
else if (parsed.status === 'blocked') {
|
|
6605
|
-
presenceManager.updatePresence(task.assignee, 'blocked');
|
|
6901
|
+
presenceManager.updatePresence(task.assignee, 'blocked', task.id);
|
|
6606
6902
|
}
|
|
6607
6903
|
else if (parsed.status === 'validating') {
|
|
6608
|
-
presenceManager.updatePresence(task.assignee, 'reviewing');
|
|
6904
|
+
presenceManager.updatePresence(task.assignee, 'reviewing', task.id);
|
|
6609
6905
|
}
|
|
6610
6906
|
}
|
|
6611
6907
|
// ── Reviewer notification: @mention reviewer when task enters validating ──
|
|
6908
|
+
// NOTE: A dedup_key is set here so the inline chat dedup guard suppresses
|
|
6909
|
+
// any duplicate reviewRequested send that may arrive via the statusNotifTargets
|
|
6910
|
+
// loop below for the same task+transition. Without it, two messages fire for
|
|
6911
|
+
// every todo→validating transition (this direct send + the loop send).
|
|
6612
6912
|
if (parsed.status === 'validating' && existing.status !== 'validating' && existing.reviewer) {
|
|
6613
6913
|
const taskMeta = task.metadata;
|
|
6614
6914
|
const prUrl = taskMeta?.review_handoff?.pr_url
|
|
@@ -6617,6 +6917,7 @@ export async function createServer() {
|
|
|
6617
6917
|
const prLine = prUrl ? `\nPR: ${prUrl}` : '';
|
|
6618
6918
|
chatManager.sendMessage({
|
|
6619
6919
|
from: 'system',
|
|
6920
|
+
to: existing.reviewer,
|
|
6620
6921
|
content: `@${existing.reviewer} [reviewRequested:${task.id}] ${task.title} → validating${prLine}`,
|
|
6621
6922
|
channel: 'task-notifications',
|
|
6622
6923
|
metadata: {
|
|
@@ -6624,6 +6925,7 @@ export async function createServer() {
|
|
|
6624
6925
|
taskId: task.id,
|
|
6625
6926
|
reviewer: existing.reviewer,
|
|
6626
6927
|
prUrl: prUrl || undefined,
|
|
6928
|
+
dedup_key: `review-requested:${task.id}:${task.updatedAt}`,
|
|
6627
6929
|
},
|
|
6628
6930
|
}).catch(() => { }); // Non-blocking
|
|
6629
6931
|
}
|
|
@@ -6726,6 +7028,7 @@ export async function createServer() {
|
|
|
6726
7028
|
const reviewMsg = `@${task.reviewer} review requested: **${task.title}** (${task.id})${prLink}. Please approve or flag issues.`;
|
|
6727
7029
|
chatManager.sendMessage({
|
|
6728
7030
|
from: 'system',
|
|
7031
|
+
to: task.reviewer,
|
|
6729
7032
|
content: reviewMsg,
|
|
6730
7033
|
channel: 'reviews',
|
|
6731
7034
|
metadata: {
|
|
@@ -6746,13 +7049,17 @@ export async function createServer() {
|
|
|
6746
7049
|
// Dedupe guard: prevent stale/out-of-order notification events
|
|
6747
7050
|
const { shouldEmitNotification } = await import('./notificationDedupeGuard.js');
|
|
6748
7051
|
for (const target of statusNotifTargets) {
|
|
6749
|
-
// Check dedupe guard before emitting
|
|
7052
|
+
// Check dedupe guard before emitting.
|
|
7053
|
+
// Pass targetAgent so each recipient gets an independent cursor — prevents
|
|
7054
|
+
// the first recipient's cursor update from suppressing later recipients for
|
|
7055
|
+
// the same event (e.g. assignee + reviewer both getting taskCompleted on 'done').
|
|
6750
7056
|
const dedupeCheck = shouldEmitNotification({
|
|
6751
7057
|
taskId: task.id,
|
|
6752
7058
|
eventUpdatedAt: task.updatedAt,
|
|
6753
7059
|
eventStatus: parsed.status,
|
|
6754
7060
|
currentTaskStatus: task.status,
|
|
6755
7061
|
currentTaskUpdatedAt: task.updatedAt,
|
|
7062
|
+
targetAgent: target.agent,
|
|
6756
7063
|
});
|
|
6757
7064
|
if (!dedupeCheck.emit) {
|
|
6758
7065
|
console.log(`[NotifDedupe] Suppressed: ${dedupeCheck.reason}`);
|
|
@@ -6765,7 +7072,13 @@ export async function createServer() {
|
|
|
6765
7072
|
message: `Task ${task.id} → ${parsed.status}`,
|
|
6766
7073
|
});
|
|
6767
7074
|
if (routing.shouldNotify) {
|
|
6768
|
-
// Route through inbox/chat based on delivery method preference
|
|
7075
|
+
// Route through inbox/chat based on delivery method preference.
|
|
7076
|
+
// For reviewRequested, set a dedup_key matching the direct send above so the
|
|
7077
|
+
// inline chat dedup suppresses this copy (the direct send fires first with a
|
|
7078
|
+
// richer payload including PR URL and `to:` routing).
|
|
7079
|
+
const dedupKey = target.type === 'reviewRequested'
|
|
7080
|
+
? `review-requested:${task.id}:${task.updatedAt}`
|
|
7081
|
+
: undefined;
|
|
6769
7082
|
chatManager.sendMessage({
|
|
6770
7083
|
from: 'system',
|
|
6771
7084
|
content: `@${target.agent} [${target.type}:${task.id}] ${task.title} → ${parsed.status}`,
|
|
@@ -6776,6 +7089,7 @@ export async function createServer() {
|
|
|
6776
7089
|
status: parsed.status,
|
|
6777
7090
|
updatedAt: task.updatedAt,
|
|
6778
7091
|
deliveryMethod: routing.deliveryMethod,
|
|
7092
|
+
...(dedupKey ? { dedup_key: dedupKey } : {}),
|
|
6779
7093
|
},
|
|
6780
7094
|
}).catch(() => { }); // Non-blocking
|
|
6781
7095
|
}
|
|
@@ -6844,7 +7158,29 @@ export async function createServer() {
|
|
|
6844
7158
|
const sourceInfo = getAgentRolesSource();
|
|
6845
7159
|
return { success: true, agents: enriched, config: sourceInfo };
|
|
6846
7160
|
};
|
|
7161
|
+
app.get('/agents', async () => buildRoleRegistryPayload());
|
|
6847
7162
|
app.get('/agents/roles', async () => buildRoleRegistryPayload());
|
|
7163
|
+
// Host-native identity resolution — resolves agent by name, alias, or display name
|
|
7164
|
+
// without requiring the OpenClaw gateway. Merges YAML roles + agent_config table.
|
|
7165
|
+
app.get('/agents/:name/identity', async (request) => {
|
|
7166
|
+
const { name } = request.params;
|
|
7167
|
+
const resolved = resolveAgentMention(name);
|
|
7168
|
+
const role = resolved ? getAgentRole(resolved) : getAgentRole(name);
|
|
7169
|
+
if (!role) {
|
|
7170
|
+
return { found: false, query: name, hint: 'Agent not found in YAML roles or config' };
|
|
7171
|
+
}
|
|
7172
|
+
return {
|
|
7173
|
+
found: true,
|
|
7174
|
+
agentId: role.name,
|
|
7175
|
+
displayName: role.displayName ?? role.name,
|
|
7176
|
+
role: role.role,
|
|
7177
|
+
description: role.description ?? null,
|
|
7178
|
+
aliases: role.aliases ?? [],
|
|
7179
|
+
affinityTags: role.affinityTags ?? [],
|
|
7180
|
+
wipCap: role.wipCap,
|
|
7181
|
+
source: 'yaml',
|
|
7182
|
+
};
|
|
7183
|
+
});
|
|
6848
7184
|
// Team-scoped alias for assignment-engine consumers
|
|
6849
7185
|
app.get('/team/roles', async () => {
|
|
6850
7186
|
const payload = buildRoleRegistryPayload();
|
|
@@ -6860,6 +7196,13 @@ export async function createServer() {
|
|
|
6860
7196
|
// ── File upload/download ──
|
|
6861
7197
|
app.post('/files', async (request, reply) => {
|
|
6862
7198
|
try {
|
|
7199
|
+
const { MAX_SIZE_BYTES: maxBytes } = await import('./files.js');
|
|
7200
|
+
// Early rejection via Content-Length before reading body
|
|
7201
|
+
const declaredLength = parseInt(String(request.headers['content-length'] || ''), 10);
|
|
7202
|
+
if (!Number.isNaN(declaredLength) && declaredLength > maxBytes) {
|
|
7203
|
+
reply.code(413);
|
|
7204
|
+
return { success: false, error: `File exceeds ${maxBytes / (1024 * 1024)}MB limit (Content-Length: ${declaredLength} bytes)` };
|
|
7205
|
+
}
|
|
6863
7206
|
const data = await request.file();
|
|
6864
7207
|
if (!data) {
|
|
6865
7208
|
reply.code(400);
|
|
@@ -6869,10 +7212,10 @@ export async function createServer() {
|
|
|
6869
7212
|
for await (const chunk of data.file)
|
|
6870
7213
|
chunks.push(chunk);
|
|
6871
7214
|
const buffer = Buffer.concat(chunks);
|
|
6872
|
-
// Check if stream was truncated (exceeds limit)
|
|
7215
|
+
// Check if stream was truncated (exceeds multipart limit)
|
|
6873
7216
|
if (data.file.truncated) {
|
|
6874
7217
|
reply.code(413);
|
|
6875
|
-
return { success: false, error:
|
|
7218
|
+
return { success: false, error: `File exceeds ${maxBytes / (1024 * 1024)}MB limit` };
|
|
6876
7219
|
}
|
|
6877
7220
|
const fields = data.fields;
|
|
6878
7221
|
const uploadedBy = typeof fields?.uploadedBy?.value === 'string' ? fields.uploadedBy.value : 'anonymous';
|
|
@@ -6894,10 +7237,11 @@ export async function createServer() {
|
|
|
6894
7237
|
return result;
|
|
6895
7238
|
}
|
|
6896
7239
|
catch (err) {
|
|
7240
|
+
const { MAX_SIZE_BYTES: maxBytes } = await import('./files.js');
|
|
6897
7241
|
const msg = err instanceof Error ? err.message : String(err);
|
|
6898
7242
|
if (msg.includes('Request file too large')) {
|
|
6899
7243
|
reply.code(413);
|
|
6900
|
-
return { success: false, error:
|
|
7244
|
+
return { success: false, error: `File exceeds ${maxBytes / (1024 * 1024)}MB limit` };
|
|
6901
7245
|
}
|
|
6902
7246
|
reply.code(500);
|
|
6903
7247
|
return { success: false, error: 'Upload failed' };
|
|
@@ -7093,6 +7437,137 @@ export async function createServer() {
|
|
|
7093
7437
|
return { success: false, error: msg };
|
|
7094
7438
|
}
|
|
7095
7439
|
});
|
|
7440
|
+
// POST /agents — Add a single agent to the team
|
|
7441
|
+
app.post('/agents', async (request, reply) => {
|
|
7442
|
+
const body = request.body;
|
|
7443
|
+
const name = typeof body.name === 'string' ? body.name.trim().toLowerCase() : '';
|
|
7444
|
+
const role = typeof body.role === 'string' ? body.role.trim() : '';
|
|
7445
|
+
const description = typeof body.description === 'string' ? body.description.trim() : '';
|
|
7446
|
+
if (!name) {
|
|
7447
|
+
reply.code(400);
|
|
7448
|
+
return { success: false, error: 'name is required' };
|
|
7449
|
+
}
|
|
7450
|
+
if (!role) {
|
|
7451
|
+
reply.code(400);
|
|
7452
|
+
return { success: false, error: 'role is required' };
|
|
7453
|
+
}
|
|
7454
|
+
if (/[^a-z0-9_-]/.test(name)) {
|
|
7455
|
+
reply.code(400);
|
|
7456
|
+
return { success: false, error: 'name must be lowercase alphanumeric (a-z, 0-9, -, _)' };
|
|
7457
|
+
}
|
|
7458
|
+
// Check if agent already exists
|
|
7459
|
+
const existing = getAgentRoles().find(r => r.name === name);
|
|
7460
|
+
if (existing) {
|
|
7461
|
+
reply.code(409);
|
|
7462
|
+
return { success: false, error: `Agent "${name}" already exists (role: ${existing.role})` };
|
|
7463
|
+
}
|
|
7464
|
+
// Read existing YAML and append new agent
|
|
7465
|
+
const { readFileSync, writeFileSync, existsSync } = await import('node:fs');
|
|
7466
|
+
const { join } = await import('node:path');
|
|
7467
|
+
const filePath = join(REFLECTT_HOME, 'TEAM-ROLES.yaml');
|
|
7468
|
+
let yaml = '';
|
|
7469
|
+
if (existsSync(filePath)) {
|
|
7470
|
+
yaml = readFileSync(filePath, 'utf-8');
|
|
7471
|
+
}
|
|
7472
|
+
if (!yaml.includes('agents:')) {
|
|
7473
|
+
yaml = 'agents:\n';
|
|
7474
|
+
}
|
|
7475
|
+
// Build agent YAML entry
|
|
7476
|
+
const affinityTags = Array.isArray(body.affinityTags) ? body.affinityTags : [role];
|
|
7477
|
+
const wipCap = typeof body.wipCap === 'number' ? body.wipCap : 2;
|
|
7478
|
+
const desc = description || `${role} agent.`;
|
|
7479
|
+
const entry = [
|
|
7480
|
+
` - name: ${name}`,
|
|
7481
|
+
` role: ${role}`,
|
|
7482
|
+
` description: ${desc}`,
|
|
7483
|
+
` affinityTags: [${affinityTags.join(', ')}]`,
|
|
7484
|
+
` wipCap: ${wipCap}`,
|
|
7485
|
+
].join('\n');
|
|
7486
|
+
// Insert before lanes: section (if present), otherwise append
|
|
7487
|
+
const lanesIdx = yaml.indexOf('\nlanes:');
|
|
7488
|
+
if (lanesIdx >= 0) {
|
|
7489
|
+
yaml = yaml.slice(0, lanesIdx) + '\n' + entry + yaml.slice(lanesIdx);
|
|
7490
|
+
}
|
|
7491
|
+
else {
|
|
7492
|
+
yaml = yaml.trimEnd() + '\n' + entry + '\n';
|
|
7493
|
+
}
|
|
7494
|
+
try {
|
|
7495
|
+
writeFileSync(filePath, yaml, 'utf-8');
|
|
7496
|
+
const { loadAgentRoles } = await import('./assignment.js');
|
|
7497
|
+
const reloaded = loadAgentRoles();
|
|
7498
|
+
// Scaffold agent workspace if it doesn't exist
|
|
7499
|
+
let workspaceCreated = false;
|
|
7500
|
+
try {
|
|
7501
|
+
const { mkdirSync, existsSync: dirExists } = await import('node:fs');
|
|
7502
|
+
const workspaceDir = join(REFLECTT_HOME, `workspace-${name}`);
|
|
7503
|
+
if (!dirExists(workspaceDir)) {
|
|
7504
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
7505
|
+
writeFileSync(join(workspaceDir, 'SOUL.md'), `# ${name}\n\n*${desc}*\n`, 'utf-8');
|
|
7506
|
+
writeFileSync(join(workspaceDir, 'AGENTS.md'), `# ${name}\n\nRole: ${role}\n\n${desc}\n`, 'utf-8');
|
|
7507
|
+
workspaceCreated = true;
|
|
7508
|
+
}
|
|
7509
|
+
}
|
|
7510
|
+
catch (wsErr) {
|
|
7511
|
+
console.warn(`[Agents] Workspace scaffold failed for ${name}:`, wsErr.message);
|
|
7512
|
+
}
|
|
7513
|
+
return {
|
|
7514
|
+
success: true,
|
|
7515
|
+
agent: { name, role, description: desc, wipCap },
|
|
7516
|
+
totalAgents: reloaded.roles.length,
|
|
7517
|
+
workspaceCreated,
|
|
7518
|
+
hint: `Agent "${name}" added to team and hot-reloaded. Start heartbeating: GET /heartbeat/${name}`,
|
|
7519
|
+
};
|
|
7520
|
+
}
|
|
7521
|
+
catch (err) {
|
|
7522
|
+
const msg = err instanceof Error ? err.message : 'Failed to save agent';
|
|
7523
|
+
reply.code(500);
|
|
7524
|
+
return { success: false, error: msg };
|
|
7525
|
+
}
|
|
7526
|
+
});
|
|
7527
|
+
// DELETE /agents/:name — Remove an agent from the team
|
|
7528
|
+
app.delete('/agents/:name', async (request, reply) => {
|
|
7529
|
+
const name = request.params.name.toLowerCase();
|
|
7530
|
+
const existing = getAgentRoles().find(r => r.name === name);
|
|
7531
|
+
if (!existing) {
|
|
7532
|
+
reply.code(404);
|
|
7533
|
+
return { success: false, error: `Agent "${name}" not found` };
|
|
7534
|
+
}
|
|
7535
|
+
const { readFileSync, writeFileSync } = await import('node:fs');
|
|
7536
|
+
const { join } = await import('node:path');
|
|
7537
|
+
const filePath = join(REFLECTT_HOME, 'TEAM-ROLES.yaml');
|
|
7538
|
+
let yaml = readFileSync(filePath, 'utf-8');
|
|
7539
|
+
// Remove the agent block: from " - name: <name>" to the next " - name:" or top-level key or EOF
|
|
7540
|
+
const lines = yaml.split('\n');
|
|
7541
|
+
const filtered = [];
|
|
7542
|
+
let skipping = false;
|
|
7543
|
+
for (const line of lines) {
|
|
7544
|
+
if (line.match(new RegExp(`^\\s+-\\s+name:\\s+${name}\\s*$`))) {
|
|
7545
|
+
skipping = true;
|
|
7546
|
+
continue;
|
|
7547
|
+
}
|
|
7548
|
+
if (skipping) {
|
|
7549
|
+
// Stop skipping at next agent entry, top-level key, or blank line before top-level
|
|
7550
|
+
if (line.match(/^\s+-\s+name:\s/) || line.match(/^[a-z]/)) {
|
|
7551
|
+
skipping = false;
|
|
7552
|
+
filtered.push(line);
|
|
7553
|
+
}
|
|
7554
|
+
continue;
|
|
7555
|
+
}
|
|
7556
|
+
filtered.push(line);
|
|
7557
|
+
}
|
|
7558
|
+
yaml = filtered.join('\n');
|
|
7559
|
+
try {
|
|
7560
|
+
writeFileSync(filePath, yaml, 'utf-8');
|
|
7561
|
+
const { loadAgentRoles } = await import('./assignment.js');
|
|
7562
|
+
const reloaded = loadAgentRoles();
|
|
7563
|
+
return { success: true, removed: name, totalAgents: reloaded.roles.length };
|
|
7564
|
+
}
|
|
7565
|
+
catch (err) {
|
|
7566
|
+
const msg = err instanceof Error ? err.message : 'Failed to remove agent';
|
|
7567
|
+
reply.code(500);
|
|
7568
|
+
return { success: false, error: msg };
|
|
7569
|
+
}
|
|
7570
|
+
});
|
|
7096
7571
|
// Resolve a mention string (name, displayName, or alias) to an agent ID
|
|
7097
7572
|
app.get('/resolve/mention/:mention', async (request) => {
|
|
7098
7573
|
const agentName = resolveAgentMention(request.params.mention);
|
|
@@ -7129,55 +7604,7 @@ export async function createServer() {
|
|
|
7129
7604
|
};
|
|
7130
7605
|
});
|
|
7131
7606
|
// ── Approval Queue ──────────────────────────────────────────────────
|
|
7132
|
-
|
|
7133
|
-
// Tasks in 'todo' that were auto-assigned (have suggestedAgent in metadata) or need assignment review
|
|
7134
|
-
const allTasks = taskManager.listTasks({});
|
|
7135
|
-
const todoTasks = allTasks.filter(t => t.status === 'todo');
|
|
7136
|
-
const items = todoTasks.map(t => {
|
|
7137
|
-
const task = t;
|
|
7138
|
-
const meta = task.metadata || {};
|
|
7139
|
-
const title = task.title || '';
|
|
7140
|
-
const tags = Array.isArray(task.tags) ? task.tags : [];
|
|
7141
|
-
const doneCriteria = Array.isArray(task.done_criteria) ? task.done_criteria : [];
|
|
7142
|
-
// Score all agents for this task
|
|
7143
|
-
const roles = getAgentRoles();
|
|
7144
|
-
const agentOptions = roles.map(agent => {
|
|
7145
|
-
const wipCount = allTasks.filter(at => at.status === 'doing' && (at.assignee || '').toLowerCase() === agent.name).length;
|
|
7146
|
-
const s = scoreAssignment(agent, { title, tags, done_criteria: doneCriteria }, wipCount);
|
|
7147
|
-
return {
|
|
7148
|
-
agentId: agent.name,
|
|
7149
|
-
name: agent.name,
|
|
7150
|
-
confidenceScore: Math.max(0, Math.min(1, s.score)),
|
|
7151
|
-
affinityTags: agent.affinityTags,
|
|
7152
|
-
};
|
|
7153
|
-
}).sort((a, b) => b.confidenceScore - a.confidenceScore);
|
|
7154
|
-
const topAgent = agentOptions[0];
|
|
7155
|
-
const suggestedAgent = task.assignee || topAgent?.agentId || null;
|
|
7156
|
-
const confidenceScore = topAgent?.confidenceScore || 0;
|
|
7157
|
-
const confidenceReason = topAgent && topAgent.confidenceScore > 0
|
|
7158
|
-
? `${topAgent.name}: affinity match on ${topAgent.affinityTags.slice(0, 3).join(', ')}`
|
|
7159
|
-
: 'No strong affinity match';
|
|
7160
|
-
return {
|
|
7161
|
-
taskId: task.id,
|
|
7162
|
-
title,
|
|
7163
|
-
description: task.description || '',
|
|
7164
|
-
priority: task.priority || 'P3',
|
|
7165
|
-
suggestedAgent,
|
|
7166
|
-
confidenceScore,
|
|
7167
|
-
confidenceReason,
|
|
7168
|
-
agentOptions,
|
|
7169
|
-
status: 'pending',
|
|
7170
|
-
};
|
|
7171
|
-
});
|
|
7172
|
-
const highConfidence = items.filter(i => i.confidenceScore >= 0.85);
|
|
7173
|
-
const needsReview = items.filter(i => i.confidenceScore < 0.85);
|
|
7174
|
-
return {
|
|
7175
|
-
items: [...highConfidence, ...needsReview],
|
|
7176
|
-
total: items.length,
|
|
7177
|
-
highConfidenceCount: highConfidence.length,
|
|
7178
|
-
needsReviewCount: needsReview.length,
|
|
7179
|
-
};
|
|
7180
|
-
});
|
|
7607
|
+
// Note: GET /approval-queue is defined below near /approval-queue/:approvalId/decide
|
|
7181
7608
|
app.post('/approval-queue/:taskId/approve', async (request, reply) => {
|
|
7182
7609
|
const body = request.body;
|
|
7183
7610
|
const taskId = request.params.taskId;
|
|
@@ -7333,6 +7760,88 @@ export async function createServer() {
|
|
|
7333
7760
|
warnings: result.warnings,
|
|
7334
7761
|
};
|
|
7335
7762
|
});
|
|
7763
|
+
// ── Presence Layer canvas state ─────────────────────────────────────
|
|
7764
|
+
// Agent emits canvas_render state transitions for the Presence Layer.
|
|
7765
|
+
// Deterministic event types. No "AI can emit anything" protocol.
|
|
7766
|
+
const CANVAS_STATES = ['floor', 'listening', 'thinking', 'rendering', 'ambient', 'decision', 'urgent', 'handoff'];
|
|
7767
|
+
const SENSOR_VALUES = [null, 'mic', 'camera', 'mic+camera'];
|
|
7768
|
+
const CanvasRenderSchema = z.object({
|
|
7769
|
+
state: z.enum(CANVAS_STATES),
|
|
7770
|
+
sensors: z.enum(['mic', 'camera', 'mic+camera']).nullable().default(null),
|
|
7771
|
+
agentId: z.string().min(1),
|
|
7772
|
+
payload: z.object({
|
|
7773
|
+
text: z.string().optional(),
|
|
7774
|
+
media: z.unknown().optional(),
|
|
7775
|
+
decision: z.object({
|
|
7776
|
+
question: z.string(),
|
|
7777
|
+
context: z.string().optional(),
|
|
7778
|
+
decisionId: z.string(),
|
|
7779
|
+
expiresAt: z.number().optional(),
|
|
7780
|
+
autoAction: z.string().optional(),
|
|
7781
|
+
}).optional(),
|
|
7782
|
+
agents: z.array(z.object({
|
|
7783
|
+
name: z.string(),
|
|
7784
|
+
state: z.string(),
|
|
7785
|
+
task: z.string().optional(),
|
|
7786
|
+
})).optional(),
|
|
7787
|
+
summary: z.object({
|
|
7788
|
+
headline: z.string(),
|
|
7789
|
+
items: z.array(z.string()).optional(),
|
|
7790
|
+
cost: z.string().optional(),
|
|
7791
|
+
duration: z.string().optional(),
|
|
7792
|
+
}).optional(),
|
|
7793
|
+
}).default({}),
|
|
7794
|
+
});
|
|
7795
|
+
// Current state per agent — in-memory, not persisted
|
|
7796
|
+
const canvasStateMap = new Map();
|
|
7797
|
+
// POST /canvas/state — agent emits a state transition
|
|
7798
|
+
app.post('/canvas/state', async (request, reply) => {
|
|
7799
|
+
const result = CanvasRenderSchema.safeParse(request.body);
|
|
7800
|
+
if (!result.success) {
|
|
7801
|
+
reply.code(422);
|
|
7802
|
+
return {
|
|
7803
|
+
error: `Invalid canvas state: ${result.error.issues.map(i => i.message).join(', ')}`,
|
|
7804
|
+
hint: `state must be one of: ${CANVAS_STATES.join(', ')}`,
|
|
7805
|
+
validStates: CANVAS_STATES,
|
|
7806
|
+
};
|
|
7807
|
+
}
|
|
7808
|
+
const { state, sensors, agentId, payload } = result.data;
|
|
7809
|
+
const now = Date.now();
|
|
7810
|
+
// Store current state
|
|
7811
|
+
canvasStateMap.set(agentId, { state, sensors, payload, updatedAt: now });
|
|
7812
|
+
// Emit canvas_render event over SSE
|
|
7813
|
+
eventBus.emit({
|
|
7814
|
+
id: `crender-${now}-${Math.random().toString(36).slice(2, 8)}`,
|
|
7815
|
+
type: 'canvas_render',
|
|
7816
|
+
timestamp: now,
|
|
7817
|
+
data: { state, sensors, agentId, payload },
|
|
7818
|
+
});
|
|
7819
|
+
return { success: true, state, agentId, timestamp: now };
|
|
7820
|
+
});
|
|
7821
|
+
// GET /canvas/state — current state for all agents (or one)
|
|
7822
|
+
app.get('/canvas/state', async (request) => {
|
|
7823
|
+
const query = request.query;
|
|
7824
|
+
if (query.agentId) {
|
|
7825
|
+
const entry = canvasStateMap.get(query.agentId);
|
|
7826
|
+
return entry ?? { state: 'floor', sensors: null, payload: {}, updatedAt: null };
|
|
7827
|
+
}
|
|
7828
|
+
const all = {};
|
|
7829
|
+
for (const [id, entry] of canvasStateMap) {
|
|
7830
|
+
all[id] = entry;
|
|
7831
|
+
}
|
|
7832
|
+
return { agents: all, count: canvasStateMap.size };
|
|
7833
|
+
});
|
|
7834
|
+
// GET /canvas/states — valid state + sensor values (discovery)
|
|
7835
|
+
app.get('/canvas/states', async () => ({
|
|
7836
|
+
states: CANVAS_STATES,
|
|
7837
|
+
sensors: SENSOR_VALUES,
|
|
7838
|
+
schema: {
|
|
7839
|
+
state: 'floor | listening | thinking | rendering | ambient | decision | urgent | handoff',
|
|
7840
|
+
sensors: 'null | mic | camera | mic+camera (non-dismissable trust indicator)',
|
|
7841
|
+
agentId: 'required — which agent is driving the canvas',
|
|
7842
|
+
payload: 'optional — text, media, decision, agents, summary',
|
|
7843
|
+
},
|
|
7844
|
+
}));
|
|
7336
7845
|
// GET /canvas/slots — current active slots
|
|
7337
7846
|
app.get('/canvas/slots', async () => {
|
|
7338
7847
|
return {
|
|
@@ -8907,6 +9416,26 @@ export async function createServer() {
|
|
|
8907
9416
|
const allDrops = chatManager.getDropStats();
|
|
8908
9417
|
const agentDrops = allDrops[agent];
|
|
8909
9418
|
const focusSummary = getFocusSummary();
|
|
9419
|
+
// Boot context: recent memories + active run (survives restart)
|
|
9420
|
+
let bootMemories = [];
|
|
9421
|
+
let activeRun = null;
|
|
9422
|
+
try {
|
|
9423
|
+
const { listMemories } = await import('./agent-memories.js');
|
|
9424
|
+
const memories = listMemories({ agentId: agent, limit: 5 });
|
|
9425
|
+
bootMemories = memories.map(m => ({
|
|
9426
|
+
key: m.key, content: m.content.slice(0, 200),
|
|
9427
|
+
namespace: m.namespace, updatedAt: m.updatedAt,
|
|
9428
|
+
}));
|
|
9429
|
+
}
|
|
9430
|
+
catch { /* agent-memories not available */ }
|
|
9431
|
+
try {
|
|
9432
|
+
const { getActiveAgentRun } = await import('./agent-runs.js');
|
|
9433
|
+
const run = getActiveAgentRun(agent, 'default');
|
|
9434
|
+
if (run) {
|
|
9435
|
+
activeRun = { id: run.id, objective: run.objective, status: run.status, startedAt: run.startedAt };
|
|
9436
|
+
}
|
|
9437
|
+
}
|
|
9438
|
+
catch { /* agent-runs not available */ }
|
|
8910
9439
|
return {
|
|
8911
9440
|
agent, ts: Date.now(),
|
|
8912
9441
|
active: slim(activeTask), next: pauseStatus.paused ? null : slim(nextTask),
|
|
@@ -8916,6 +9445,12 @@ export async function createServer() {
|
|
|
8916
9445
|
...(focusSummary ? { focus: focusSummary } : {}),
|
|
8917
9446
|
...(agentDrops ? { drops: { total: agentDrops.total, rolling_1h: agentDrops.rolling_1h } } : {}),
|
|
8918
9447
|
...(pauseStatus.paused ? { paused: true, pauseMessage: pauseStatus.message, resumesAt: pauseStatus.entry?.pausedUntil ?? null } : {}),
|
|
9448
|
+
...(bootMemories.length > 0 ? { memories: bootMemories } : {}),
|
|
9449
|
+
...(activeRun ? { run: activeRun } : {}),
|
|
9450
|
+
...(() => {
|
|
9451
|
+
const p = presenceManager.getAllPresence().find(p => p.agent === agent);
|
|
9452
|
+
return p?.waiting ? { waiting: p.waiting } : {};
|
|
9453
|
+
})(),
|
|
8919
9454
|
action: pauseStatus.paused ? `PAUSED: ${pauseStatus.message}`
|
|
8920
9455
|
: activeTask ? `Continue ${activeTask.id}`
|
|
8921
9456
|
: nextTask ? `Claim ${nextTask.id}`
|
|
@@ -8923,6 +9458,21 @@ export async function createServer() {
|
|
|
8923
9458
|
: 'HEARTBEAT_OK',
|
|
8924
9459
|
};
|
|
8925
9460
|
});
|
|
9461
|
+
// ── Agent Waiting State ──────────────────────────────────────────────
|
|
9462
|
+
// Agents signal they're blocked on human input. Shows in heartbeat + presence.
|
|
9463
|
+
app.post('/agents/:agent/waiting', async (request, reply) => {
|
|
9464
|
+
const agent = String(request.params.agent || '').trim().toLowerCase();
|
|
9465
|
+
const body = request.body ?? {};
|
|
9466
|
+
if (!body.reason)
|
|
9467
|
+
return reply.code(400).send({ error: 'reason is required' });
|
|
9468
|
+
presenceManager.setWaiting(agent, { reason: body.reason, waitingFor: body.waitingFor, taskId: body.taskId, expiresAt: body.expiresAt });
|
|
9469
|
+
return { success: true, agent, status: 'waiting', waiting: { reason: body.reason, waitingFor: body.waitingFor, taskId: body.taskId, expiresAt: body.expiresAt } };
|
|
9470
|
+
});
|
|
9471
|
+
app.delete('/agents/:agent/waiting', async (request) => {
|
|
9472
|
+
const agent = String(request.params.agent || '').trim().toLowerCase();
|
|
9473
|
+
presenceManager.clearWaiting(agent);
|
|
9474
|
+
return { success: true, agent, status: 'idle' };
|
|
9475
|
+
});
|
|
8926
9476
|
// ── Bootstrap: dynamic agent config generation ──────────────────────
|
|
8927
9477
|
app.get('/bootstrap/heartbeat/:agent', async (request) => {
|
|
8928
9478
|
const agent = String(request.params.agent || '').trim().toLowerCase();
|
|
@@ -8986,6 +9536,7 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
8986
9536
|
- Do not load full chat history.
|
|
8987
9537
|
- Do not post plan-only updates.
|
|
8988
9538
|
- If nothing changed and no direct action is required, reply \`HEARTBEAT_OK\`.
|
|
9539
|
+
- **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.
|
|
8989
9540
|
`;
|
|
8990
9541
|
// Stable hash for change detection (agents can cache and compare)
|
|
8991
9542
|
const { createHash } = await import('node:crypto');
|
|
@@ -9063,6 +9614,7 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
9063
9614
|
endpoints: [
|
|
9064
9615
|
{ method: 'POST', path: '/reflections', hint: 'Submit. Required: pain, impact, evidence[], went_well, suspected_why, proposed_fix, confidence, role_type, author' },
|
|
9065
9616
|
{ method: 'GET', path: '/reflections', hint: 'List. Query: author, limit' },
|
|
9617
|
+
{ method: 'GET', path: '/reflections/schema', hint: 'Required/optional fields, role types, severity levels, dedup rules' },
|
|
9066
9618
|
],
|
|
9067
9619
|
},
|
|
9068
9620
|
activity: {
|
|
@@ -9316,8 +9868,18 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
9316
9868
|
reply.code(404);
|
|
9317
9869
|
return { success: false, error: 'Task not found', input: id, suggestions: lookup.suggestions };
|
|
9318
9870
|
}
|
|
9319
|
-
|
|
9320
|
-
|
|
9871
|
+
const assignee = String(task.assignee || '').trim();
|
|
9872
|
+
const isUnassigned = assignee.length === 0 || assignee.toLowerCase() === 'unassigned';
|
|
9873
|
+
if (!isUnassigned) {
|
|
9874
|
+
reply.code(409);
|
|
9875
|
+
return {
|
|
9876
|
+
success: false,
|
|
9877
|
+
error: `Task already assigned to ${assignee}`,
|
|
9878
|
+
code: 'TASK_ALREADY_ASSIGNED',
|
|
9879
|
+
status: 409,
|
|
9880
|
+
assignee,
|
|
9881
|
+
hint: 'Task claims are atomic: first claim wins. Pull another task via GET /tasks/next.',
|
|
9882
|
+
};
|
|
9321
9883
|
}
|
|
9322
9884
|
const shortId = lookup.resolvedId.replace(/^task-\d+-/, '');
|
|
9323
9885
|
const branch = `${body.agent}/task-${shortId}`;
|
|
@@ -10079,17 +10641,12 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
10079
10641
|
return { success: false, error: 'agent and model are required' };
|
|
10080
10642
|
}
|
|
10081
10643
|
const event = recordUsage({
|
|
10082
|
-
|
|
10083
|
-
task_id: body.task_id,
|
|
10644
|
+
agentId: body.agent,
|
|
10084
10645
|
model: body.model,
|
|
10085
|
-
|
|
10086
|
-
|
|
10087
|
-
|
|
10088
|
-
estimated_cost_usd: body.estimated_cost_usd != null ? Number(body.estimated_cost_usd) : undefined,
|
|
10089
|
-
category: body.category || 'other',
|
|
10646
|
+
inputTokens: Number(body.input_tokens) || 0,
|
|
10647
|
+
outputTokens: Number(body.output_tokens) || 0,
|
|
10648
|
+
cost: body.estimated_cost_usd != null ? Number(body.estimated_cost_usd) : 0,
|
|
10090
10649
|
timestamp: Number(body.timestamp) || Date.now(),
|
|
10091
|
-
team_id: body.team_id,
|
|
10092
|
-
metadata: body.metadata,
|
|
10093
10650
|
});
|
|
10094
10651
|
return { success: true, event };
|
|
10095
10652
|
});
|
|
@@ -10170,6 +10727,42 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
10170
10727
|
const q = request.query;
|
|
10171
10728
|
return { suggestions: getRoutingSuggestions({ since: q.since ? Number(q.since) : undefined }) };
|
|
10172
10729
|
});
|
|
10730
|
+
// ── Cost Dashboard ──
|
|
10731
|
+
// GET /costs — aggregated spend: daily by model, avg per lane, top tasks
|
|
10732
|
+
app.get('/costs', async (request) => {
|
|
10733
|
+
const q = request.query;
|
|
10734
|
+
const days = q.days ? Math.min(Number(q.days), 90) : 7;
|
|
10735
|
+
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
10736
|
+
const dailyByModel = getDailySpendByModel({ days });
|
|
10737
|
+
const byLane = getAvgCostByLane({ days: Math.max(days, 30) }); // lane data needs more window
|
|
10738
|
+
const byAgent = getAvgCostByAgent({ days: Math.max(days, 30) });
|
|
10739
|
+
const topTasks = getUsageByTask({ since, limit: 20 });
|
|
10740
|
+
const summary = getUsageSummary({ since });
|
|
10741
|
+
// Roll up daily totals per day for the sparkline
|
|
10742
|
+
const dailyTotals = {};
|
|
10743
|
+
for (const row of dailyByModel) {
|
|
10744
|
+
dailyTotals[row.date] = (dailyTotals[row.date] ?? 0) + row.total_cost_usd;
|
|
10745
|
+
}
|
|
10746
|
+
// Note: avg_cost_by_lane and avg_cost_by_agent use Math.max(days, 30) as their window.
|
|
10747
|
+
// Lane/agent-level averages need task density to be meaningful — a 7-day window might
|
|
10748
|
+
// have 0-1 closed tasks per agent/lane and produce misleading numbers. Using a 30-day
|
|
10749
|
+
// floor is intentional. daily_by_model, daily_totals, and top_tasks_by_cost use the
|
|
10750
|
+
// requested `days` window directly and will match the `window_days` field in the response.
|
|
10751
|
+
const laneAgentWindow = Math.max(days, 30);
|
|
10752
|
+
return {
|
|
10753
|
+
window_days: days,
|
|
10754
|
+
lane_agent_window_days: laneAgentWindow,
|
|
10755
|
+
summary: Array.isArray(summary) ? summary[0] ?? null : summary,
|
|
10756
|
+
daily_by_model: dailyByModel,
|
|
10757
|
+
daily_totals: Object.entries(dailyTotals)
|
|
10758
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
10759
|
+
.map(([date, total_cost_usd]) => ({ date, total_cost_usd })),
|
|
10760
|
+
avg_cost_by_lane: byLane,
|
|
10761
|
+
avg_cost_by_agent: byAgent,
|
|
10762
|
+
top_tasks_by_cost: topTasks,
|
|
10763
|
+
generated_at: Date.now(),
|
|
10764
|
+
};
|
|
10765
|
+
});
|
|
10173
10766
|
// Operational metrics endpoint (lightweight dashboard contract)
|
|
10174
10767
|
app.get('/metrics', async () => {
|
|
10175
10768
|
const startedAt = Date.now();
|
|
@@ -10587,6 +11180,10 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
10587
11180
|
request.headers['x-request-id'] ||
|
|
10588
11181
|
undefined;
|
|
10589
11182
|
const idempotencyKey = deliveryId ? `${provider}_${deliveryId}` : undefined;
|
|
11183
|
+
// Enrich GitHub webhook payloads with agent attribution
|
|
11184
|
+
const enrichedBody = provider === 'github'
|
|
11185
|
+
? enrichWebhookPayload(body)
|
|
11186
|
+
: body;
|
|
10590
11187
|
// Enqueue through delivery engine for each configured target
|
|
10591
11188
|
const events = [];
|
|
10592
11189
|
for (const route of routes) {
|
|
@@ -10597,7 +11194,7 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
10597
11194
|
const event = webhookDelivery.enqueue({
|
|
10598
11195
|
provider,
|
|
10599
11196
|
eventType,
|
|
10600
|
-
payload:
|
|
11197
|
+
payload: enrichedBody,
|
|
10601
11198
|
targetUrl: `http://localhost:${serverConfig.port}${route.path}`,
|
|
10602
11199
|
idempotencyKey: idempotencyKey ? `${idempotencyKey}_${route.id}` : undefined,
|
|
10603
11200
|
metadata: {
|
|
@@ -10611,6 +11208,21 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
10611
11208
|
});
|
|
10612
11209
|
events.push(event);
|
|
10613
11210
|
}
|
|
11211
|
+
// Post GitHub events to the 'github' chat channel with remapped mentions.
|
|
11212
|
+
// Pass enrichedBody (not body) so formatGitHubEvent has access to
|
|
11213
|
+
// _reflectt_attribution and can mention the correct agent (@link not @kai).
|
|
11214
|
+
if (provider === 'github') {
|
|
11215
|
+
const ghEventType = request.headers['x-github-event'] || eventType;
|
|
11216
|
+
const chatMessage = formatGitHubEvent(ghEventType, enrichedBody);
|
|
11217
|
+
if (chatMessage) {
|
|
11218
|
+
chatManager.sendMessage({
|
|
11219
|
+
from: 'github',
|
|
11220
|
+
content: chatMessage,
|
|
11221
|
+
channel: 'github',
|
|
11222
|
+
metadata: { source: 'github-webhook', eventType: ghEventType, delivery: request.headers['x-github-delivery'] },
|
|
11223
|
+
}).catch(() => { }); // non-blocking
|
|
11224
|
+
}
|
|
11225
|
+
}
|
|
10614
11226
|
reply.code(202);
|
|
10615
11227
|
return { success: true, accepted: events.length, events: events.map(e => ({ id: e.id, idempotencyKey: e.idempotencyKey, status: e.status })) };
|
|
10616
11228
|
});
|
|
@@ -11344,6 +11956,62 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
11344
11956
|
app.get('/calendar/reminders/stats', async () => {
|
|
11345
11957
|
return getReminderEngineStats();
|
|
11346
11958
|
});
|
|
11959
|
+
// ── Schedule feed — team-wide time-awareness ──────────────────────────────
|
|
11960
|
+
//
|
|
11961
|
+
// Provides canonical records for deploy windows, focus blocks, and
|
|
11962
|
+
// scheduled task work so agents can coordinate timing without chat.
|
|
11963
|
+
//
|
|
11964
|
+
// MVP scope: one-off windows only. No iCal/RRULE, no reminders.
|
|
11965
|
+
// See src/schedule.ts for what is intentionally NOT included.
|
|
11966
|
+
// GET /schedule/feed — upcoming entries in chronological order
|
|
11967
|
+
app.get('/schedule/feed', async (request) => {
|
|
11968
|
+
const q = request.query;
|
|
11969
|
+
const kinds = q.kinds ? q.kinds.split(',') : undefined;
|
|
11970
|
+
const entries = getScheduleFeed({
|
|
11971
|
+
after: q.after ? parseInt(q.after, 10) : undefined,
|
|
11972
|
+
before: q.before ? parseInt(q.before, 10) : undefined,
|
|
11973
|
+
kinds,
|
|
11974
|
+
owner: q.owner,
|
|
11975
|
+
limit: q.limit ? parseInt(q.limit, 10) : undefined,
|
|
11976
|
+
});
|
|
11977
|
+
return { entries, count: entries.length };
|
|
11978
|
+
});
|
|
11979
|
+
// POST /schedule/entries — create a new schedule entry
|
|
11980
|
+
app.post('/schedule/entries', async (request, reply) => {
|
|
11981
|
+
try {
|
|
11982
|
+
const entry = createScheduleEntry(request.body);
|
|
11983
|
+
return reply.status(201).send({ entry });
|
|
11984
|
+
}
|
|
11985
|
+
catch (err) {
|
|
11986
|
+
return reply.status(400).send({ error: err.message });
|
|
11987
|
+
}
|
|
11988
|
+
});
|
|
11989
|
+
// GET /schedule/entries/:id
|
|
11990
|
+
app.get('/schedule/entries/:id', async (request, reply) => {
|
|
11991
|
+
const entry = getScheduleEntry(request.params.id);
|
|
11992
|
+
if (!entry)
|
|
11993
|
+
return reply.status(404).send({ error: 'Not found' });
|
|
11994
|
+
return { entry };
|
|
11995
|
+
});
|
|
11996
|
+
// PATCH /schedule/entries/:id
|
|
11997
|
+
app.patch('/schedule/entries/:id', async (request, reply) => {
|
|
11998
|
+
try {
|
|
11999
|
+
const entry = updateScheduleEntry(request.params.id, request.body);
|
|
12000
|
+
if (!entry)
|
|
12001
|
+
return reply.status(404).send({ error: 'Not found' });
|
|
12002
|
+
return { entry };
|
|
12003
|
+
}
|
|
12004
|
+
catch (err) {
|
|
12005
|
+
return reply.status(400).send({ error: err.message });
|
|
12006
|
+
}
|
|
12007
|
+
});
|
|
12008
|
+
// DELETE /schedule/entries/:id
|
|
12009
|
+
app.delete('/schedule/entries/:id', async (request, reply) => {
|
|
12010
|
+
const deleted = deleteScheduleEntry(request.params.id);
|
|
12011
|
+
if (!deleted)
|
|
12012
|
+
return reply.status(404).send({ error: 'Not found' });
|
|
12013
|
+
return reply.status(204).send();
|
|
12014
|
+
});
|
|
11347
12015
|
// ── iCal Import/Export ───────────────────────────────────────────────────
|
|
11348
12016
|
// Export all events as .ics
|
|
11349
12017
|
app.get('/calendar/export.ics', async (request, reply) => {
|
|
@@ -11517,6 +12185,964 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
11517
12185
|
});
|
|
11518
12186
|
// Start hourly auto-snapshot for alert-preflight daily metrics
|
|
11519
12187
|
startAutoSnapshot();
|
|
12188
|
+
// ─── Browser capability routes ───────────────────────────────────────────────
|
|
12189
|
+
const browser = await import('./capabilities/browser.js');
|
|
12190
|
+
app.get('/browser/config', async () => {
|
|
12191
|
+
return browser.getBrowserConfig();
|
|
12192
|
+
});
|
|
12193
|
+
app.post('/browser/sessions', async (request, reply) => {
|
|
12194
|
+
try {
|
|
12195
|
+
const body = request.body;
|
|
12196
|
+
if (!body?.agent)
|
|
12197
|
+
return reply.code(400).send({ error: 'agent is required' });
|
|
12198
|
+
const session = await browser.createSession({
|
|
12199
|
+
agent: body.agent,
|
|
12200
|
+
url: body.url,
|
|
12201
|
+
headless: body.headless,
|
|
12202
|
+
viewport: body.viewport,
|
|
12203
|
+
});
|
|
12204
|
+
const { _stagehand, _page, _idleTimer, ...safe } = session;
|
|
12205
|
+
return reply.code(201).send(safe);
|
|
12206
|
+
}
|
|
12207
|
+
catch (err) {
|
|
12208
|
+
const status = err.message?.includes('Max concurrent') || err.message?.includes('exceeded max') ? 429 : 500;
|
|
12209
|
+
return reply.code(status).send({ error: err.message });
|
|
12210
|
+
}
|
|
12211
|
+
});
|
|
12212
|
+
app.get('/browser/sessions', async () => {
|
|
12213
|
+
return { sessions: browser.listSessions() };
|
|
12214
|
+
});
|
|
12215
|
+
app.get('/browser/sessions/:id', async (request, reply) => {
|
|
12216
|
+
const session = browser.getSession(request.params.id);
|
|
12217
|
+
if (!session)
|
|
12218
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
12219
|
+
const { _stagehand, _page, _idleTimer, ...safe } = session;
|
|
12220
|
+
return safe;
|
|
12221
|
+
});
|
|
12222
|
+
app.delete('/browser/sessions/:id', async (request, reply) => {
|
|
12223
|
+
await browser.closeSession(request.params.id);
|
|
12224
|
+
return { ok: true };
|
|
12225
|
+
});
|
|
12226
|
+
app.post('/browser/sessions/:id/act', async (request, reply) => {
|
|
12227
|
+
try {
|
|
12228
|
+
const body = request.body;
|
|
12229
|
+
if (!body?.instruction)
|
|
12230
|
+
return reply.code(400).send({ error: 'instruction is required' });
|
|
12231
|
+
const result = await browser.act(request.params.id, body.instruction);
|
|
12232
|
+
return result;
|
|
12233
|
+
}
|
|
12234
|
+
catch (err) {
|
|
12235
|
+
return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
|
|
12236
|
+
}
|
|
12237
|
+
});
|
|
12238
|
+
app.post('/browser/sessions/:id/extract', async (request, reply) => {
|
|
12239
|
+
try {
|
|
12240
|
+
const body = request.body;
|
|
12241
|
+
if (!body?.instruction)
|
|
12242
|
+
return reply.code(400).send({ error: 'instruction is required' });
|
|
12243
|
+
const result = await browser.extract(request.params.id, body.instruction, body.schema);
|
|
12244
|
+
return result;
|
|
12245
|
+
}
|
|
12246
|
+
catch (err) {
|
|
12247
|
+
return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
|
|
12248
|
+
}
|
|
12249
|
+
});
|
|
12250
|
+
app.post('/browser/sessions/:id/observe', async (request, reply) => {
|
|
12251
|
+
try {
|
|
12252
|
+
const body = request.body;
|
|
12253
|
+
if (!body?.instruction)
|
|
12254
|
+
return reply.code(400).send({ error: 'instruction is required' });
|
|
12255
|
+
const result = await browser.observe(request.params.id, body.instruction);
|
|
12256
|
+
return result;
|
|
12257
|
+
}
|
|
12258
|
+
catch (err) {
|
|
12259
|
+
return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
|
|
12260
|
+
}
|
|
12261
|
+
});
|
|
12262
|
+
app.post('/browser/sessions/:id/navigate', async (request, reply) => {
|
|
12263
|
+
try {
|
|
12264
|
+
const body = request.body;
|
|
12265
|
+
if (!body?.url)
|
|
12266
|
+
return reply.code(400).send({ error: 'url is required' });
|
|
12267
|
+
const result = await browser.navigate(request.params.id, body.url);
|
|
12268
|
+
return result;
|
|
12269
|
+
}
|
|
12270
|
+
catch (err) {
|
|
12271
|
+
return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
|
|
12272
|
+
}
|
|
12273
|
+
});
|
|
12274
|
+
app.get('/browser/sessions/:id/screenshot', async (request, reply) => {
|
|
12275
|
+
try {
|
|
12276
|
+
const result = await browser.screenshot(request.params.id);
|
|
12277
|
+
return result;
|
|
12278
|
+
}
|
|
12279
|
+
catch (err) {
|
|
12280
|
+
return reply.code(err.message?.includes('No active') ? 404 : 500).send({ error: err.message });
|
|
12281
|
+
}
|
|
12282
|
+
});
|
|
12283
|
+
// ── Agent Runs & Events ──────────────────────────────────────────────────
|
|
12284
|
+
const { createAgentRun, updateAgentRun, getAgentRun, getActiveAgentRun, listAgentRuns, appendAgentEvent, listAgentEvents, VALID_RUN_STATUSES, } = await import('./agent-runs.js');
|
|
12285
|
+
// Create a new agent run
|
|
12286
|
+
app.post('/agents/:agentId/runs', async (request, reply) => {
|
|
12287
|
+
const { agentId } = request.params;
|
|
12288
|
+
const body = request.body;
|
|
12289
|
+
if (!body?.objective)
|
|
12290
|
+
return reply.code(400).send({ error: 'objective is required' });
|
|
12291
|
+
const teamId = body.teamId ?? 'default';
|
|
12292
|
+
try {
|
|
12293
|
+
const run = createAgentRun(agentId, teamId, body.objective, {
|
|
12294
|
+
taskId: body.taskId,
|
|
12295
|
+
parentRunId: body.parentRunId,
|
|
12296
|
+
});
|
|
12297
|
+
return reply.code(201).send(run);
|
|
12298
|
+
}
|
|
12299
|
+
catch (err) {
|
|
12300
|
+
return reply.code(500).send({ error: err.message });
|
|
12301
|
+
}
|
|
12302
|
+
});
|
|
12303
|
+
// Update an agent run (status, context, artifacts)
|
|
12304
|
+
app.patch('/agents/:agentId/runs/:runId', async (request, reply) => {
|
|
12305
|
+
const { runId } = request.params;
|
|
12306
|
+
const body = request.body;
|
|
12307
|
+
if (body?.status && !VALID_RUN_STATUSES.includes(body.status)) {
|
|
12308
|
+
return reply.code(400).send({ error: `Invalid status. Valid: ${VALID_RUN_STATUSES.join(', ')}` });
|
|
12309
|
+
}
|
|
12310
|
+
try {
|
|
12311
|
+
const run = updateAgentRun(runId, {
|
|
12312
|
+
status: body?.status,
|
|
12313
|
+
contextSnapshot: body?.contextSnapshot,
|
|
12314
|
+
artifacts: body?.artifacts,
|
|
12315
|
+
});
|
|
12316
|
+
if (!run)
|
|
12317
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
12318
|
+
return run;
|
|
12319
|
+
}
|
|
12320
|
+
catch (err) {
|
|
12321
|
+
return reply.code(500).send({ error: err.message });
|
|
12322
|
+
}
|
|
12323
|
+
});
|
|
12324
|
+
// List agent runs
|
|
12325
|
+
app.get('/agents/:agentId/runs', async (request, reply) => {
|
|
12326
|
+
const { agentId } = request.params;
|
|
12327
|
+
const query = request.query;
|
|
12328
|
+
const teamId = query.teamId ?? 'default';
|
|
12329
|
+
const limit = query.limit ? parseInt(query.limit, 10) : undefined;
|
|
12330
|
+
return listAgentRuns(agentId, teamId, { status: query.status, limit });
|
|
12331
|
+
});
|
|
12332
|
+
// Get active run for an agent
|
|
12333
|
+
app.get('/agents/:agentId/runs/current', async (request, reply) => {
|
|
12334
|
+
const { agentId } = request.params;
|
|
12335
|
+
const query = request.query;
|
|
12336
|
+
const teamId = query.teamId ?? 'default';
|
|
12337
|
+
const run = getActiveAgentRun(agentId, teamId);
|
|
12338
|
+
if (!run)
|
|
12339
|
+
return reply.code(404).send({ error: 'No active run' });
|
|
12340
|
+
return run;
|
|
12341
|
+
});
|
|
12342
|
+
// Append an event
|
|
12343
|
+
const { validateRoutingSemantics } = await import('./agent-runs.js');
|
|
12344
|
+
// GET /events/routing/validate — check if a payload passes routing semantics
|
|
12345
|
+
app.post('/events/routing/validate', async (request) => {
|
|
12346
|
+
const body = request.body;
|
|
12347
|
+
if (!body?.eventType)
|
|
12348
|
+
return { valid: false, errors: ['eventType is required'], warnings: [] };
|
|
12349
|
+
return validateRoutingSemantics(body.eventType, body.payload ?? {});
|
|
12350
|
+
});
|
|
12351
|
+
app.post('/agents/:agentId/events', async (request, reply) => {
|
|
12352
|
+
const { agentId } = request.params;
|
|
12353
|
+
const body = request.body;
|
|
12354
|
+
if (!body?.eventType)
|
|
12355
|
+
return reply.code(400).send({ error: 'eventType is required' });
|
|
12356
|
+
try {
|
|
12357
|
+
const event = appendAgentEvent({
|
|
12358
|
+
agentId,
|
|
12359
|
+
runId: body.runId,
|
|
12360
|
+
eventType: body.eventType,
|
|
12361
|
+
payload: body.payload,
|
|
12362
|
+
enforceRouting: body.enforceRouting,
|
|
12363
|
+
});
|
|
12364
|
+
return reply.code(201).send(event);
|
|
12365
|
+
}
|
|
12366
|
+
catch (err) {
|
|
12367
|
+
if (err.message.includes('Routing semantics violation')) {
|
|
12368
|
+
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).' });
|
|
12369
|
+
}
|
|
12370
|
+
return reply.code(500).send({ error: err.message });
|
|
12371
|
+
const message = String(err?.message || err);
|
|
12372
|
+
if (message.includes('rationale')) {
|
|
12373
|
+
return reply.code(400).send({ error: message });
|
|
12374
|
+
}
|
|
12375
|
+
return reply.code(500).send({ error: message });
|
|
12376
|
+
}
|
|
12377
|
+
});
|
|
12378
|
+
// List agent events
|
|
12379
|
+
app.get('/agents/:agentId/events', async (request, reply) => {
|
|
12380
|
+
const { agentId } = request.params;
|
|
12381
|
+
const query = request.query;
|
|
12382
|
+
return listAgentEvents({
|
|
12383
|
+
agentId,
|
|
12384
|
+
runId: query.runId,
|
|
12385
|
+
eventType: query.type,
|
|
12386
|
+
since: query.since ? parseInt(query.since, 10) : undefined,
|
|
12387
|
+
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
12388
|
+
});
|
|
12389
|
+
});
|
|
12390
|
+
// ── Run Event Stream (SSE) ─────────────────────────────────────────────
|
|
12391
|
+
// Real-time SSE stream for run events. Canvas subscribes here instead of polling.
|
|
12392
|
+
// GET /agents/:agentId/runs/:runId/stream — stream events for a specific run
|
|
12393
|
+
// GET /agents/:agentId/stream — stream all events for an agent
|
|
12394
|
+
app.get('/agents/:agentId/runs/:runId/stream', async (request, reply) => {
|
|
12395
|
+
const { agentId, runId } = request.params;
|
|
12396
|
+
const run = getAgentRun(runId);
|
|
12397
|
+
if (!run) {
|
|
12398
|
+
reply.code(404);
|
|
12399
|
+
return { error: 'Run not found' };
|
|
12400
|
+
}
|
|
12401
|
+
reply.raw.writeHead(200, {
|
|
12402
|
+
'Content-Type': 'text/event-stream',
|
|
12403
|
+
'Cache-Control': 'no-cache',
|
|
12404
|
+
'Connection': 'keep-alive',
|
|
12405
|
+
'X-Accel-Buffering': 'no',
|
|
12406
|
+
});
|
|
12407
|
+
// Send current run state as initial snapshot
|
|
12408
|
+
reply.raw.write(`event: snapshot\ndata: ${JSON.stringify({ run, events: listAgentEvents({ runId, limit: 20 }) })}\n\n`);
|
|
12409
|
+
// Subscribe to eventBus for this run's events
|
|
12410
|
+
const listenerId = `run-stream-${runId}-${Date.now()}`;
|
|
12411
|
+
let closed = false;
|
|
12412
|
+
eventBus.on(listenerId, (event) => {
|
|
12413
|
+
if (closed)
|
|
12414
|
+
return;
|
|
12415
|
+
const data = event.data;
|
|
12416
|
+
// Forward events that match this agent or run
|
|
12417
|
+
if (data && (data.runId === runId || data.agentId === agentId)) {
|
|
12418
|
+
try {
|
|
12419
|
+
reply.raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
12420
|
+
}
|
|
12421
|
+
catch { /* connection closed */ }
|
|
12422
|
+
}
|
|
12423
|
+
});
|
|
12424
|
+
// Heartbeat
|
|
12425
|
+
const heartbeat = setInterval(() => {
|
|
12426
|
+
if (closed) {
|
|
12427
|
+
clearInterval(heartbeat);
|
|
12428
|
+
return;
|
|
12429
|
+
}
|
|
12430
|
+
try {
|
|
12431
|
+
reply.raw.write(`:heartbeat\n\n`);
|
|
12432
|
+
}
|
|
12433
|
+
catch {
|
|
12434
|
+
clearInterval(heartbeat);
|
|
12435
|
+
}
|
|
12436
|
+
}, 15_000);
|
|
12437
|
+
// Cleanup
|
|
12438
|
+
request.raw.on('close', () => {
|
|
12439
|
+
closed = true;
|
|
12440
|
+
eventBus.off(listenerId);
|
|
12441
|
+
clearInterval(heartbeat);
|
|
12442
|
+
});
|
|
12443
|
+
});
|
|
12444
|
+
// Stream all events for an agent
|
|
12445
|
+
app.get('/agents/:agentId/stream', async (request, reply) => {
|
|
12446
|
+
const { agentId } = request.params;
|
|
12447
|
+
reply.raw.writeHead(200, {
|
|
12448
|
+
'Content-Type': 'text/event-stream',
|
|
12449
|
+
'Cache-Control': 'no-cache',
|
|
12450
|
+
'Connection': 'keep-alive',
|
|
12451
|
+
'X-Accel-Buffering': 'no',
|
|
12452
|
+
});
|
|
12453
|
+
// Send recent events as snapshot
|
|
12454
|
+
const recentEvents = listAgentEvents({ agentId, limit: 20 });
|
|
12455
|
+
const activeRun = getActiveAgentRun(agentId, 'default');
|
|
12456
|
+
reply.raw.write(`event: snapshot\ndata: ${JSON.stringify({ activeRun, events: recentEvents })}\n\n`);
|
|
12457
|
+
const listenerId = `agent-stream-${agentId}-${Date.now()}`;
|
|
12458
|
+
let closed = false;
|
|
12459
|
+
eventBus.on(listenerId, (event) => {
|
|
12460
|
+
if (closed)
|
|
12461
|
+
return;
|
|
12462
|
+
const data = event.data;
|
|
12463
|
+
if (data && data.agentId === agentId) {
|
|
12464
|
+
try {
|
|
12465
|
+
reply.raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
12466
|
+
}
|
|
12467
|
+
catch { /* connection closed */ }
|
|
12468
|
+
}
|
|
12469
|
+
});
|
|
12470
|
+
const heartbeat = setInterval(() => {
|
|
12471
|
+
if (closed) {
|
|
12472
|
+
clearInterval(heartbeat);
|
|
12473
|
+
return;
|
|
12474
|
+
}
|
|
12475
|
+
try {
|
|
12476
|
+
reply.raw.write(`:heartbeat\n\n`);
|
|
12477
|
+
}
|
|
12478
|
+
catch {
|
|
12479
|
+
clearInterval(heartbeat);
|
|
12480
|
+
}
|
|
12481
|
+
}, 15_000);
|
|
12482
|
+
request.raw.on('close', () => {
|
|
12483
|
+
closed = true;
|
|
12484
|
+
eventBus.off(listenerId);
|
|
12485
|
+
clearInterval(heartbeat);
|
|
12486
|
+
});
|
|
12487
|
+
});
|
|
12488
|
+
// ── Workflow Templates ─────────────────────────────────────────────────
|
|
12489
|
+
const { listWorkflowTemplates, getWorkflowTemplate, runWorkflow } = await import('./workflow-templates.js');
|
|
12490
|
+
// GET /workflows — list available workflow templates
|
|
12491
|
+
app.get('/workflows', async () => ({ templates: listWorkflowTemplates() }));
|
|
12492
|
+
// GET /workflows/:id — get template details
|
|
12493
|
+
app.get('/workflows/:id', async (request, reply) => {
|
|
12494
|
+
const template = getWorkflowTemplate(request.params.id);
|
|
12495
|
+
if (!template) {
|
|
12496
|
+
reply.code(404);
|
|
12497
|
+
return { error: 'Template not found' };
|
|
12498
|
+
}
|
|
12499
|
+
return {
|
|
12500
|
+
id: template.id,
|
|
12501
|
+
name: template.name,
|
|
12502
|
+
description: template.description,
|
|
12503
|
+
steps: template.steps.map(s => ({ name: s.name, description: s.description })),
|
|
12504
|
+
};
|
|
12505
|
+
});
|
|
12506
|
+
// POST /workflows/:id/run — execute a workflow
|
|
12507
|
+
app.post('/workflows/:id/run', async (request, reply) => {
|
|
12508
|
+
const template = getWorkflowTemplate(request.params.id);
|
|
12509
|
+
if (!template) {
|
|
12510
|
+
reply.code(404);
|
|
12511
|
+
return { error: 'Template not found' };
|
|
12512
|
+
}
|
|
12513
|
+
const body = request.body ?? {};
|
|
12514
|
+
const agentId = body.agentId ?? 'link';
|
|
12515
|
+
const teamId = body.teamId ?? 'default';
|
|
12516
|
+
const result = await runWorkflow(template, agentId, teamId, body);
|
|
12517
|
+
return result;
|
|
12518
|
+
});
|
|
12519
|
+
// ── Agent Messaging (Host-native) ─────────────────────────────────────
|
|
12520
|
+
// Local agent-to-agent messaging. Replaces gateway for same-Host agents.
|
|
12521
|
+
const { sendAgentMessage, listAgentMessages, listSentMessages, markMessagesRead, getUnreadCount, listChannelMessages } = await import('./agent-messaging.js');
|
|
12522
|
+
// Send message
|
|
12523
|
+
app.post('/agents/:agentId/messages/send', async (request, reply) => {
|
|
12524
|
+
const { agentId } = request.params;
|
|
12525
|
+
const body = request.body;
|
|
12526
|
+
if (!body?.to)
|
|
12527
|
+
return reply.code(400).send({ error: 'to (recipient agent) is required' });
|
|
12528
|
+
if (!body?.content)
|
|
12529
|
+
return reply.code(400).send({ error: 'content is required' });
|
|
12530
|
+
const msg = sendAgentMessage({
|
|
12531
|
+
fromAgent: agentId,
|
|
12532
|
+
toAgent: body.to,
|
|
12533
|
+
channel: body.channel,
|
|
12534
|
+
content: body.content,
|
|
12535
|
+
metadata: body.metadata,
|
|
12536
|
+
});
|
|
12537
|
+
// Emit event for SSE subscribers
|
|
12538
|
+
eventBus.emit({
|
|
12539
|
+
id: `amsg-evt-${Date.now()}`,
|
|
12540
|
+
type: 'message_posted',
|
|
12541
|
+
timestamp: Date.now(),
|
|
12542
|
+
data: { messageId: msg.id, from: agentId, to: body.to, channel: msg.channel },
|
|
12543
|
+
});
|
|
12544
|
+
return reply.code(201).send(msg);
|
|
12545
|
+
});
|
|
12546
|
+
// Inbox
|
|
12547
|
+
app.get('/agents/:agentId/messages', async (request) => {
|
|
12548
|
+
const { agentId } = request.params;
|
|
12549
|
+
const query = request.query;
|
|
12550
|
+
return {
|
|
12551
|
+
messages: listAgentMessages({
|
|
12552
|
+
agentId,
|
|
12553
|
+
channel: query.channel,
|
|
12554
|
+
unreadOnly: query.unread === 'true',
|
|
12555
|
+
since: query.since ? parseInt(query.since, 10) : undefined,
|
|
12556
|
+
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
12557
|
+
}),
|
|
12558
|
+
unreadCount: getUnreadCount(agentId),
|
|
12559
|
+
};
|
|
12560
|
+
});
|
|
12561
|
+
// Sent
|
|
12562
|
+
app.get('/agents/:agentId/messages/sent', async (request) => {
|
|
12563
|
+
const { agentId } = request.params;
|
|
12564
|
+
const query = request.query;
|
|
12565
|
+
return { messages: listSentMessages(agentId, query.limit ? parseInt(query.limit, 10) : undefined) };
|
|
12566
|
+
});
|
|
12567
|
+
// Mark read
|
|
12568
|
+
app.post('/agents/:agentId/messages/read', async (request) => {
|
|
12569
|
+
const { agentId } = request.params;
|
|
12570
|
+
const body = request.body ?? {};
|
|
12571
|
+
const marked = markMessagesRead(agentId, body.messageIds);
|
|
12572
|
+
return { marked };
|
|
12573
|
+
});
|
|
12574
|
+
// Channel messages
|
|
12575
|
+
app.get('/messages/channel/:channel', async (request) => {
|
|
12576
|
+
const { channel } = request.params;
|
|
12577
|
+
const query = request.query;
|
|
12578
|
+
return {
|
|
12579
|
+
messages: listChannelMessages(channel, {
|
|
12580
|
+
since: query.since ? parseInt(query.since, 10) : undefined,
|
|
12581
|
+
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
12582
|
+
}),
|
|
12583
|
+
};
|
|
12584
|
+
});
|
|
12585
|
+
// ── Run Retention / Archive ────────────────────────────────────────────
|
|
12586
|
+
const { applyRunRetention, getRetentionStats } = await import('./agent-runs.js');
|
|
12587
|
+
// GET /runs/retention/stats — preview what retention policy would do
|
|
12588
|
+
app.get('/runs/retention/stats', async (request) => {
|
|
12589
|
+
const query = request.query;
|
|
12590
|
+
return getRetentionStats({
|
|
12591
|
+
maxAgeDays: query.maxAgeDays ? parseInt(query.maxAgeDays, 10) : undefined,
|
|
12592
|
+
maxCompletedRuns: query.maxCompletedRuns ? parseInt(query.maxCompletedRuns, 10) : undefined,
|
|
12593
|
+
});
|
|
12594
|
+
});
|
|
12595
|
+
// POST /runs/retention/apply — apply retention policy
|
|
12596
|
+
app.post('/runs/retention/apply', async (request) => {
|
|
12597
|
+
const body = request.body ?? {};
|
|
12598
|
+
return applyRunRetention({
|
|
12599
|
+
policy: {
|
|
12600
|
+
maxAgeDays: body.maxAgeDays,
|
|
12601
|
+
maxCompletedRuns: body.maxCompletedRuns,
|
|
12602
|
+
deleteArchived: body.deleteArchived,
|
|
12603
|
+
},
|
|
12604
|
+
agentId: body.agentId,
|
|
12605
|
+
dryRun: body.dryRun,
|
|
12606
|
+
});
|
|
12607
|
+
});
|
|
12608
|
+
// ── Artifact Store (Host-native) ──────────────────────────────────────
|
|
12609
|
+
const { storeArtifact, getArtifact, readArtifactContent, listArtifacts, deleteArtifact, getStorageUsage } = await import('./artifact-store.js');
|
|
12610
|
+
// Upload artifact
|
|
12611
|
+
app.post('/agents/:agentId/artifacts', async (request, reply) => {
|
|
12612
|
+
const { agentId } = request.params;
|
|
12613
|
+
const body = request.body;
|
|
12614
|
+
if (!body?.name)
|
|
12615
|
+
return reply.code(400).send({ error: 'name is required' });
|
|
12616
|
+
if (!body?.content)
|
|
12617
|
+
return reply.code(400).send({ error: 'content is required' });
|
|
12618
|
+
const contentBuf = body.encoding === 'base64' ? Buffer.from(body.content, 'base64') : Buffer.from(body.content);
|
|
12619
|
+
const art = storeArtifact({ agentId, name: body.name, content: contentBuf, mimeType: body.mimeType, runId: body.runId, taskId: body.taskId, metadata: body.metadata });
|
|
12620
|
+
return reply.code(201).send(art);
|
|
12621
|
+
});
|
|
12622
|
+
// List artifacts
|
|
12623
|
+
app.get('/agents/:agentId/artifacts', async (request) => {
|
|
12624
|
+
const { agentId } = request.params;
|
|
12625
|
+
const query = request.query;
|
|
12626
|
+
return {
|
|
12627
|
+
artifacts: listArtifacts({ agentId, runId: query.runId, taskId: query.taskId, limit: query.limit ? parseInt(query.limit, 10) : undefined }),
|
|
12628
|
+
usage: getStorageUsage(agentId),
|
|
12629
|
+
};
|
|
12630
|
+
});
|
|
12631
|
+
// Get artifact metadata
|
|
12632
|
+
app.get('/artifacts/:artifactId', async (request, reply) => {
|
|
12633
|
+
const { artifactId } = request.params;
|
|
12634
|
+
const art = getArtifact(artifactId);
|
|
12635
|
+
if (!art)
|
|
12636
|
+
return reply.code(404).send({ error: 'Artifact not found' });
|
|
12637
|
+
return art;
|
|
12638
|
+
});
|
|
12639
|
+
// Download artifact content
|
|
12640
|
+
app.get('/artifacts/:artifactId/content', async (request, reply) => {
|
|
12641
|
+
const { artifactId } = request.params;
|
|
12642
|
+
const content = readArtifactContent(artifactId);
|
|
12643
|
+
if (!content)
|
|
12644
|
+
return reply.code(404).send({ error: 'Artifact not found or file missing' });
|
|
12645
|
+
const art = getArtifact(artifactId);
|
|
12646
|
+
return reply.type(art.mimeType).send(content);
|
|
12647
|
+
});
|
|
12648
|
+
// Delete artifact
|
|
12649
|
+
app.delete('/artifacts/:artifactId', async (request, reply) => {
|
|
12650
|
+
const { artifactId } = request.params;
|
|
12651
|
+
const deleted = deleteArtifact(artifactId);
|
|
12652
|
+
if (!deleted)
|
|
12653
|
+
return reply.code(404).send({ error: 'Artifact not found' });
|
|
12654
|
+
return { deleted: true };
|
|
12655
|
+
});
|
|
12656
|
+
// Storage usage
|
|
12657
|
+
app.get('/agents/:agentId/storage', async (request) => {
|
|
12658
|
+
const { agentId } = request.params;
|
|
12659
|
+
return getStorageUsage(agentId);
|
|
12660
|
+
});
|
|
12661
|
+
// ── Webhook Storage ──────────────────────────────────────────────────
|
|
12662
|
+
const { storeWebhookPayload, getWebhookPayload, listWebhookPayloads, markPayloadProcessed, getUnprocessedCount, purgeOldPayloads } = await import('./webhook-storage.js');
|
|
12663
|
+
// Ingest webhook payload
|
|
12664
|
+
app.post('/webhooks/ingest', async (request, reply) => {
|
|
12665
|
+
const body = request.body;
|
|
12666
|
+
if (!body?.source)
|
|
12667
|
+
return reply.code(400).send({ error: 'source is required' });
|
|
12668
|
+
if (!body?.eventType)
|
|
12669
|
+
return reply.code(400).send({ error: 'eventType is required' });
|
|
12670
|
+
if (!body?.body)
|
|
12671
|
+
return reply.code(400).send({ error: 'body (payload) is required' });
|
|
12672
|
+
const headers = {};
|
|
12673
|
+
for (const [k, v] of Object.entries(request.headers)) {
|
|
12674
|
+
if (typeof v === 'string')
|
|
12675
|
+
headers[k] = v;
|
|
12676
|
+
}
|
|
12677
|
+
const payload = storeWebhookPayload({ source: body.source, eventType: body.eventType, agentId: body.agentId, body: body.body, headers });
|
|
12678
|
+
return reply.code(201).send(payload);
|
|
12679
|
+
});
|
|
12680
|
+
// List payloads
|
|
12681
|
+
app.get('/webhooks/payloads', async (request) => {
|
|
12682
|
+
const query = request.query;
|
|
12683
|
+
return {
|
|
12684
|
+
payloads: listWebhookPayloads({
|
|
12685
|
+
source: query.source,
|
|
12686
|
+
agentId: query.agentId,
|
|
12687
|
+
unprocessedOnly: query.unprocessed === 'true',
|
|
12688
|
+
since: query.since ? parseInt(query.since, 10) : undefined,
|
|
12689
|
+
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
12690
|
+
}),
|
|
12691
|
+
unprocessedCount: getUnprocessedCount({ source: query.source, agentId: query.agentId }),
|
|
12692
|
+
};
|
|
12693
|
+
});
|
|
12694
|
+
// Get single payload
|
|
12695
|
+
app.get('/webhooks/payloads/:payloadId', async (request, reply) => {
|
|
12696
|
+
const { payloadId } = request.params;
|
|
12697
|
+
const payload = getWebhookPayload(payloadId);
|
|
12698
|
+
if (!payload)
|
|
12699
|
+
return reply.code(404).send({ error: 'Payload not found' });
|
|
12700
|
+
return payload;
|
|
12701
|
+
});
|
|
12702
|
+
// Mark processed
|
|
12703
|
+
app.post('/webhooks/payloads/:payloadId/process', async (request, reply) => {
|
|
12704
|
+
const { payloadId } = request.params;
|
|
12705
|
+
const marked = markPayloadProcessed(payloadId);
|
|
12706
|
+
if (!marked)
|
|
12707
|
+
return reply.code(404).send({ error: 'Payload not found or already processed' });
|
|
12708
|
+
return { processed: true };
|
|
12709
|
+
});
|
|
12710
|
+
// Purge old processed payloads
|
|
12711
|
+
app.post('/webhooks/purge', async (request) => {
|
|
12712
|
+
const body = request.body ?? {};
|
|
12713
|
+
const deleted = purgeOldPayloads(body.maxAgeDays ?? 30);
|
|
12714
|
+
return { deleted };
|
|
12715
|
+
});
|
|
12716
|
+
// ── Approval Routing ────────────────────────────────────────────────────
|
|
12717
|
+
const { listPendingApprovals, listApprovalQueue, submitApprovalDecision, } = await import('./agent-runs.js');
|
|
12718
|
+
// List pending approvals (review_requested events needing action)
|
|
12719
|
+
app.get('/approvals/pending', async (request) => {
|
|
12720
|
+
const query = request.query;
|
|
12721
|
+
return listPendingApprovals({
|
|
12722
|
+
agentId: query.agentId,
|
|
12723
|
+
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
12724
|
+
});
|
|
12725
|
+
});
|
|
12726
|
+
// Dedicated approval queue — unified view of everything needing human decision.
|
|
12727
|
+
// Answers: what needs decision, who owns it, when it expires, what happens if ignored.
|
|
12728
|
+
app.get('/approval-queue', async (request) => {
|
|
12729
|
+
const query = request.query;
|
|
12730
|
+
const items = listApprovalQueue({
|
|
12731
|
+
agentId: query.agentId,
|
|
12732
|
+
category: query.category === 'review' || query.category === 'agent_action' ? query.category : undefined,
|
|
12733
|
+
includeExpired: query.includeExpired === 'true',
|
|
12734
|
+
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
12735
|
+
});
|
|
12736
|
+
return {
|
|
12737
|
+
items,
|
|
12738
|
+
count: items.length,
|
|
12739
|
+
hasExpired: items.some(i => i.isExpired),
|
|
12740
|
+
};
|
|
12741
|
+
});
|
|
12742
|
+
// Submit agent-action approval (approve_requested events)
|
|
12743
|
+
app.post('/approval-queue/:approvalId/decide', async (request, reply) => {
|
|
12744
|
+
const { approvalId } = request.params;
|
|
12745
|
+
const body = request.body;
|
|
12746
|
+
if (!body?.decision || !['approve', 'reject', 'defer'].includes(body.decision)) {
|
|
12747
|
+
return reply.code(400).send({ error: 'decision must be "approve", "reject", or "defer"' });
|
|
12748
|
+
}
|
|
12749
|
+
if (!body?.actor) {
|
|
12750
|
+
return reply.code(400).send({ error: 'actor is required' });
|
|
12751
|
+
}
|
|
12752
|
+
try {
|
|
12753
|
+
const result = submitApprovalDecision({
|
|
12754
|
+
eventId: approvalId,
|
|
12755
|
+
decision: body.decision,
|
|
12756
|
+
reviewer: body.actor,
|
|
12757
|
+
comment: body.comment,
|
|
12758
|
+
});
|
|
12759
|
+
// Emit canvas_input event so Presence Layer updates
|
|
12760
|
+
eventBus.emit({
|
|
12761
|
+
id: `aq-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
12762
|
+
type: 'canvas_input',
|
|
12763
|
+
timestamp: Date.now(),
|
|
12764
|
+
data: {
|
|
12765
|
+
action: 'decision',
|
|
12766
|
+
approvalId,
|
|
12767
|
+
decision: body.decision,
|
|
12768
|
+
actor: body.actor,
|
|
12769
|
+
},
|
|
12770
|
+
});
|
|
12771
|
+
return result;
|
|
12772
|
+
}
|
|
12773
|
+
catch (err) {
|
|
12774
|
+
return reply.code(err.message.includes('not found') ? 404 : 400).send({ error: err.message });
|
|
12775
|
+
}
|
|
12776
|
+
});
|
|
12777
|
+
// Submit approval decision
|
|
12778
|
+
app.post('/approvals/:eventId/decide', async (request, reply) => {
|
|
12779
|
+
const { eventId } = request.params;
|
|
12780
|
+
const body = request.body;
|
|
12781
|
+
if (!body?.decision || !['approve', 'reject'].includes(body.decision)) {
|
|
12782
|
+
return reply.code(400).send({ error: 'decision must be "approve" or "reject"' });
|
|
12783
|
+
}
|
|
12784
|
+
if (!body?.reviewer) {
|
|
12785
|
+
return reply.code(400).send({ error: 'reviewer is required' });
|
|
12786
|
+
}
|
|
12787
|
+
try {
|
|
12788
|
+
const result = submitApprovalDecision({
|
|
12789
|
+
eventId,
|
|
12790
|
+
decision: body.decision,
|
|
12791
|
+
reviewer: body.reviewer,
|
|
12792
|
+
comment: body.comment,
|
|
12793
|
+
rationale: body.rationale,
|
|
12794
|
+
});
|
|
12795
|
+
return result;
|
|
12796
|
+
}
|
|
12797
|
+
catch (err) {
|
|
12798
|
+
return reply.code(err.message.includes('not found') ? 404 : 400).send({ error: err.message });
|
|
12799
|
+
}
|
|
12800
|
+
});
|
|
12801
|
+
// ── Canvas Input ──────────────────────────────────────────────────────
|
|
12802
|
+
// Human → agent control seam for the Presence Layer.
|
|
12803
|
+
// Payload is intentionally small per COO spec: action + target + actor.
|
|
12804
|
+
const CANVAS_INPUT_ACTIONS = ['decision', 'interrupt', 'pause', 'resume', 'mute', 'unmute'];
|
|
12805
|
+
const CanvasInputSchema = z.object({
|
|
12806
|
+
action: z.enum(CANVAS_INPUT_ACTIONS),
|
|
12807
|
+
targetRunId: z.string().optional(), // which run to act on
|
|
12808
|
+
decisionId: z.string().optional(), // for decision actions
|
|
12809
|
+
choice: z.enum(['approve', 'deny', 'defer']).optional(), // for decision actions
|
|
12810
|
+
actor: z.string().min(1), // who made this input
|
|
12811
|
+
comment: z.string().optional(), // optional rationale
|
|
12812
|
+
});
|
|
12813
|
+
app.post('/canvas/input', async (request, reply) => {
|
|
12814
|
+
const body = request.body;
|
|
12815
|
+
const result = CanvasInputSchema.safeParse(body);
|
|
12816
|
+
if (!result.success) {
|
|
12817
|
+
reply.code(422);
|
|
12818
|
+
return {
|
|
12819
|
+
error: `Invalid canvas input: ${result.error.issues.map(i => i.message).join(', ')}`,
|
|
12820
|
+
hint: 'Required: action (decision|interrupt|pause|resume|mute|unmute), actor. Optional: targetRunId, decisionId, choice, comment.',
|
|
12821
|
+
};
|
|
12822
|
+
}
|
|
12823
|
+
const input = result.data;
|
|
12824
|
+
const now = Date.now();
|
|
12825
|
+
// Route by action type
|
|
12826
|
+
if (input.action === 'decision') {
|
|
12827
|
+
if (!input.decisionId || !input.choice) {
|
|
12828
|
+
reply.code(422);
|
|
12829
|
+
return { error: 'Decision action requires decisionId and choice (approve|deny|defer)' };
|
|
12830
|
+
}
|
|
12831
|
+
// Emit canvas_input event for SSE subscribers
|
|
12832
|
+
eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
|
|
12833
|
+
action: input.action,
|
|
12834
|
+
decisionId: input.decisionId,
|
|
12835
|
+
choice: input.choice,
|
|
12836
|
+
actor: input.actor,
|
|
12837
|
+
comment: input.comment,
|
|
12838
|
+
timestamp: now,
|
|
12839
|
+
} });
|
|
12840
|
+
return {
|
|
12841
|
+
success: true,
|
|
12842
|
+
action: 'decision',
|
|
12843
|
+
decisionId: input.decisionId,
|
|
12844
|
+
choice: input.choice,
|
|
12845
|
+
actor: input.actor,
|
|
12846
|
+
timestamp: now,
|
|
12847
|
+
};
|
|
12848
|
+
}
|
|
12849
|
+
if (input.action === 'interrupt' || input.action === 'pause') {
|
|
12850
|
+
// Update active run if specified
|
|
12851
|
+
const runId = input.targetRunId;
|
|
12852
|
+
if (runId) {
|
|
12853
|
+
try {
|
|
12854
|
+
updateAgentRun(runId, {
|
|
12855
|
+
status: input.action === 'interrupt' ? 'cancelled' : 'blocked',
|
|
12856
|
+
});
|
|
12857
|
+
}
|
|
12858
|
+
catch { /* run may not exist — still emit event */ }
|
|
12859
|
+
}
|
|
12860
|
+
eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
|
|
12861
|
+
action: input.action,
|
|
12862
|
+
targetRunId: runId || null,
|
|
12863
|
+
actor: input.actor,
|
|
12864
|
+
timestamp: now,
|
|
12865
|
+
} });
|
|
12866
|
+
return {
|
|
12867
|
+
success: true,
|
|
12868
|
+
action: input.action,
|
|
12869
|
+
targetRunId: runId || null,
|
|
12870
|
+
actor: input.actor,
|
|
12871
|
+
timestamp: now,
|
|
12872
|
+
};
|
|
12873
|
+
}
|
|
12874
|
+
if (input.action === 'resume') {
|
|
12875
|
+
const runId = input.targetRunId;
|
|
12876
|
+
if (runId) {
|
|
12877
|
+
try {
|
|
12878
|
+
updateAgentRun(runId, { status: 'working' });
|
|
12879
|
+
}
|
|
12880
|
+
catch { /* run may not exist */ }
|
|
12881
|
+
}
|
|
12882
|
+
eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
|
|
12883
|
+
action: 'resume',
|
|
12884
|
+
targetRunId: runId || null,
|
|
12885
|
+
actor: input.actor,
|
|
12886
|
+
timestamp: now,
|
|
12887
|
+
} });
|
|
12888
|
+
return { success: true, action: 'resume', targetRunId: runId || null, actor: input.actor, timestamp: now };
|
|
12889
|
+
}
|
|
12890
|
+
// Mute/unmute — emit event only, no state change needed
|
|
12891
|
+
eventBus.emit({ id: `cinput-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type: "canvas_input", timestamp: Date.now(), data: {
|
|
12892
|
+
action: input.action,
|
|
12893
|
+
actor: input.actor,
|
|
12894
|
+
timestamp: now,
|
|
12895
|
+
} });
|
|
12896
|
+
return { success: true, action: input.action, actor: input.actor, timestamp: now };
|
|
12897
|
+
});
|
|
12898
|
+
// GET /canvas/input/schema — discovery endpoint
|
|
12899
|
+
app.get('/canvas/input/schema', async () => ({
|
|
12900
|
+
actions: CANVAS_INPUT_ACTIONS,
|
|
12901
|
+
schema: {
|
|
12902
|
+
action: 'decision | interrupt | pause | resume | mute | unmute',
|
|
12903
|
+
targetRunId: 'optional — which run to act on',
|
|
12904
|
+
decisionId: 'required for decision action — approval event ID',
|
|
12905
|
+
choice: 'required for decision — approve | deny | defer',
|
|
12906
|
+
actor: 'required — who made this input',
|
|
12907
|
+
comment: 'optional — rationale',
|
|
12908
|
+
},
|
|
12909
|
+
}));
|
|
12910
|
+
// ── Email / SMS relay ──────────────────────────────────────────────────
|
|
12911
|
+
async function cloudRelay(path, body, reply) {
|
|
12912
|
+
const cloudUrl = process.env.REFLECTT_CLOUD_URL;
|
|
12913
|
+
const hostToken = process.env.REFLECTT_HOST_TOKEN;
|
|
12914
|
+
if (!cloudUrl || !hostToken) {
|
|
12915
|
+
reply.code(503);
|
|
12916
|
+
return { error: 'Not connected to cloud. Configure REFLECTT_CLOUD_URL and REFLECTT_HOST_TOKEN.' };
|
|
12917
|
+
}
|
|
12918
|
+
try {
|
|
12919
|
+
const res = await fetch(`${cloudUrl}${path}`, {
|
|
12920
|
+
method: 'POST',
|
|
12921
|
+
headers: {
|
|
12922
|
+
'Content-Type': 'application/json',
|
|
12923
|
+
Authorization: `Bearer ${hostToken}`,
|
|
12924
|
+
},
|
|
12925
|
+
body: JSON.stringify(body),
|
|
12926
|
+
});
|
|
12927
|
+
const data = await res.json().catch(() => ({}));
|
|
12928
|
+
if (!res.ok) {
|
|
12929
|
+
reply.code(res.status);
|
|
12930
|
+
return data;
|
|
12931
|
+
}
|
|
12932
|
+
return data;
|
|
12933
|
+
}
|
|
12934
|
+
catch (err) {
|
|
12935
|
+
reply.code(502);
|
|
12936
|
+
return { error: `Cloud relay failed: ${err.message}` };
|
|
12937
|
+
}
|
|
12938
|
+
}
|
|
12939
|
+
// Send email via cloud relay
|
|
12940
|
+
app.post('/email/send', async (request, reply) => {
|
|
12941
|
+
const body = request.body;
|
|
12942
|
+
const from = typeof body.from === 'string' ? body.from.trim() : '';
|
|
12943
|
+
const to = body.to;
|
|
12944
|
+
const subject = typeof body.subject === 'string' ? body.subject.trim() : '';
|
|
12945
|
+
if (!from)
|
|
12946
|
+
return reply.code(400).send({ error: 'from is required' });
|
|
12947
|
+
if (!to)
|
|
12948
|
+
return reply.code(400).send({ error: 'to is required' });
|
|
12949
|
+
if (!subject)
|
|
12950
|
+
return reply.code(400).send({ error: 'subject is required' });
|
|
12951
|
+
if (!body.html && !body.text)
|
|
12952
|
+
return reply.code(400).send({ error: 'html or text body is required' });
|
|
12953
|
+
// Use host-relay endpoint — authenticates with host credential, uses host's own teamId server-side
|
|
12954
|
+
const hostId = process.env.REFLECTT_HOST_ID;
|
|
12955
|
+
const relayPath = hostId ? `/api/hosts/${encodeURIComponent(hostId)}/relay/email` : '/api/hosts/relay/email';
|
|
12956
|
+
return cloudRelay(relayPath, {
|
|
12957
|
+
from,
|
|
12958
|
+
to,
|
|
12959
|
+
subject,
|
|
12960
|
+
html: body.html,
|
|
12961
|
+
text: body.text,
|
|
12962
|
+
replyTo: body.replyTo,
|
|
12963
|
+
cc: body.cc,
|
|
12964
|
+
bcc: body.bcc,
|
|
12965
|
+
agent: body.agentId || body.agent || 'unknown',
|
|
12966
|
+
}, reply);
|
|
12967
|
+
});
|
|
12968
|
+
// Send SMS via cloud relay
|
|
12969
|
+
app.post('/sms/send', async (request, reply) => {
|
|
12970
|
+
const body = request.body;
|
|
12971
|
+
const to = typeof body.to === 'string' ? body.to.trim() : '';
|
|
12972
|
+
const msgBody = typeof body.body === 'string' ? body.body.trim() : '';
|
|
12973
|
+
if (!to)
|
|
12974
|
+
return reply.code(400).send({ error: 'to is required (phone number)' });
|
|
12975
|
+
if (!msgBody)
|
|
12976
|
+
return reply.code(400).send({ error: 'body is required' });
|
|
12977
|
+
const hostIdSms = process.env.REFLECTT_HOST_ID;
|
|
12978
|
+
const smsRelayPath = hostIdSms ? `/api/hosts/${encodeURIComponent(hostIdSms)}/relay/sms` : '/api/hosts/relay/sms';
|
|
12979
|
+
return cloudRelay(smsRelayPath, {
|
|
12980
|
+
to,
|
|
12981
|
+
body: msgBody,
|
|
12982
|
+
from: body.from,
|
|
12983
|
+
agent: body.agentId || body.agent || 'unknown',
|
|
12984
|
+
}, reply);
|
|
12985
|
+
});
|
|
12986
|
+
// ── Agent Config ──────────────────────────────────────────────────────
|
|
12987
|
+
// Per-agent model preference, cost cap, and settings.
|
|
12988
|
+
// This is the policy anchor for cost enforcement.
|
|
12989
|
+
const { getAgentConfig, listAgentConfigs, setAgentConfig, deleteAgentConfig, checkCostCap } = await import('./agent-config.js');
|
|
12990
|
+
// GET /agents/:agentId/config — get config for an agent
|
|
12991
|
+
app.get('/agents/:agentId/config', async (request) => {
|
|
12992
|
+
const config = getAgentConfig(request.params.agentId);
|
|
12993
|
+
return config ?? { agentId: request.params.agentId, configured: false };
|
|
12994
|
+
});
|
|
12995
|
+
// PUT /agents/:agentId/config — upsert config for an agent
|
|
12996
|
+
app.put('/agents/:agentId/config', async (request, reply) => {
|
|
12997
|
+
const body = request.body ?? {};
|
|
12998
|
+
try {
|
|
12999
|
+
const config = setAgentConfig(request.params.agentId, {
|
|
13000
|
+
teamId: typeof body.teamId === 'string' ? body.teamId : undefined,
|
|
13001
|
+
model: body.model !== undefined ? body.model : undefined,
|
|
13002
|
+
fallbackModel: body.fallbackModel !== undefined ? body.fallbackModel : undefined,
|
|
13003
|
+
costCapDaily: body.costCapDaily !== undefined ? body.costCapDaily : undefined,
|
|
13004
|
+
costCapMonthly: body.costCapMonthly !== undefined ? body.costCapMonthly : undefined,
|
|
13005
|
+
maxTokensPerCall: body.maxTokensPerCall !== undefined ? body.maxTokensPerCall : undefined,
|
|
13006
|
+
settings: body.settings !== undefined ? body.settings : undefined,
|
|
13007
|
+
});
|
|
13008
|
+
return config;
|
|
13009
|
+
}
|
|
13010
|
+
catch (err) {
|
|
13011
|
+
reply.code(400);
|
|
13012
|
+
return { error: err.message };
|
|
13013
|
+
}
|
|
13014
|
+
});
|
|
13015
|
+
// DELETE /agents/:agentId/config — remove config for an agent
|
|
13016
|
+
app.delete('/agents/:agentId/config', async (request, reply) => {
|
|
13017
|
+
const deleted = deleteAgentConfig(request.params.agentId);
|
|
13018
|
+
if (!deleted) {
|
|
13019
|
+
reply.code(404);
|
|
13020
|
+
return { error: 'Config not found' };
|
|
13021
|
+
}
|
|
13022
|
+
return { success: true };
|
|
13023
|
+
});
|
|
13024
|
+
// GET /agent-configs — list all agent configs
|
|
13025
|
+
app.get('/agent-configs', async (request) => {
|
|
13026
|
+
const query = request.query;
|
|
13027
|
+
return { configs: listAgentConfigs({ teamId: query.teamId }) };
|
|
13028
|
+
});
|
|
13029
|
+
// GET /agents/:agentId/cost-check — runtime cost enforcement check
|
|
13030
|
+
// Used by the runtime before making model calls.
|
|
13031
|
+
app.get('/agents/:agentId/cost-check', async (request) => {
|
|
13032
|
+
const query = request.query;
|
|
13033
|
+
const dailySpend = query.dailySpend ? parseFloat(query.dailySpend) : 0;
|
|
13034
|
+
const monthlySpend = query.monthlySpend ? parseFloat(query.monthlySpend) : 0;
|
|
13035
|
+
return checkCostCap(request.params.agentId, dailySpend, monthlySpend);
|
|
13036
|
+
});
|
|
13037
|
+
// ── Cost-Policy Enforcement Middleware ──────────────────────────────────
|
|
13038
|
+
const { enforcePolicy, recordUsage, getDailySpend, getMonthlySpend, purgeUsageLog, ensureUsageLogTable, } = await import('./cost-enforcement.js');
|
|
13039
|
+
ensureUsageLogTable();
|
|
13040
|
+
// POST /agents/:agentId/enforce-cost — runtime enforcement before model calls
|
|
13041
|
+
app.post('/agents/:agentId/enforce-cost', async (request, reply) => {
|
|
13042
|
+
const result = enforcePolicy(request.params.agentId);
|
|
13043
|
+
const status = result.action === 'deny' ? 403 : 200;
|
|
13044
|
+
return reply.code(status).send(result);
|
|
13045
|
+
});
|
|
13046
|
+
// GET /agents/:agentId/spend — current daily + monthly spend
|
|
13047
|
+
app.get('/agents/:agentId/spend', async (request) => {
|
|
13048
|
+
const { agentId } = request.params;
|
|
13049
|
+
return {
|
|
13050
|
+
agentId,
|
|
13051
|
+
dailySpend: getDailySpend(agentId),
|
|
13052
|
+
monthlySpend: getMonthlySpend(agentId),
|
|
13053
|
+
};
|
|
13054
|
+
});
|
|
13055
|
+
// POST /usage/record — record a usage event
|
|
13056
|
+
app.post('/usage/record', async (request, reply) => {
|
|
13057
|
+
const body = request.body;
|
|
13058
|
+
if (!body?.agentId)
|
|
13059
|
+
return reply.code(400).send({ error: 'agentId is required' });
|
|
13060
|
+
if (!body?.model)
|
|
13061
|
+
return reply.code(400).send({ error: 'model is required' });
|
|
13062
|
+
if (typeof body.cost !== 'number')
|
|
13063
|
+
return reply.code(400).send({ error: 'cost is required (number)' });
|
|
13064
|
+
recordUsage({
|
|
13065
|
+
agentId: body.agentId,
|
|
13066
|
+
model: body.model,
|
|
13067
|
+
inputTokens: body.inputTokens ?? 0,
|
|
13068
|
+
outputTokens: body.outputTokens ?? 0,
|
|
13069
|
+
cost: body.cost,
|
|
13070
|
+
timestamp: Date.now(),
|
|
13071
|
+
});
|
|
13072
|
+
return reply.code(201).send({ ok: true });
|
|
13073
|
+
});
|
|
13074
|
+
// POST /usage/purge — purge old usage records
|
|
13075
|
+
app.post('/usage/purge', async (request) => {
|
|
13076
|
+
const body = request.body;
|
|
13077
|
+
const deleted = purgeUsageLog(body?.maxAgeDays ?? 90);
|
|
13078
|
+
return { deleted };
|
|
13079
|
+
});
|
|
13080
|
+
// ── Agent Memories ─────────────────────────────────────────────────────
|
|
13081
|
+
const { setMemory, getMemory, listMemories, deleteMemory, deleteMemoryById, purgeExpiredMemories, countMemories, } = await import('./agent-memories.js');
|
|
13082
|
+
// Set (create or update) a memory
|
|
13083
|
+
app.put('/agents/:agentId/memories', async (request, reply) => {
|
|
13084
|
+
const { agentId } = request.params;
|
|
13085
|
+
const body = request.body;
|
|
13086
|
+
if (!body?.key)
|
|
13087
|
+
return reply.code(400).send({ error: 'key is required' });
|
|
13088
|
+
if (body.content === undefined || body.content === null)
|
|
13089
|
+
return reply.code(400).send({ error: 'content is required' });
|
|
13090
|
+
try {
|
|
13091
|
+
const memory = setMemory({
|
|
13092
|
+
agentId,
|
|
13093
|
+
namespace: body.namespace,
|
|
13094
|
+
key: body.key,
|
|
13095
|
+
content: body.content,
|
|
13096
|
+
tags: body.tags,
|
|
13097
|
+
expiresAt: body.expiresAt,
|
|
13098
|
+
});
|
|
13099
|
+
return reply.code(200).send(memory);
|
|
13100
|
+
}
|
|
13101
|
+
catch (err) {
|
|
13102
|
+
return reply.code(500).send({ error: err.message });
|
|
13103
|
+
}
|
|
13104
|
+
});
|
|
13105
|
+
// Get a specific memory by key
|
|
13106
|
+
app.get('/agents/:agentId/memories/:key', async (request, reply) => {
|
|
13107
|
+
const { agentId, key } = request.params;
|
|
13108
|
+
const query = request.query;
|
|
13109
|
+
const memory = getMemory(agentId, key, query.namespace);
|
|
13110
|
+
if (!memory)
|
|
13111
|
+
return reply.code(404).send({ error: 'Memory not found' });
|
|
13112
|
+
return memory;
|
|
13113
|
+
});
|
|
13114
|
+
// List memories for an agent
|
|
13115
|
+
app.get('/agents/:agentId/memories', async (request, reply) => {
|
|
13116
|
+
const { agentId } = request.params;
|
|
13117
|
+
const query = request.query;
|
|
13118
|
+
return listMemories({
|
|
13119
|
+
agentId,
|
|
13120
|
+
namespace: query.namespace,
|
|
13121
|
+
tag: query.tag,
|
|
13122
|
+
search: query.search,
|
|
13123
|
+
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
13124
|
+
});
|
|
13125
|
+
});
|
|
13126
|
+
// Delete a memory by key
|
|
13127
|
+
app.delete('/agents/:agentId/memories/:key', async (request, reply) => {
|
|
13128
|
+
const { agentId, key } = request.params;
|
|
13129
|
+
const query = request.query;
|
|
13130
|
+
const deleted = deleteMemory(agentId, key, query.namespace);
|
|
13131
|
+
if (!deleted)
|
|
13132
|
+
return reply.code(404).send({ error: 'Memory not found' });
|
|
13133
|
+
return { deleted: true };
|
|
13134
|
+
});
|
|
13135
|
+
// Count memories
|
|
13136
|
+
app.get('/agents/:agentId/memories/count', async (request, reply) => {
|
|
13137
|
+
const { agentId } = request.params;
|
|
13138
|
+
const query = request.query;
|
|
13139
|
+
return { count: countMemories(agentId, query.namespace) };
|
|
13140
|
+
});
|
|
13141
|
+
// Purge expired memories (housekeeping)
|
|
13142
|
+
app.post('/agents/memories/purge', async (_request, reply) => {
|
|
13143
|
+
const purged = purgeExpiredMemories();
|
|
13144
|
+
return { purged };
|
|
13145
|
+
});
|
|
11520
13146
|
return app;
|
|
11521
13147
|
}
|
|
11522
13148
|
//# sourceMappingURL=server.js.map
|