reflectt-node 0.1.7 → 0.1.8
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/gitignore.template +23 -0
- package/dist/boardHealthWorker.d.ts +4 -0
- package/dist/boardHealthWorker.d.ts.map +1 -1
- package/dist/boardHealthWorker.js +36 -1
- 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/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 +37 -12
- package/dist/cli.js.map +1 -1
- package/dist/cloud.d.ts.map +1 -1
- package/dist/cloud.js +131 -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/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/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 +122 -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/inbox.d.ts.map +1 -1
- package/dist/inbox.js +4 -0
- package/dist/inbox.js.map +1 -1
- package/dist/index.js +37 -1
- package/dist/index.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-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/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 +486 -14
- 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 +193 -41
- package/dist/tasks.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/package.json +1 -1
- package/public/dashboard.js +119 -37
- package/public/docs.md +18 -0
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';
|
|
@@ -71,7 +71,7 @@ import { getBuildInfo } from './buildInfo.js';
|
|
|
71
71
|
import { appendStoredLog, readStoredLogs, getStoredLogPath } from './logStore.js';
|
|
72
72
|
import { getAgentRoles, getAgentRolesSource, loadAgentRoles, startConfigWatch, suggestAssignee, suggestReviewer, checkWipCap, saveAgentRoles, scoreAssignment, 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 { recordUsage, recordUsageBatch, getUsageSummary, getUsageByAgent, getUsageByModel, getUsageByTask, setCap, listCaps, deleteCap, checkCaps, getRoutingSuggestions, estimateCost, ensureUsageTables } from './usage-tracking.js';
|
|
74
|
+
import { recordUsage, 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,7 @@ 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';
|
|
83
84
|
import { exportBundle, importBundle } from './portability.js';
|
|
84
85
|
import { getNotificationManager } from './notifications.js';
|
|
85
86
|
import { getConnectivityManager } from './connectivity.js';
|
|
@@ -115,7 +116,9 @@ import { getRoutingApprovalQueue, getRoutingSuggestion, buildApprovalPatch, buil
|
|
|
115
116
|
import { calendarManager } from './calendar.js';
|
|
116
117
|
import { calendarEvents } from './calendar-events.js';
|
|
117
118
|
import { startReminderEngine, stopReminderEngine, getReminderEngineStats } from './calendar-reminder-engine.js';
|
|
119
|
+
import { startDeployMonitor, stopDeployMonitor } from './deploy-monitor.js';
|
|
118
120
|
import { exportICS, exportEventICS, importICS } from './calendar-ical.js';
|
|
121
|
+
import { createScheduleEntry, getScheduleEntry, updateScheduleEntry, deleteScheduleEntry, getScheduleFeed } from './schedule.js';
|
|
119
122
|
import { createDoc, getDoc, listDocs, updateDoc, deleteDoc } from './knowledge-docs.js';
|
|
120
123
|
import { onTaskShipped, onDecisionComment, isDecisionComment } from './knowledge-auto-index.js';
|
|
121
124
|
import { upsertHostHeartbeat, getHost, listHosts, removeHost } from './host-registry.js';
|
|
@@ -426,6 +429,9 @@ const QaBundleSchema = z.object({
|
|
|
426
429
|
});
|
|
427
430
|
const ReviewHandoffSchema = z.object({
|
|
428
431
|
task_id: z.string().trim().regex(/^task-[a-zA-Z0-9-]+$/),
|
|
432
|
+
// Stored transactionally (server-side) from POST /tasks/:id/comments.
|
|
433
|
+
// This must always resolve via GET /tasks/:id/comments.
|
|
434
|
+
comment_id: z.string().trim().regex(/^tcomment-\d+-[a-z0-9]+$/i).optional(),
|
|
429
435
|
repo: z.string().trim().min(1).optional(), // optional for config_only tasks
|
|
430
436
|
artifact_path: z.string().trim().min(1), // relaxed: accepts any path (process/, ~/.reflectt/, etc.)
|
|
431
437
|
test_proof: z.string().trim().min(1).optional(), // optional for non-code tasks
|
|
@@ -621,6 +627,29 @@ function enforceQaBundleGateForValidating(status, metadata, expectedTaskId) {
|
|
|
621
627
|
hint: 'Use the same canonical process/... artifact path in both fields.',
|
|
622
628
|
};
|
|
623
629
|
}
|
|
630
|
+
// Canonical artifact reference (until central storage exists):
|
|
631
|
+
// For code tasks, artifact paths must be repo-relative under process/ (or a URL).
|
|
632
|
+
if (reviewPacket && !nonCodeLane) {
|
|
633
|
+
const packetArtifact = typeof reviewPacket.artifact_path === 'string' ? reviewPacket.artifact_path.trim() : '';
|
|
634
|
+
const packetIsUrl = /^https?:\/\//i.test(packetArtifact);
|
|
635
|
+
const packetIsProcess = packetArtifact.startsWith('process/');
|
|
636
|
+
if (packetArtifact && !packetIsUrl && !packetIsProcess) {
|
|
637
|
+
return {
|
|
638
|
+
ok: false,
|
|
639
|
+
error: 'Validating gate: metadata.qa_bundle.review_packet.artifact_path must be under process/ (repo-relative) or a URL',
|
|
640
|
+
hint: 'Set review_packet.artifact_path to process/TASK-...md (committed in the PR) or a PR/GitHub URL.',
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
const metaIsUrl = /^https?:\/\//i.test(artifactPath);
|
|
644
|
+
const metaIsProcess = artifactPath.startsWith('process/');
|
|
645
|
+
if (artifactPath && !metaIsUrl && !metaIsProcess) {
|
|
646
|
+
return {
|
|
647
|
+
ok: false,
|
|
648
|
+
error: 'Validating gate: metadata.artifact_path must be under process/ (repo-relative) or a URL',
|
|
649
|
+
hint: 'Set metadata.artifact_path to process/TASK-...md (committed in the PR) or a PR/GitHub URL.',
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
}
|
|
624
653
|
// PR integrity: validate commit SHA + changed_files against live PR head
|
|
625
654
|
if (!nonCodeLane && reviewPacket?.pr_url) {
|
|
626
655
|
const overrideFlag = metadataObj.pr_integrity_override === true;
|
|
@@ -742,6 +771,40 @@ function applyReviewStateMetadata(existing, parsed, mergedMeta, now) {
|
|
|
742
771
|
};
|
|
743
772
|
console.log(`[ArtifactNormalize] task ${existing.id}: normalized`, normResult.warnings);
|
|
744
773
|
}
|
|
774
|
+
// ── Review handoff comment pointer repair/fill ──
|
|
775
|
+
// If review_handoff exists, ensure comment_id points to a real comment.
|
|
776
|
+
// We do this *server-side* to avoid phantom/unresolvable pointers.
|
|
777
|
+
const rh = metadata.review_handoff;
|
|
778
|
+
if (rh && typeof rh === 'object' && !Array.isArray(rh)) {
|
|
779
|
+
const rhAny = rh;
|
|
780
|
+
const commentId = typeof rhAny.comment_id === 'string' ? rhAny.comment_id.trim() : '';
|
|
781
|
+
const all = taskManager.getTaskComments(existing.id, { includeSuppressed: true });
|
|
782
|
+
const resolves = commentId ? all.some(c => c.id === commentId) : false;
|
|
783
|
+
if (!resolves) {
|
|
784
|
+
// Prefer an explicit category tag; fallback to most recent comment by assignee.
|
|
785
|
+
const assignee = (existing.assignee || '').trim().toLowerCase();
|
|
786
|
+
const byHandoffCategory = all
|
|
787
|
+
.filter(c => {
|
|
788
|
+
const cat = String(c.category || '').toLowerCase();
|
|
789
|
+
return cat === 'review_handoff' || cat === 'handoff';
|
|
790
|
+
});
|
|
791
|
+
const byAssignee = assignee
|
|
792
|
+
? all.filter(c => String(c.author || '').trim().toLowerCase() === assignee)
|
|
793
|
+
: [];
|
|
794
|
+
const candidate = (byHandoffCategory.length > 0
|
|
795
|
+
? byHandoffCategory[byHandoffCategory.length - 1]
|
|
796
|
+
: (byAssignee.length > 0 ? byAssignee[byAssignee.length - 1] : (all.length > 0 ? all[all.length - 1] : null)));
|
|
797
|
+
if (candidate) {
|
|
798
|
+
metadata.review_handoff = { ...rhAny, comment_id: candidate.id };
|
|
799
|
+
metadata.review_handoff_comment_id_autofilled = {
|
|
800
|
+
previous: commentId || null,
|
|
801
|
+
next: candidate.id,
|
|
802
|
+
at: now,
|
|
803
|
+
strategy: byHandoffCategory.length > 0 ? 'category:review_handoff' : (byAssignee.length > 0 ? 'latest_assignee_comment' : 'latest_comment'),
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
745
808
|
}
|
|
746
809
|
if (previousStatus === 'validating' && nextStatus === 'doing' && !incomingReviewState) {
|
|
747
810
|
metadata.review_state = 'needs_author';
|
|
@@ -859,7 +922,13 @@ function isEchoOutOfLaneTask(task) {
|
|
|
859
922
|
// For Echo, anything classified outside content/docs voice lane gets flagged unless reassigned.
|
|
860
923
|
return true;
|
|
861
924
|
}
|
|
862
|
-
|
|
925
|
+
const reviewHandoffValidationStats = {
|
|
926
|
+
failures: 0,
|
|
927
|
+
lastFailureAt: 0,
|
|
928
|
+
lastFailureTaskId: '',
|
|
929
|
+
lastFailureError: '',
|
|
930
|
+
};
|
|
931
|
+
async function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
|
|
863
932
|
if (status !== 'validating')
|
|
864
933
|
return { ok: true };
|
|
865
934
|
if (isTaskAutomatedRecurring(metadata))
|
|
@@ -874,7 +943,7 @@ function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
|
|
|
874
943
|
return {
|
|
875
944
|
ok: false,
|
|
876
945
|
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
|
|
946
|
+
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
947
|
};
|
|
879
948
|
}
|
|
880
949
|
const handoff = parsed.data;
|
|
@@ -885,6 +954,38 @@ function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
|
|
|
885
954
|
hint: 'Set metadata.review_handoff.task_id to the exact task being transitioned.',
|
|
886
955
|
};
|
|
887
956
|
}
|
|
957
|
+
// Ensure review_handoff.comment_id resolves to a real comment.
|
|
958
|
+
// If missing (or stale), we repair it from existing comments; if none exist,
|
|
959
|
+
// we create a server-authored pointer comment so reviewers always have a stable anchor.
|
|
960
|
+
const commentsAll = taskManager.getTaskComments(taskId, { includeSuppressed: true });
|
|
961
|
+
let commentId = typeof handoff.comment_id === 'string' ? handoff.comment_id.trim() : '';
|
|
962
|
+
let handoffComment = commentId ? (commentsAll.find(c => c.id === commentId) || null) : null;
|
|
963
|
+
if (!handoffComment) {
|
|
964
|
+
const byCategory = commentsAll.filter(c => {
|
|
965
|
+
const cat = String(c.category || '').toLowerCase();
|
|
966
|
+
return cat === 'review_handoff' || cat === 'handoff';
|
|
967
|
+
});
|
|
968
|
+
const candidate = byCategory.length > 0
|
|
969
|
+
? byCategory[byCategory.length - 1]
|
|
970
|
+
: (commentsAll.length > 0 ? commentsAll[commentsAll.length - 1] : null);
|
|
971
|
+
if (candidate) {
|
|
972
|
+
commentId = candidate.id;
|
|
973
|
+
handoffComment = candidate;
|
|
974
|
+
}
|
|
975
|
+
else {
|
|
976
|
+
// No comments exist — create a stable anchor comment.
|
|
977
|
+
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' } });
|
|
978
|
+
commentId = created.id;
|
|
979
|
+
handoffComment = created;
|
|
980
|
+
}
|
|
981
|
+
// Persist repaired comment_id into the handoff metadata (server-side).
|
|
982
|
+
;
|
|
983
|
+
handoff.comment_id = commentId;
|
|
984
|
+
const rhObj = root.review_handoff;
|
|
985
|
+
if (rhObj && typeof rhObj === 'object' && !Array.isArray(rhObj)) {
|
|
986
|
+
rhObj.comment_id = commentId;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
888
989
|
// config_only: artifacts live in ~/.reflectt/, no repo/PR required
|
|
889
990
|
// doc_only/non_code/design/docs lanes: no PR/commit required
|
|
890
991
|
const nonCodeLane = handoff.non_code === true || isDesignOrDocsLane(root);
|
|
@@ -904,7 +1005,43 @@ function enforceReviewHandoffGateForValidating(status, taskId, metadata) {
|
|
|
904
1005
|
};
|
|
905
1006
|
}
|
|
906
1007
|
}
|
|
907
|
-
|
|
1008
|
+
// Artifact retrievability gate.
|
|
1009
|
+
// If the artifact isn't accessible from this node (repo / shared-workspace / GitHub fallback),
|
|
1010
|
+
// a reviewer on another host will almost certainly be blocked.
|
|
1011
|
+
const artifactPath = typeof handoff.artifact_path === 'string' ? handoff.artifact_path.trim() : '';
|
|
1012
|
+
const norm = normalizeArtifactPath(artifactPath);
|
|
1013
|
+
if (norm.rejected || !norm.normalized) {
|
|
1014
|
+
return {
|
|
1015
|
+
ok: false,
|
|
1016
|
+
error: `Validating gate: review_handoff.artifact_path is not a valid retrievable reference (${norm.rejectReason || 'invalid path'}).`,
|
|
1017
|
+
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.',
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
// URLs are assumed retrievable.
|
|
1021
|
+
if (/^https?:\/\//i.test(norm.normalized))
|
|
1022
|
+
return { ok: true };
|
|
1023
|
+
// If the file is accessible locally (repo or shared-workspace), accept.
|
|
1024
|
+
const repoRoot = resolve(import.meta.dirname || process.cwd(), '..');
|
|
1025
|
+
const resolved = await resolveTaskArtifact(norm.normalized, repoRoot);
|
|
1026
|
+
if (resolved.accessible)
|
|
1027
|
+
return { ok: true };
|
|
1028
|
+
// GitHub fallback: if PR+commit are known and artifact is process/*, we can build a stable blob URL.
|
|
1029
|
+
const prUrl = root.pr_url || root.qa_bundle?.review_packet?.pr_url || root.review_handoff?.pr_url;
|
|
1030
|
+
const commitSha = root.commit_sha || root.commit || root.qa_bundle?.review_packet?.commit || root.review_handoff?.commit_sha;
|
|
1031
|
+
if (typeof prUrl === 'string' && typeof commitSha === 'string' && norm.normalized.startsWith('process/')) {
|
|
1032
|
+
const blobUrl = buildGitHubBlobUrl(prUrl, commitSha, norm.normalized);
|
|
1033
|
+
if (blobUrl)
|
|
1034
|
+
return { ok: true };
|
|
1035
|
+
}
|
|
1036
|
+
// For non-code tasks, the handoff comment itself is considered the primary artifact.
|
|
1037
|
+
// We only require that comment_id resolves (handled above).
|
|
1038
|
+
if (nonCodeLane)
|
|
1039
|
+
return { ok: true };
|
|
1040
|
+
return {
|
|
1041
|
+
ok: false,
|
|
1042
|
+
error: 'Validating gate: review_handoff.artifact_path is not retrievable from repo/shared-workspace/GitHub fallback.',
|
|
1043
|
+
hint: 'Move the artifact into shared-workspace process/, or reference a PR+commit so GitHub blob fallback can resolve it (process/* only).',
|
|
1044
|
+
};
|
|
908
1045
|
}
|
|
909
1046
|
const DEFAULT_LIMITS = {
|
|
910
1047
|
chatMessages: 50,
|
|
@@ -1516,7 +1653,8 @@ export async function createServer() {
|
|
|
1516
1653
|
await app.register(fastifyWebsocket);
|
|
1517
1654
|
// Multipart file uploads (50MB limit)
|
|
1518
1655
|
const fastifyMultipart = await import('@fastify/multipart');
|
|
1519
|
-
|
|
1656
|
+
const { MAX_SIZE_BYTES: _multipartMax } = await import('./files.js');
|
|
1657
|
+
await app.register(fastifyMultipart.default, { limits: { fileSize: _multipartMax } });
|
|
1520
1658
|
// Normalize error responses to a consistent envelope
|
|
1521
1659
|
app.addHook('preSerialization', async (request, reply, payload) => {
|
|
1522
1660
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
@@ -1561,6 +1699,8 @@ export async function createServer() {
|
|
|
1561
1699
|
envelope.gate = body.gate;
|
|
1562
1700
|
if (body.problems !== undefined)
|
|
1563
1701
|
envelope.problems = body.problems;
|
|
1702
|
+
if (body.tombstone !== undefined)
|
|
1703
|
+
envelope.tombstone = body.tombstone;
|
|
1564
1704
|
if (alreadyEnvelope && body.data !== undefined)
|
|
1565
1705
|
envelope.data = body.data;
|
|
1566
1706
|
// Minimal persisted error log: enables /logs to return real entries.
|
|
@@ -1847,6 +1987,10 @@ export async function createServer() {
|
|
|
1847
1987
|
// Board health execution worker — config from policy
|
|
1848
1988
|
boardHealthWorker.updateConfig(policy.boardHealth);
|
|
1849
1989
|
boardHealthWorker.start();
|
|
1990
|
+
// Activate noise budget enforcement — the 24h canary period is complete.
|
|
1991
|
+
// Canary mode (log-only) is still the default in case of fresh installs,
|
|
1992
|
+
// but on a running server we want real duplicate suppression.
|
|
1993
|
+
noiseBudgetManager.activateEnforcement();
|
|
1850
1994
|
// Noise budget: wire digest flush handler to send batched messages to #ops
|
|
1851
1995
|
noiseBudgetManager.setDigestFlushHandler(async (channel, entries) => {
|
|
1852
1996
|
if (entries.length === 0)
|
|
@@ -1876,6 +2020,8 @@ export async function createServer() {
|
|
|
1876
2020
|
startShippedHeartbeat();
|
|
1877
2021
|
// Calendar reminder engine — polls for pending reminders every 30s
|
|
1878
2022
|
startReminderEngine();
|
|
2023
|
+
// Deploy monitor — alert within 5m when production deploys fail (Vercel + health URL)
|
|
2024
|
+
startDeployMonitor();
|
|
1879
2025
|
app.addHook('onClose', async () => {
|
|
1880
2026
|
clearInterval(idleNudgeTimer);
|
|
1881
2027
|
clearInterval(cadenceWatchdogTimer);
|
|
@@ -1885,6 +2031,7 @@ export async function createServer() {
|
|
|
1885
2031
|
stopShippedHeartbeat();
|
|
1886
2032
|
stopTeamPulse();
|
|
1887
2033
|
stopReminderEngine();
|
|
2034
|
+
stopDeployMonitor();
|
|
1888
2035
|
stopKeepalive();
|
|
1889
2036
|
stopSelfKeepalive();
|
|
1890
2037
|
wsHeartbeat.stop();
|
|
@@ -2703,6 +2850,9 @@ export async function createServer() {
|
|
|
2703
2850
|
reflectionPipeline: { registered: Boolean(reflectionPipelineTimer), lastTickAt: ticks.reflection_pipeline, lastTickAgeSec: ageSec(ticks.reflection_pipeline) },
|
|
2704
2851
|
boardHealthWorker: { registered: board.running, lastTickAt: ticks.board_health || board.lastTickAt, lastTickAgeSec: ageSec(ticks.board_health || board.lastTickAt) },
|
|
2705
2852
|
},
|
|
2853
|
+
reviewHandoffValidation: {
|
|
2854
|
+
...reviewHandoffValidationStats,
|
|
2855
|
+
},
|
|
2706
2856
|
reflectionPipelineHealth: {
|
|
2707
2857
|
...reflectionPipelineHealth,
|
|
2708
2858
|
},
|
|
@@ -3037,6 +3187,23 @@ export async function createServer() {
|
|
|
3037
3187
|
};
|
|
3038
3188
|
}
|
|
3039
3189
|
const data = parsedBody.data;
|
|
3190
|
+
// Reserve system sender for server-internal control-plane messages.
|
|
3191
|
+
// Prevent browser clients (dashboard.js) or external callers from spoofing system alerts.
|
|
3192
|
+
//
|
|
3193
|
+
// Allow explicit internal callers (tests/tools) via header:
|
|
3194
|
+
// x-reflectt-internal: true
|
|
3195
|
+
if (data.from === 'system') {
|
|
3196
|
+
const internal = String(request.headers['x-reflectt-internal'] || '').toLowerCase() === 'true';
|
|
3197
|
+
if (!internal) {
|
|
3198
|
+
reply.code(403);
|
|
3199
|
+
return {
|
|
3200
|
+
success: false,
|
|
3201
|
+
error: 'Sender "system" is reserved (use from="dashboard" or your agent name).',
|
|
3202
|
+
code: 'SENDER_RESERVED',
|
|
3203
|
+
hint: 'Only internal callers may emit system messages. Add header x-reflectt-internal:true for test/tooling.',
|
|
3204
|
+
};
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3040
3207
|
// Require at least content or attachments
|
|
3041
3208
|
if (!data.content && (!data.attachments || data.attachments.length === 0)) {
|
|
3042
3209
|
reply.code(400);
|
|
@@ -4306,6 +4473,24 @@ export async function createServer() {
|
|
|
4306
4473
|
};
|
|
4307
4474
|
}
|
|
4308
4475
|
if (!resolved.task || !resolved.resolvedId) {
|
|
4476
|
+
// Check if this task was deleted — return 410 Gone with tombstone metadata instead of 404.
|
|
4477
|
+
const tombstone = taskManager.getTaskDeletionTombstone(request.params.id);
|
|
4478
|
+
if (tombstone) {
|
|
4479
|
+
reply.code(410);
|
|
4480
|
+
return {
|
|
4481
|
+
success: false,
|
|
4482
|
+
error: 'Task has been deleted',
|
|
4483
|
+
code: 'TASK_DELETED',
|
|
4484
|
+
status: 410,
|
|
4485
|
+
tombstone: {
|
|
4486
|
+
taskId: tombstone.taskId,
|
|
4487
|
+
deletedAt: tombstone.deletedAt,
|
|
4488
|
+
deletedBy: tombstone.deletedBy,
|
|
4489
|
+
previousStatus: tombstone.previousStatus,
|
|
4490
|
+
title: tombstone.title,
|
|
4491
|
+
},
|
|
4492
|
+
};
|
|
4493
|
+
}
|
|
4309
4494
|
reply.code(404);
|
|
4310
4495
|
return {
|
|
4311
4496
|
error: 'Task not found',
|
|
@@ -4987,6 +5172,24 @@ export async function createServer() {
|
|
|
4987
5172
|
// fan out inbox-visible notifications to assignee/reviewer + explicit @mentions.
|
|
4988
5173
|
// Notification routing respects per-agent preferences (quiet hours, mute, filters).
|
|
4989
5174
|
const task = taskManager.getTask(resolved.resolvedId);
|
|
5175
|
+
// ── Transactional review_handoff.comment_id stamping ──
|
|
5176
|
+
// If the author tags this comment as the handoff entrypoint, the server stamps
|
|
5177
|
+
// metadata.review_handoff.comment_id from the persisted comment ID.
|
|
5178
|
+
// This prevents clients from supplying phantom IDs.
|
|
5179
|
+
const category = typeof data.category === 'string' ? String(data.category).trim().toLowerCase() : '';
|
|
5180
|
+
if (task && (category === 'review_handoff' || category === 'handoff')) {
|
|
5181
|
+
const meta = (task.metadata || {});
|
|
5182
|
+
const rh = meta.review_handoff;
|
|
5183
|
+
if (rh && typeof rh === 'object' && !Array.isArray(rh)) {
|
|
5184
|
+
const rhAny = rh;
|
|
5185
|
+
if (rhAny.comment_id !== comment.id) {
|
|
5186
|
+
taskManager.patchTaskMetadata(task.id, {
|
|
5187
|
+
review_handoff: { ...rhAny, comment_id: comment.id },
|
|
5188
|
+
review_handoff_comment_id_stamped_at: Date.now(),
|
|
5189
|
+
});
|
|
5190
|
+
}
|
|
5191
|
+
}
|
|
5192
|
+
}
|
|
4990
5193
|
// Never fan out notifications for test-harness tasks.
|
|
4991
5194
|
// Our repo contains a few "LIVE server" tests (BASE=127.0.0.1:4445) that create
|
|
4992
5195
|
// tasks/comments with metadata.is_test=true. Without this guard, running `npm test`
|
|
@@ -6106,7 +6309,23 @@ export async function createServer() {
|
|
|
6106
6309
|
}
|
|
6107
6310
|
// Merge incoming metadata with existing for gate checks + persistence.
|
|
6108
6311
|
// Apply auto-defaults (ETA, artifact_path) when not explicitly provided.
|
|
6109
|
-
|
|
6312
|
+
// Do not accept caller-supplied review_handoff.comment_id (it must be stamped server-side from POST /tasks/:id/comments).
|
|
6313
|
+
// If a client tries to patch it directly, we strip it to prevent phantom pointers.
|
|
6314
|
+
const incomingMetaRaw = (parsed.metadata || {});
|
|
6315
|
+
const incomingMeta = { ...incomingMetaRaw };
|
|
6316
|
+
const incomingRh = incomingMeta.review_handoff;
|
|
6317
|
+
if (incomingRh && typeof incomingRh === 'object' && !Array.isArray(incomingRh)) {
|
|
6318
|
+
const rhAny = incomingRh;
|
|
6319
|
+
if (typeof rhAny.comment_id === 'string') {
|
|
6320
|
+
const { comment_id, ...rest } = rhAny;
|
|
6321
|
+
incomingMeta.review_handoff = rest;
|
|
6322
|
+
incomingMeta.review_handoff_comment_id_stripped = {
|
|
6323
|
+
stripped: true,
|
|
6324
|
+
attempted: comment_id,
|
|
6325
|
+
at: Date.now(),
|
|
6326
|
+
};
|
|
6327
|
+
}
|
|
6328
|
+
}
|
|
6110
6329
|
const effectiveTargetStatus = parsed.status ?? existing.status;
|
|
6111
6330
|
const autoFilledMeta = applyAutoDefaults(lookup.resolvedId, effectiveTargetStatus, incomingMeta);
|
|
6112
6331
|
const mergedRawMeta = { ...(existing.metadata || {}), ...autoFilledMeta };
|
|
@@ -6285,8 +6504,12 @@ export async function createServer() {
|
|
|
6285
6504
|
hint: qaGate.hint,
|
|
6286
6505
|
};
|
|
6287
6506
|
}
|
|
6288
|
-
const handoffGate = enforceReviewHandoffGateForValidating(effectiveStatus, lookup.resolvedId, mergedMeta);
|
|
6507
|
+
const handoffGate = await enforceReviewHandoffGateForValidating(effectiveStatus, lookup.resolvedId, mergedMeta);
|
|
6289
6508
|
if (!handoffGate.ok) {
|
|
6509
|
+
reviewHandoffValidationStats.failures += 1;
|
|
6510
|
+
reviewHandoffValidationStats.lastFailureAt = Date.now();
|
|
6511
|
+
reviewHandoffValidationStats.lastFailureTaskId = lookup.resolvedId;
|
|
6512
|
+
reviewHandoffValidationStats.lastFailureError = handoffGate.error;
|
|
6290
6513
|
reply.code(400);
|
|
6291
6514
|
return {
|
|
6292
6515
|
success: false,
|
|
@@ -6617,6 +6840,7 @@ export async function createServer() {
|
|
|
6617
6840
|
const prLine = prUrl ? `\nPR: ${prUrl}` : '';
|
|
6618
6841
|
chatManager.sendMessage({
|
|
6619
6842
|
from: 'system',
|
|
6843
|
+
to: existing.reviewer,
|
|
6620
6844
|
content: `@${existing.reviewer} [reviewRequested:${task.id}] ${task.title} → validating${prLine}`,
|
|
6621
6845
|
channel: 'task-notifications',
|
|
6622
6846
|
metadata: {
|
|
@@ -6726,6 +6950,7 @@ export async function createServer() {
|
|
|
6726
6950
|
const reviewMsg = `@${task.reviewer} review requested: **${task.title}** (${task.id})${prLink}. Please approve or flag issues.`;
|
|
6727
6951
|
chatManager.sendMessage({
|
|
6728
6952
|
from: 'system',
|
|
6953
|
+
to: task.reviewer,
|
|
6729
6954
|
content: reviewMsg,
|
|
6730
6955
|
channel: 'reviews',
|
|
6731
6956
|
metadata: {
|
|
@@ -6844,6 +7069,7 @@ export async function createServer() {
|
|
|
6844
7069
|
const sourceInfo = getAgentRolesSource();
|
|
6845
7070
|
return { success: true, agents: enriched, config: sourceInfo };
|
|
6846
7071
|
};
|
|
7072
|
+
app.get('/agents', async () => buildRoleRegistryPayload());
|
|
6847
7073
|
app.get('/agents/roles', async () => buildRoleRegistryPayload());
|
|
6848
7074
|
// Team-scoped alias for assignment-engine consumers
|
|
6849
7075
|
app.get('/team/roles', async () => {
|
|
@@ -6860,6 +7086,13 @@ export async function createServer() {
|
|
|
6860
7086
|
// ── File upload/download ──
|
|
6861
7087
|
app.post('/files', async (request, reply) => {
|
|
6862
7088
|
try {
|
|
7089
|
+
const { MAX_SIZE_BYTES: maxBytes } = await import('./files.js');
|
|
7090
|
+
// Early rejection via Content-Length before reading body
|
|
7091
|
+
const declaredLength = parseInt(String(request.headers['content-length'] || ''), 10);
|
|
7092
|
+
if (!Number.isNaN(declaredLength) && declaredLength > maxBytes) {
|
|
7093
|
+
reply.code(413);
|
|
7094
|
+
return { success: false, error: `File exceeds ${maxBytes / (1024 * 1024)}MB limit (Content-Length: ${declaredLength} bytes)` };
|
|
7095
|
+
}
|
|
6863
7096
|
const data = await request.file();
|
|
6864
7097
|
if (!data) {
|
|
6865
7098
|
reply.code(400);
|
|
@@ -6869,10 +7102,10 @@ export async function createServer() {
|
|
|
6869
7102
|
for await (const chunk of data.file)
|
|
6870
7103
|
chunks.push(chunk);
|
|
6871
7104
|
const buffer = Buffer.concat(chunks);
|
|
6872
|
-
// Check if stream was truncated (exceeds limit)
|
|
7105
|
+
// Check if stream was truncated (exceeds multipart limit)
|
|
6873
7106
|
if (data.file.truncated) {
|
|
6874
7107
|
reply.code(413);
|
|
6875
|
-
return { success: false, error:
|
|
7108
|
+
return { success: false, error: `File exceeds ${maxBytes / (1024 * 1024)}MB limit` };
|
|
6876
7109
|
}
|
|
6877
7110
|
const fields = data.fields;
|
|
6878
7111
|
const uploadedBy = typeof fields?.uploadedBy?.value === 'string' ? fields.uploadedBy.value : 'anonymous';
|
|
@@ -6894,10 +7127,11 @@ export async function createServer() {
|
|
|
6894
7127
|
return result;
|
|
6895
7128
|
}
|
|
6896
7129
|
catch (err) {
|
|
7130
|
+
const { MAX_SIZE_BYTES: maxBytes } = await import('./files.js');
|
|
6897
7131
|
const msg = err instanceof Error ? err.message : String(err);
|
|
6898
7132
|
if (msg.includes('Request file too large')) {
|
|
6899
7133
|
reply.code(413);
|
|
6900
|
-
return { success: false, error:
|
|
7134
|
+
return { success: false, error: `File exceeds ${maxBytes / (1024 * 1024)}MB limit` };
|
|
6901
7135
|
}
|
|
6902
7136
|
reply.code(500);
|
|
6903
7137
|
return { success: false, error: 'Upload failed' };
|
|
@@ -7093,6 +7327,137 @@ export async function createServer() {
|
|
|
7093
7327
|
return { success: false, error: msg };
|
|
7094
7328
|
}
|
|
7095
7329
|
});
|
|
7330
|
+
// POST /agents — Add a single agent to the team
|
|
7331
|
+
app.post('/agents', async (request, reply) => {
|
|
7332
|
+
const body = request.body;
|
|
7333
|
+
const name = typeof body.name === 'string' ? body.name.trim().toLowerCase() : '';
|
|
7334
|
+
const role = typeof body.role === 'string' ? body.role.trim() : '';
|
|
7335
|
+
const description = typeof body.description === 'string' ? body.description.trim() : '';
|
|
7336
|
+
if (!name) {
|
|
7337
|
+
reply.code(400);
|
|
7338
|
+
return { success: false, error: 'name is required' };
|
|
7339
|
+
}
|
|
7340
|
+
if (!role) {
|
|
7341
|
+
reply.code(400);
|
|
7342
|
+
return { success: false, error: 'role is required' };
|
|
7343
|
+
}
|
|
7344
|
+
if (/[^a-z0-9_-]/.test(name)) {
|
|
7345
|
+
reply.code(400);
|
|
7346
|
+
return { success: false, error: 'name must be lowercase alphanumeric (a-z, 0-9, -, _)' };
|
|
7347
|
+
}
|
|
7348
|
+
// Check if agent already exists
|
|
7349
|
+
const existing = getAgentRoles().find(r => r.name === name);
|
|
7350
|
+
if (existing) {
|
|
7351
|
+
reply.code(409);
|
|
7352
|
+
return { success: false, error: `Agent "${name}" already exists (role: ${existing.role})` };
|
|
7353
|
+
}
|
|
7354
|
+
// Read existing YAML and append new agent
|
|
7355
|
+
const { readFileSync, writeFileSync, existsSync } = await import('node:fs');
|
|
7356
|
+
const { join } = await import('node:path');
|
|
7357
|
+
const filePath = join(REFLECTT_HOME, 'TEAM-ROLES.yaml');
|
|
7358
|
+
let yaml = '';
|
|
7359
|
+
if (existsSync(filePath)) {
|
|
7360
|
+
yaml = readFileSync(filePath, 'utf-8');
|
|
7361
|
+
}
|
|
7362
|
+
if (!yaml.includes('agents:')) {
|
|
7363
|
+
yaml = 'agents:\n';
|
|
7364
|
+
}
|
|
7365
|
+
// Build agent YAML entry
|
|
7366
|
+
const affinityTags = Array.isArray(body.affinityTags) ? body.affinityTags : [role];
|
|
7367
|
+
const wipCap = typeof body.wipCap === 'number' ? body.wipCap : 2;
|
|
7368
|
+
const desc = description || `${role} agent.`;
|
|
7369
|
+
const entry = [
|
|
7370
|
+
` - name: ${name}`,
|
|
7371
|
+
` role: ${role}`,
|
|
7372
|
+
` description: ${desc}`,
|
|
7373
|
+
` affinityTags: [${affinityTags.join(', ')}]`,
|
|
7374
|
+
` wipCap: ${wipCap}`,
|
|
7375
|
+
].join('\n');
|
|
7376
|
+
// Insert before lanes: section (if present), otherwise append
|
|
7377
|
+
const lanesIdx = yaml.indexOf('\nlanes:');
|
|
7378
|
+
if (lanesIdx >= 0) {
|
|
7379
|
+
yaml = yaml.slice(0, lanesIdx) + '\n' + entry + yaml.slice(lanesIdx);
|
|
7380
|
+
}
|
|
7381
|
+
else {
|
|
7382
|
+
yaml = yaml.trimEnd() + '\n' + entry + '\n';
|
|
7383
|
+
}
|
|
7384
|
+
try {
|
|
7385
|
+
writeFileSync(filePath, yaml, 'utf-8');
|
|
7386
|
+
const { loadAgentRoles } = await import('./assignment.js');
|
|
7387
|
+
const reloaded = loadAgentRoles();
|
|
7388
|
+
// Scaffold agent workspace if it doesn't exist
|
|
7389
|
+
let workspaceCreated = false;
|
|
7390
|
+
try {
|
|
7391
|
+
const { mkdirSync, existsSync: dirExists } = await import('node:fs');
|
|
7392
|
+
const workspaceDir = join(REFLECTT_HOME, `workspace-${name}`);
|
|
7393
|
+
if (!dirExists(workspaceDir)) {
|
|
7394
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
7395
|
+
writeFileSync(join(workspaceDir, 'SOUL.md'), `# ${name}\n\n*${desc}*\n`, 'utf-8');
|
|
7396
|
+
writeFileSync(join(workspaceDir, 'AGENTS.md'), `# ${name}\n\nRole: ${role}\n\n${desc}\n`, 'utf-8');
|
|
7397
|
+
workspaceCreated = true;
|
|
7398
|
+
}
|
|
7399
|
+
}
|
|
7400
|
+
catch (wsErr) {
|
|
7401
|
+
console.warn(`[Agents] Workspace scaffold failed for ${name}:`, wsErr.message);
|
|
7402
|
+
}
|
|
7403
|
+
return {
|
|
7404
|
+
success: true,
|
|
7405
|
+
agent: { name, role, description: desc, wipCap },
|
|
7406
|
+
totalAgents: reloaded.roles.length,
|
|
7407
|
+
workspaceCreated,
|
|
7408
|
+
hint: `Agent "${name}" added to team and hot-reloaded. Start heartbeating: GET /heartbeat/${name}`,
|
|
7409
|
+
};
|
|
7410
|
+
}
|
|
7411
|
+
catch (err) {
|
|
7412
|
+
const msg = err instanceof Error ? err.message : 'Failed to save agent';
|
|
7413
|
+
reply.code(500);
|
|
7414
|
+
return { success: false, error: msg };
|
|
7415
|
+
}
|
|
7416
|
+
});
|
|
7417
|
+
// DELETE /agents/:name — Remove an agent from the team
|
|
7418
|
+
app.delete('/agents/:name', async (request, reply) => {
|
|
7419
|
+
const name = request.params.name.toLowerCase();
|
|
7420
|
+
const existing = getAgentRoles().find(r => r.name === name);
|
|
7421
|
+
if (!existing) {
|
|
7422
|
+
reply.code(404);
|
|
7423
|
+
return { success: false, error: `Agent "${name}" not found` };
|
|
7424
|
+
}
|
|
7425
|
+
const { readFileSync, writeFileSync } = await import('node:fs');
|
|
7426
|
+
const { join } = await import('node:path');
|
|
7427
|
+
const filePath = join(REFLECTT_HOME, 'TEAM-ROLES.yaml');
|
|
7428
|
+
let yaml = readFileSync(filePath, 'utf-8');
|
|
7429
|
+
// Remove the agent block: from " - name: <name>" to the next " - name:" or top-level key or EOF
|
|
7430
|
+
const lines = yaml.split('\n');
|
|
7431
|
+
const filtered = [];
|
|
7432
|
+
let skipping = false;
|
|
7433
|
+
for (const line of lines) {
|
|
7434
|
+
if (line.match(new RegExp(`^\\s+-\\s+name:\\s+${name}\\s*$`))) {
|
|
7435
|
+
skipping = true;
|
|
7436
|
+
continue;
|
|
7437
|
+
}
|
|
7438
|
+
if (skipping) {
|
|
7439
|
+
// Stop skipping at next agent entry, top-level key, or blank line before top-level
|
|
7440
|
+
if (line.match(/^\s+-\s+name:\s/) || line.match(/^[a-z]/)) {
|
|
7441
|
+
skipping = false;
|
|
7442
|
+
filtered.push(line);
|
|
7443
|
+
}
|
|
7444
|
+
continue;
|
|
7445
|
+
}
|
|
7446
|
+
filtered.push(line);
|
|
7447
|
+
}
|
|
7448
|
+
yaml = filtered.join('\n');
|
|
7449
|
+
try {
|
|
7450
|
+
writeFileSync(filePath, yaml, 'utf-8');
|
|
7451
|
+
const { loadAgentRoles } = await import('./assignment.js');
|
|
7452
|
+
const reloaded = loadAgentRoles();
|
|
7453
|
+
return { success: true, removed: name, totalAgents: reloaded.roles.length };
|
|
7454
|
+
}
|
|
7455
|
+
catch (err) {
|
|
7456
|
+
const msg = err instanceof Error ? err.message : 'Failed to remove agent';
|
|
7457
|
+
reply.code(500);
|
|
7458
|
+
return { success: false, error: msg };
|
|
7459
|
+
}
|
|
7460
|
+
});
|
|
7096
7461
|
// Resolve a mention string (name, displayName, or alias) to an agent ID
|
|
7097
7462
|
app.get('/resolve/mention/:mention', async (request) => {
|
|
7098
7463
|
const agentName = resolveAgentMention(request.params.mention);
|
|
@@ -9063,6 +9428,7 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
9063
9428
|
endpoints: [
|
|
9064
9429
|
{ method: 'POST', path: '/reflections', hint: 'Submit. Required: pain, impact, evidence[], went_well, suspected_why, proposed_fix, confidence, role_type, author' },
|
|
9065
9430
|
{ method: 'GET', path: '/reflections', hint: 'List. Query: author, limit' },
|
|
9431
|
+
{ method: 'GET', path: '/reflections/schema', hint: 'Required/optional fields, role types, severity levels, dedup rules' },
|
|
9066
9432
|
],
|
|
9067
9433
|
},
|
|
9068
9434
|
activity: {
|
|
@@ -9316,8 +9682,18 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
9316
9682
|
reply.code(404);
|
|
9317
9683
|
return { success: false, error: 'Task not found', input: id, suggestions: lookup.suggestions };
|
|
9318
9684
|
}
|
|
9319
|
-
|
|
9320
|
-
|
|
9685
|
+
const assignee = String(task.assignee || '').trim();
|
|
9686
|
+
const isUnassigned = assignee.length === 0 || assignee.toLowerCase() === 'unassigned';
|
|
9687
|
+
if (!isUnassigned) {
|
|
9688
|
+
reply.code(409);
|
|
9689
|
+
return {
|
|
9690
|
+
success: false,
|
|
9691
|
+
error: `Task already assigned to ${assignee}`,
|
|
9692
|
+
code: 'TASK_ALREADY_ASSIGNED',
|
|
9693
|
+
status: 409,
|
|
9694
|
+
assignee,
|
|
9695
|
+
hint: 'Task claims are atomic: first claim wins. Pull another task via GET /tasks/next.',
|
|
9696
|
+
};
|
|
9321
9697
|
}
|
|
9322
9698
|
const shortId = lookup.resolvedId.replace(/^task-\d+-/, '');
|
|
9323
9699
|
const branch = `${body.agent}/task-${shortId}`;
|
|
@@ -10170,6 +10546,42 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
10170
10546
|
const q = request.query;
|
|
10171
10547
|
return { suggestions: getRoutingSuggestions({ since: q.since ? Number(q.since) : undefined }) };
|
|
10172
10548
|
});
|
|
10549
|
+
// ── Cost Dashboard ──
|
|
10550
|
+
// GET /costs — aggregated spend: daily by model, avg per lane, top tasks
|
|
10551
|
+
app.get('/costs', async (request) => {
|
|
10552
|
+
const q = request.query;
|
|
10553
|
+
const days = q.days ? Math.min(Number(q.days), 90) : 7;
|
|
10554
|
+
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
10555
|
+
const dailyByModel = getDailySpendByModel({ days });
|
|
10556
|
+
const byLane = getAvgCostByLane({ days: Math.max(days, 30) }); // lane data needs more window
|
|
10557
|
+
const byAgent = getAvgCostByAgent({ days: Math.max(days, 30) });
|
|
10558
|
+
const topTasks = getUsageByTask({ since, limit: 20 });
|
|
10559
|
+
const summary = getUsageSummary({ since });
|
|
10560
|
+
// Roll up daily totals per day for the sparkline
|
|
10561
|
+
const dailyTotals = {};
|
|
10562
|
+
for (const row of dailyByModel) {
|
|
10563
|
+
dailyTotals[row.date] = (dailyTotals[row.date] ?? 0) + row.total_cost_usd;
|
|
10564
|
+
}
|
|
10565
|
+
// Note: avg_cost_by_lane and avg_cost_by_agent use Math.max(days, 30) as their window.
|
|
10566
|
+
// Lane/agent-level averages need task density to be meaningful — a 7-day window might
|
|
10567
|
+
// have 0-1 closed tasks per agent/lane and produce misleading numbers. Using a 30-day
|
|
10568
|
+
// floor is intentional. daily_by_model, daily_totals, and top_tasks_by_cost use the
|
|
10569
|
+
// requested `days` window directly and will match the `window_days` field in the response.
|
|
10570
|
+
const laneAgentWindow = Math.max(days, 30);
|
|
10571
|
+
return {
|
|
10572
|
+
window_days: days,
|
|
10573
|
+
lane_agent_window_days: laneAgentWindow,
|
|
10574
|
+
summary: Array.isArray(summary) ? summary[0] ?? null : summary,
|
|
10575
|
+
daily_by_model: dailyByModel,
|
|
10576
|
+
daily_totals: Object.entries(dailyTotals)
|
|
10577
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
10578
|
+
.map(([date, total_cost_usd]) => ({ date, total_cost_usd })),
|
|
10579
|
+
avg_cost_by_lane: byLane,
|
|
10580
|
+
avg_cost_by_agent: byAgent,
|
|
10581
|
+
top_tasks_by_cost: topTasks,
|
|
10582
|
+
generated_at: Date.now(),
|
|
10583
|
+
};
|
|
10584
|
+
});
|
|
10173
10585
|
// Operational metrics endpoint (lightweight dashboard contract)
|
|
10174
10586
|
app.get('/metrics', async () => {
|
|
10175
10587
|
const startedAt = Date.now();
|
|
@@ -10587,6 +10999,10 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
10587
10999
|
request.headers['x-request-id'] ||
|
|
10588
11000
|
undefined;
|
|
10589
11001
|
const idempotencyKey = deliveryId ? `${provider}_${deliveryId}` : undefined;
|
|
11002
|
+
// Enrich GitHub webhook payloads with agent attribution
|
|
11003
|
+
const enrichedBody = provider === 'github'
|
|
11004
|
+
? enrichWebhookPayload(body)
|
|
11005
|
+
: body;
|
|
10590
11006
|
// Enqueue through delivery engine for each configured target
|
|
10591
11007
|
const events = [];
|
|
10592
11008
|
for (const route of routes) {
|
|
@@ -10597,7 +11013,7 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
10597
11013
|
const event = webhookDelivery.enqueue({
|
|
10598
11014
|
provider,
|
|
10599
11015
|
eventType,
|
|
10600
|
-
payload:
|
|
11016
|
+
payload: enrichedBody,
|
|
10601
11017
|
targetUrl: `http://localhost:${serverConfig.port}${route.path}`,
|
|
10602
11018
|
idempotencyKey: idempotencyKey ? `${idempotencyKey}_${route.id}` : undefined,
|
|
10603
11019
|
metadata: {
|
|
@@ -11344,6 +11760,62 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
11344
11760
|
app.get('/calendar/reminders/stats', async () => {
|
|
11345
11761
|
return getReminderEngineStats();
|
|
11346
11762
|
});
|
|
11763
|
+
// ── Schedule feed — team-wide time-awareness ──────────────────────────────
|
|
11764
|
+
//
|
|
11765
|
+
// Provides canonical records for deploy windows, focus blocks, and
|
|
11766
|
+
// scheduled task work so agents can coordinate timing without chat.
|
|
11767
|
+
//
|
|
11768
|
+
// MVP scope: one-off windows only. No iCal/RRULE, no reminders.
|
|
11769
|
+
// See src/schedule.ts for what is intentionally NOT included.
|
|
11770
|
+
// GET /schedule/feed — upcoming entries in chronological order
|
|
11771
|
+
app.get('/schedule/feed', async (request) => {
|
|
11772
|
+
const q = request.query;
|
|
11773
|
+
const kinds = q.kinds ? q.kinds.split(',') : undefined;
|
|
11774
|
+
const entries = getScheduleFeed({
|
|
11775
|
+
after: q.after ? parseInt(q.after, 10) : undefined,
|
|
11776
|
+
before: q.before ? parseInt(q.before, 10) : undefined,
|
|
11777
|
+
kinds,
|
|
11778
|
+
owner: q.owner,
|
|
11779
|
+
limit: q.limit ? parseInt(q.limit, 10) : undefined,
|
|
11780
|
+
});
|
|
11781
|
+
return { entries, count: entries.length };
|
|
11782
|
+
});
|
|
11783
|
+
// POST /schedule/entries — create a new schedule entry
|
|
11784
|
+
app.post('/schedule/entries', async (request, reply) => {
|
|
11785
|
+
try {
|
|
11786
|
+
const entry = createScheduleEntry(request.body);
|
|
11787
|
+
return reply.status(201).send({ entry });
|
|
11788
|
+
}
|
|
11789
|
+
catch (err) {
|
|
11790
|
+
return reply.status(400).send({ error: err.message });
|
|
11791
|
+
}
|
|
11792
|
+
});
|
|
11793
|
+
// GET /schedule/entries/:id
|
|
11794
|
+
app.get('/schedule/entries/:id', async (request, reply) => {
|
|
11795
|
+
const entry = getScheduleEntry(request.params.id);
|
|
11796
|
+
if (!entry)
|
|
11797
|
+
return reply.status(404).send({ error: 'Not found' });
|
|
11798
|
+
return { entry };
|
|
11799
|
+
});
|
|
11800
|
+
// PATCH /schedule/entries/:id
|
|
11801
|
+
app.patch('/schedule/entries/:id', async (request, reply) => {
|
|
11802
|
+
try {
|
|
11803
|
+
const entry = updateScheduleEntry(request.params.id, request.body);
|
|
11804
|
+
if (!entry)
|
|
11805
|
+
return reply.status(404).send({ error: 'Not found' });
|
|
11806
|
+
return { entry };
|
|
11807
|
+
}
|
|
11808
|
+
catch (err) {
|
|
11809
|
+
return reply.status(400).send({ error: err.message });
|
|
11810
|
+
}
|
|
11811
|
+
});
|
|
11812
|
+
// DELETE /schedule/entries/:id
|
|
11813
|
+
app.delete('/schedule/entries/:id', async (request, reply) => {
|
|
11814
|
+
const deleted = deleteScheduleEntry(request.params.id);
|
|
11815
|
+
if (!deleted)
|
|
11816
|
+
return reply.status(404).send({ error: 'Not found' });
|
|
11817
|
+
return reply.status(204).send();
|
|
11818
|
+
});
|
|
11347
11819
|
// ── iCal Import/Export ───────────────────────────────────────────────────
|
|
11348
11820
|
// Export all events as .ics
|
|
11349
11821
|
app.get('/calendar/export.ics', async (request, reply) => {
|