@kontextmind/kxm 0.6.0
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/.claude-plugin/marketplace.json +19 -0
- package/.kxm/README.md +14 -0
- package/.kxm/assets/README.md +5 -0
- package/.kxm/assets/retrospectives/README.md +5 -0
- package/.kxm/config/README.md +5 -0
- package/.kxm/config/agents.json +43 -0
- package/.kxm/config/env.example +56 -0
- package/.kxm/config/update.example.yaml +9 -0
- package/.kxm/config/workflows/fix.json +160 -0
- package/.kxm/config/workflows/jira-development.json +116 -0
- package/.kxm/config/workflows/provenance-quorum.json +150 -0
- package/.kxm/config/workflows/v04-dogfood.json +72 -0
- package/CHANGELOG.md +465 -0
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/SECURITY.md +72 -0
- package/docs/README.md +48 -0
- package/docs/agent-communication-envelopes-and-gates.md +553 -0
- package/docs/architecture.md +242 -0
- package/docs/assignment-runner.md +241 -0
- package/docs/configuration.md +361 -0
- package/docs/continuous-improvement.md +114 -0
- package/docs/getting-started.md +253 -0
- package/docs/kxm-handbook.md +1090 -0
- package/docs/operations.md +205 -0
- package/docs/provenance-gates.md +291 -0
- package/docs/skills.md +45 -0
- package/docs/templates/README.md +95 -0
- package/docs/templates/adr.md +88 -0
- package/docs/templates/architecture.md +120 -0
- package/docs/templates/bug-fix.md +109 -0
- package/docs/templates/feature.md +108 -0
- package/docs/templates/handoff.md +72 -0
- package/docs/templates/postmortem.md +77 -0
- package/docs/templates/research.md +100 -0
- package/docs/templates/review.md +85 -0
- package/docs/templates/runbook.md +73 -0
- package/docs/templates/test-plan.md +87 -0
- package/docs/templates/test-report.md +72 -0
- package/docs/test-matrix.md +121 -0
- package/docs/troubleshooting.md +249 -0
- package/docs/vnext/README.md +62 -0
- package/docs/vnext/architecture.md +185 -0
- package/docs/vnext/effects-and-recovery.md +172 -0
- package/docs/vnext/lifecycles.md +235 -0
- package/docs/vnext/migration.md +220 -0
- package/docs/vnext/routing.md +184 -0
- package/docs/vnext/synchronization.md +172 -0
- package/docs/vnext/terminology.md +240 -0
- package/docs/vnext/validation.md +335 -0
- package/docs/webhook-workflows.md +240 -0
- package/docs/workflow-guide.md +1150 -0
- package/examples/README.md +102 -0
- package/examples/provenance-workflow.json +40 -0
- package/examples/requester.ts +30 -0
- package/examples/reviewer-agent.ts +29 -0
- package/examples/roundtrip.ts +46 -0
- package/examples/vnext/.kxm/agents/coordinator.yaml +16 -0
- package/examples/vnext/.kxm/agents/critic-1.yaml +16 -0
- package/examples/vnext/.kxm/agents/critic-2.yaml +15 -0
- package/examples/vnext/.kxm/agents/critic-3.yaml +15 -0
- package/examples/vnext/.kxm/agents/implementer.yaml +15 -0
- package/examples/vnext/.kxm/agents/planner.yaml +13 -0
- package/examples/vnext/.kxm/agents/reproducer.yaml +15 -0
- package/examples/vnext/.kxm/agents/reviewer.yaml +15 -0
- package/examples/vnext/.kxm/gates.yaml +8 -0
- package/examples/vnext/.kxm/models/critic-claude.yaml +11 -0
- package/examples/vnext/.kxm/models/critic-gemini.yaml +11 -0
- package/examples/vnext/.kxm/models/critic-grok.yaml +12 -0
- package/examples/vnext/.kxm/models/implementation.yaml +14 -0
- package/examples/vnext/.kxm/models/primary.yaml +17 -0
- package/examples/vnext/.kxm/prices.yaml +111 -0
- package/examples/vnext/.kxm/project/env.yaml +7 -0
- package/examples/vnext/.kxm/project.yaml +32 -0
- package/examples/vnext/.kxm/repo/repo.yaml +8 -0
- package/examples/vnext/.kxm/workflows/default.yaml +92 -0
- package/examples/vnext/.kxm/workflows/fix.yaml +376 -0
- package/examples/vnext/.kxm/workflows/improve.yaml +57 -0
- package/examples/vnext/README.md +53 -0
- package/examples/vnext/records/assignment-result-recorded.json +63 -0
- package/examples/vnext/records/assignment-result.json +46 -0
- package/examples/vnext/records/context-candidate.json +42 -0
- package/examples/vnext/records/delivery-manifest.json +66 -0
- package/examples/vnext/records/effect-uncertainty-resolved-sync.json +67 -0
- package/examples/vnext/records/effect-uncertainty-resolved.json +62 -0
- package/examples/vnext/records/run-created.json +54 -0
- package/examples/vnext/records/sync-event.json +65 -0
- package/examples/vnext/repositories/api/.kxm/repo/env.yaml +7 -0
- package/examples/vnext/repositories/api/.kxm/repo/repo.yaml +8 -0
- package/examples/vnext/repositories/web/.kxm/repo/repo.yaml +8 -0
- package/examples/workflow-signal.ts +63 -0
- package/package.json +129 -0
- package/plugins/kxm/.claude-plugin/plugin.json +73 -0
- package/plugins/kxm/.mcp.json +19 -0
- package/plugins/kxm/README.md +93 -0
- package/plugins/kxm/dist/cli.js +42853 -0
- package/plugins/kxm/dist/client.js +416 -0
- package/plugins/kxm/dist/core.js +1823 -0
- package/plugins/kxm/dist/extension.js +3797 -0
- package/plugins/kxm/dist/mcp-server.js +17104 -0
- package/plugins/kxm/dist/runtime.js +23361 -0
- package/plugins/kxm/dist/server.js +13640 -0
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +21109 -0
- package/plugins/kxm/package.json +12 -0
- package/plugins/kxm/skills/kxm/SKILL.md +97 -0
- package/plugins/kxm/skills/kxm/references/protocol.md +103 -0
- package/plugins/kxm/skills/kxm-session/SKILL.md +53 -0
- package/plugins/kxm/src/arbiter.ts +355 -0
- package/plugins/kxm/src/artifacts-exist.ts +62 -0
- package/plugins/kxm/src/autocomplete.ts +236 -0
- package/plugins/kxm/src/cli.ts +3707 -0
- package/plugins/kxm/src/client.ts +614 -0
- package/plugins/kxm/src/commands.ts +1063 -0
- package/plugins/kxm/src/config.ts +290 -0
- package/plugins/kxm/src/context/providers.ts +101 -0
- package/plugins/kxm/src/context-packet.ts +332 -0
- package/plugins/kxm/src/context.ts +499 -0
- package/plugins/kxm/src/core.ts +6 -0
- package/plugins/kxm/src/database.ts +563 -0
- package/plugins/kxm/src/diagnostics.ts +184 -0
- package/plugins/kxm/src/envelope.ts +118 -0
- package/plugins/kxm/src/extension.ts +895 -0
- package/plugins/kxm/src/external-effects.ts +299 -0
- package/plugins/kxm/src/github-watch.ts +255 -0
- package/plugins/kxm/src/hub-binding.ts +160 -0
- package/plugins/kxm/src/hub.ts +2502 -0
- package/plugins/kxm/src/improve.ts +383 -0
- package/plugins/kxm/src/inbox.ts +10 -0
- package/plugins/kxm/src/kxm-install-kind.ts +113 -0
- package/plugins/kxm/src/kxm-update-config.ts +39 -0
- package/plugins/kxm/src/kxm-update.ts +238 -0
- package/plugins/kxm/src/local-snapshot.ts +406 -0
- package/plugins/kxm/src/logger.ts +198 -0
- package/plugins/kxm/src/mcp-server.ts +143 -0
- package/plugins/kxm/src/memory.ts +385 -0
- package/plugins/kxm/src/nous-pi.ts +287 -0
- package/plugins/kxm/src/nous-provider.ts +729 -0
- package/plugins/kxm/src/price-calc.ts +87 -0
- package/plugins/kxm/src/prices.ts +121 -0
- package/plugins/kxm/src/protocol.ts +172 -0
- package/plugins/kxm/src/recovery.ts +211 -0
- package/plugins/kxm/src/redact.ts +26 -0
- package/plugins/kxm/src/retrospective.ts +400 -0
- package/plugins/kxm/src/routing.ts +830 -0
- package/plugins/kxm/src/runtime.ts +9 -0
- package/plugins/kxm/src/server.ts +117 -0
- package/plugins/kxm/src/session-work.ts +571 -0
- package/plugins/kxm/src/session.ts +184 -0
- package/plugins/kxm/src/skills.ts +535 -0
- package/plugins/kxm/src/state.ts +326 -0
- package/plugins/kxm/src/store.ts +637 -0
- package/plugins/kxm/src/studio-layout.ts +268 -0
- package/plugins/kxm/src/suggest.ts +162 -0
- package/plugins/kxm/src/task-manager.ts +244 -0
- package/plugins/kxm/src/telemetry.ts +116 -0
- package/plugins/kxm/src/tui.ts +1046 -0
- package/plugins/kxm/src/vnext-bindings.ts +403 -0
- package/plugins/kxm/src/vnext-config.ts +1646 -0
- package/plugins/kxm/src/vnext-engine-artifacts.ts +86 -0
- package/plugins/kxm/src/vnext-engine-command.ts +533 -0
- package/plugins/kxm/src/vnext-engine-compile.ts +722 -0
- package/plugins/kxm/src/vnext-engine-evidence.ts +273 -0
- package/plugins/kxm/src/vnext-engine-fold.ts +1400 -0
- package/plugins/kxm/src/vnext-engine-gate-records.ts +583 -0
- package/plugins/kxm/src/vnext-engine-plan.ts +717 -0
- package/plugins/kxm/src/vnext-engine.ts +2458 -0
- package/plugins/kxm/src/vnext-gate-hash.ts +10 -0
- package/plugins/kxm/src/vnext-harness.ts +1142 -0
- package/plugins/kxm/src/vnext-init.ts +430 -0
- package/plugins/kxm/src/vnext-migrate.ts +1848 -0
- package/plugins/kxm/src/vnext-oneshot-producer.ts +424 -0
- package/plugins/kxm/src/vnext-permission.ts +936 -0
- package/plugins/kxm/src/vnext-pi-producer.ts +628 -0
- package/plugins/kxm/src/vnext-repair.ts +1094 -0
- package/plugins/kxm/src/vnext-runtime-owner.ts +320 -0
- package/plugins/kxm/src/vnext-runtime-store.ts +1560 -0
- package/plugins/kxm/src/vnext-runtime-supervisor.ts +586 -0
- package/plugins/kxm/src/vnext-runtime.ts +663 -0
- package/plugins/kxm/src/vnext-template.ts +247 -0
- package/plugins/kxm/src/wiki.ts +313 -0
- package/plugins/kxm/src/workflow.ts +1548 -0
- package/schemas/vnext/README.md +46 -0
- package/schemas/vnext/agent.schema.json +40 -0
- package/schemas/vnext/assignment-result.schema.json +66 -0
- package/schemas/vnext/backup-manifest.schema.json +89 -0
- package/schemas/vnext/candidate.schema.json +109 -0
- package/schemas/vnext/common.schema.json +422 -0
- package/schemas/vnext/context-candidate.schema.json +76 -0
- package/schemas/vnext/context-packet.schema.json +192 -0
- package/schemas/vnext/delivery-manifest.schema.json +159 -0
- package/schemas/vnext/environment.schema.json +66 -0
- package/schemas/vnext/gate-registry.schema.json +109 -0
- package/schemas/vnext/handoff-manifest.schema.json +146 -0
- package/schemas/vnext/init-operation.schema.json +61 -0
- package/schemas/vnext/local-repository-bindings.schema.json +30 -0
- package/schemas/vnext/memory-record.schema.json +45 -0
- package/schemas/vnext/migration-decision.schema.json +26 -0
- package/schemas/vnext/migration-plan.schema.json +123 -0
- package/schemas/vnext/migration-receipt.schema.json +52 -0
- package/schemas/vnext/model.schema.json +42 -0
- package/schemas/vnext/permission-diff.schema.json +57 -0
- package/schemas/vnext/prices.schema.json +115 -0
- package/schemas/vnext/project.schema.json +85 -0
- package/schemas/vnext/repository.schema.json +24 -0
- package/schemas/vnext/run-event.schema.json +460 -0
- package/schemas/vnext/session-brief.schema.json +153 -0
- package/schemas/vnext/sync-event.schema.json +234 -0
- package/schemas/vnext/template-provenance.schema.json +38 -0
- package/schemas/vnext/workflow.schema.json +248 -0
- package/scripts/assignment-run.d.mts +354 -0
- package/scripts/assignment-run.mjs +4451 -0
- package/scripts/build-runtime.mjs +56 -0
- package/scripts/check-generated.mjs +77 -0
- package/scripts/check-versions.mjs +34 -0
- package/scripts/emit-codex-artifacts.d.mts +9 -0
- package/scripts/emit-codex-artifacts.mjs +91 -0
- package/scripts/harness-run.d.mts +83 -0
- package/scripts/harness-run.mjs +2095 -0
- package/scripts/kxm-hub.mjs +105 -0
- package/scripts/kxm-publish-npm.mjs +327 -0
- package/scripts/kxm-release-github.mjs +472 -0
- package/scripts/kxm-runtime-supervisor.mjs +7 -0
- package/scripts/kxm-worker.mjs +1127 -0
- package/scripts/kxm.mjs +27 -0
- package/scripts/roster-policy.d.mts +20 -0
- package/scripts/roster-policy.mjs +161 -0
- package/scripts/smoke-multi-pi.mjs +479 -0
|
@@ -0,0 +1,2502 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_MAX_HOPS,
|
|
8
|
+
DEFAULT_MESSAGE_RETENTION_MS,
|
|
9
|
+
DEFAULT_MESSAGE_TTL_MS,
|
|
10
|
+
DEFAULT_RATE_LIMIT_MAX,
|
|
11
|
+
DEFAULT_RATE_LIMIT_WINDOW_MS,
|
|
12
|
+
DEFAULT_STALE_AFTER_MS,
|
|
13
|
+
MAX_BODY_BYTES,
|
|
14
|
+
MAX_CONTENT_CHARS,
|
|
15
|
+
MAX_MESSAGE_TTL_MS,
|
|
16
|
+
MIN_MESSAGE_TTL_MS,
|
|
17
|
+
MIN_MESSAGE_RETENTION_MS,
|
|
18
|
+
ProtocolError,
|
|
19
|
+
newId,
|
|
20
|
+
nowIso,
|
|
21
|
+
optionalString,
|
|
22
|
+
parseBoundedInteger,
|
|
23
|
+
parseDeliveryMode,
|
|
24
|
+
requireString,
|
|
25
|
+
type AgentRecord,
|
|
26
|
+
type DeliveryMode,
|
|
27
|
+
type HubEvent,
|
|
28
|
+
type MessageRecord,
|
|
29
|
+
type WorkflowMessageContext,
|
|
30
|
+
} from "./protocol.ts";
|
|
31
|
+
import { workflowScopeExtras } from "./diagnostics.ts";
|
|
32
|
+
import { arbitrate, explainContextItem, journalEntryToContextItem, memoryRecordToContextItem, rolePolicy } from "./arbiter.ts";
|
|
33
|
+
import { loadAuthoredMemory } from "./memory.ts";
|
|
34
|
+
import { contextItemAuditMetadata, CONTEXT_AUTHORITIES, CONTEXT_CONFIDENCES, type ContextAuthority, type ContextConfidence, type ContextItem } from "./context.ts";
|
|
35
|
+
import { NativeStateProvider } from "./state.ts";
|
|
36
|
+
import { SkillLifecycle } from "./skills.ts";
|
|
37
|
+
import { compileKnowledgeWiki, lintKnowledgeWiki, type WikiSourcePool } from "./wiki.ts";
|
|
38
|
+
import { buildRetrospective, writeRetrospective } from "./retrospective.ts";
|
|
39
|
+
import { MeshStore, type StoredAgent } from "./store.ts";
|
|
40
|
+
import {
|
|
41
|
+
canonicalWorkflowEvidenceKey,
|
|
42
|
+
approveWorkflowDegradation,
|
|
43
|
+
checkpointRun,
|
|
44
|
+
improvementReport,
|
|
45
|
+
applyJournalPromotion,
|
|
46
|
+
journalEvidenceRequired,
|
|
47
|
+
parseJournalCategory,
|
|
48
|
+
renderWorkflowPrompt,
|
|
49
|
+
resumeWorkflowFromSignal,
|
|
50
|
+
valueAtPath,
|
|
51
|
+
waitForWorkflowSignal,
|
|
52
|
+
verifyWorkflowEvidenceReferences,
|
|
53
|
+
workflowDefinitionHash,
|
|
54
|
+
workflowEvidenceStrings,
|
|
55
|
+
type ImprovementArea,
|
|
56
|
+
type JournalCategory,
|
|
57
|
+
type WebhookWorkflowDefinition,
|
|
58
|
+
type WorkflowCheckpointStatus,
|
|
59
|
+
type WorkflowEvidenceInput,
|
|
60
|
+
type WorkflowEvidenceReferenceInput,
|
|
61
|
+
type WorkflowJournalEntry,
|
|
62
|
+
type WorkflowRun,
|
|
63
|
+
type WorkflowSignalReceipt,
|
|
64
|
+
type WorkflowStageState,
|
|
65
|
+
type WorkflowVerifiedEvidence,
|
|
66
|
+
} from "./workflow.ts";
|
|
67
|
+
|
|
68
|
+
export interface RateLimitOptions {
|
|
69
|
+
windowMs?: number;
|
|
70
|
+
maxRequests?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface MeshHubOptions {
|
|
74
|
+
host?: string;
|
|
75
|
+
port?: number;
|
|
76
|
+
authToken?: string;
|
|
77
|
+
projectTokens?: Record<string, string>;
|
|
78
|
+
dataPath?: string;
|
|
79
|
+
assetsDir?: string;
|
|
80
|
+
staleAfterMs?: number;
|
|
81
|
+
messageTtlMs?: number;
|
|
82
|
+
messageRetentionMs?: number;
|
|
83
|
+
cleanupIntervalMs?: number;
|
|
84
|
+
shutdownGraceMs?: number;
|
|
85
|
+
rateLimit?: RateLimitOptions | false;
|
|
86
|
+
webhookWorkflows?: WebhookWorkflowDefinition[];
|
|
87
|
+
logger?: (entry: Record<string, unknown>) => void;
|
|
88
|
+
skillsDir?: string;
|
|
89
|
+
skillLifecycle?: SkillLifecycle;
|
|
90
|
+
repoRoot?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface MeshHub {
|
|
94
|
+
readonly server: Server;
|
|
95
|
+
readonly state: {
|
|
96
|
+
agents: Map<string, StoredAgent>;
|
|
97
|
+
messages: Map<string, MessageRecord>;
|
|
98
|
+
workflowRuns: Map<string, WorkflowRun>;
|
|
99
|
+
journal: Map<string, WorkflowJournalEntry>;
|
|
100
|
+
persistent: boolean;
|
|
101
|
+
};
|
|
102
|
+
start(): Promise<{ host: string; port: number; url: string }>;
|
|
103
|
+
close(): Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type SseClient = { response: ServerResponse; heartbeat: NodeJS.Timeout; presenceOnly?: boolean };
|
|
107
|
+
type OpsSseClient = SseClient & { project: string };
|
|
108
|
+
type OpsTopic = "agents" | "messages" | "workflows";
|
|
109
|
+
type RateBucket = { startedAt: number; count: number };
|
|
110
|
+
|
|
111
|
+
const DEFAULT_WORKFLOW_WAIT_TIMEOUT_MS = 24 * 60 * 60_000;
|
|
112
|
+
const MIN_WORKFLOW_WAIT_TIMEOUT_MS = 1_000;
|
|
113
|
+
const MAX_WORKFLOW_WAIT_TIMEOUT_MS = 30 * 24 * 60 * 60_000;
|
|
114
|
+
|
|
115
|
+
function isLoopback(host: string): boolean {
|
|
116
|
+
if (host === "localhost" || host === "::1") return true;
|
|
117
|
+
return isIP(host) === 4 && host.startsWith("127.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function publicAgent(agent: StoredAgent): AgentRecord {
|
|
121
|
+
const { key: _key, ...record } = agent;
|
|
122
|
+
return record;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function safeTokenEqual(actual: string | undefined, expected: string): boolean {
|
|
126
|
+
if (!actual) return false;
|
|
127
|
+
const actualHash = createHash("sha256").update(actual).digest();
|
|
128
|
+
const expectedHash = createHash("sha256").update(expected).digest();
|
|
129
|
+
return timingSafeEqual(actualHash, expectedHash);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function bearerToken(request: IncomingMessage): string | undefined {
|
|
133
|
+
const header = request.headers.authorization;
|
|
134
|
+
return header?.startsWith("Bearer ") ? header.slice(7) : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function json(response: ServerResponse, statusCode: number, payload: unknown): void {
|
|
138
|
+
const body = JSON.stringify(payload);
|
|
139
|
+
response.writeHead(statusCode, {
|
|
140
|
+
"content-type": "application/json; charset=utf-8",
|
|
141
|
+
"content-length": Buffer.byteLength(body),
|
|
142
|
+
"cache-control": "no-store",
|
|
143
|
+
"x-content-type-options": "nosniff",
|
|
144
|
+
});
|
|
145
|
+
response.end(body);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function text(response: ServerResponse, statusCode: number, body: string, contentType: string): void {
|
|
149
|
+
response.writeHead(statusCode, {
|
|
150
|
+
"content-type": contentType,
|
|
151
|
+
"content-length": Buffer.byteLength(body),
|
|
152
|
+
"cache-control": "no-store",
|
|
153
|
+
"x-content-type-options": "nosniff",
|
|
154
|
+
});
|
|
155
|
+
response.end(body);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function readBody(request: IncomingMessage): Promise<Buffer> {
|
|
159
|
+
const contentType = request.headers["content-type"] ?? "";
|
|
160
|
+
if (!contentType.toLowerCase().startsWith("application/json")) {
|
|
161
|
+
throw new ProtocolError(415, "content-type must be application/json", "unsupported_media_type");
|
|
162
|
+
}
|
|
163
|
+
const chunks: Buffer[] = [];
|
|
164
|
+
let length = 0;
|
|
165
|
+
for await (const chunk of request) {
|
|
166
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
167
|
+
length += buffer.length;
|
|
168
|
+
if (length > MAX_BODY_BYTES) {
|
|
169
|
+
throw new ProtocolError(413, "request body is too large", "payload_too_large");
|
|
170
|
+
}
|
|
171
|
+
chunks.push(buffer);
|
|
172
|
+
}
|
|
173
|
+
return Buffer.concat(chunks);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseJsonBody(buffer: Buffer): Record<string, unknown> {
|
|
177
|
+
if (buffer.length === 0) return {};
|
|
178
|
+
try {
|
|
179
|
+
const value = JSON.parse(buffer.toString("utf8"));
|
|
180
|
+
if (value === null || Array.isArray(value) || typeof value !== "object") {
|
|
181
|
+
throw new Error("body must be an object");
|
|
182
|
+
}
|
|
183
|
+
return value as Record<string, unknown>;
|
|
184
|
+
} catch {
|
|
185
|
+
throw new ProtocolError(400, "request body must be valid JSON object", "invalid_json");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function readJson(request: IncomingMessage): Promise<Record<string, unknown>> {
|
|
190
|
+
return parseJsonBody(await readBody(request));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function sameWorkflowMessageContext(
|
|
194
|
+
left: WorkflowMessageContext | undefined,
|
|
195
|
+
right: WorkflowMessageContext | undefined,
|
|
196
|
+
): boolean {
|
|
197
|
+
if (!left || !right) return left === right;
|
|
198
|
+
return left.schema === right.schema
|
|
199
|
+
&& left.runId === right.runId
|
|
200
|
+
&& left.stageId === right.stageId
|
|
201
|
+
&& left.requirementKey === right.requirementKey
|
|
202
|
+
&& left.attempt === right.attempt;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function sameIdempotentRequest(
|
|
206
|
+
message: MessageRecord,
|
|
207
|
+
target: string,
|
|
208
|
+
content: string,
|
|
209
|
+
delivery: DeliveryMode,
|
|
210
|
+
correlationId: string | undefined,
|
|
211
|
+
replyTo: string | undefined,
|
|
212
|
+
hops: number,
|
|
213
|
+
maxHops: number,
|
|
214
|
+
ttlMs: number,
|
|
215
|
+
workflowContext: WorkflowMessageContext | undefined,
|
|
216
|
+
): boolean {
|
|
217
|
+
return (message.to === target || message.toName.toLowerCase() === target.toLowerCase())
|
|
218
|
+
&& message.content === content
|
|
219
|
+
&& message.delivery === delivery
|
|
220
|
+
&& message.correlationId === correlationId
|
|
221
|
+
&& message.replyTo === replyTo
|
|
222
|
+
&& message.hops === hops
|
|
223
|
+
&& message.maxHops === maxHops
|
|
224
|
+
&& sameWorkflowMessageContext(message.workflowContext, workflowContext)
|
|
225
|
+
&& Date.parse(message.expiresAt) - Date.parse(message.createdAt) === ttlMs;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function parseContextAuthority(value: unknown): ContextAuthority {
|
|
229
|
+
if (typeof value !== "string" || !CONTEXT_AUTHORITIES.includes(value as ContextAuthority)) {
|
|
230
|
+
throw new ProtocolError(400, "authority must be one of policy, instruction, evidence, hypothesis", "invalid_context_request");
|
|
231
|
+
}
|
|
232
|
+
return value as ContextAuthority;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseContextConfidence(value: unknown): ContextConfidence {
|
|
236
|
+
if (typeof value !== "string" || !CONTEXT_CONFIDENCES.includes(value as ContextConfidence)) {
|
|
237
|
+
throw new ProtocolError(400, "confidence must be one of verified, probable, uncertain", "invalid_context_request");
|
|
238
|
+
}
|
|
239
|
+
return value as ContextConfidence;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function boundedStringList(value: unknown, field: string, maxItems = 32): string[] {
|
|
243
|
+
if (value === undefined) return [];
|
|
244
|
+
if (!Array.isArray(value) || value.length > maxItems) {
|
|
245
|
+
throw new ProtocolError(400, `${field} must be an array with at most ${maxItems} items`);
|
|
246
|
+
}
|
|
247
|
+
return value.map((item, index) => requireString(item, `${field}[${index}]`, { max: 1_000 }));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function boundedWorkflowEvidence(value: unknown, field = "evidence", maxItems = 64): WorkflowEvidenceInput {
|
|
251
|
+
if (value === undefined) return {};
|
|
252
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
253
|
+
throw new ProtocolError(400, `${field} must be an object keyed by required evidence identity`, "invalid_workflow_evidence");
|
|
254
|
+
}
|
|
255
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
256
|
+
if (entries.length > maxItems) {
|
|
257
|
+
throw new ProtocolError(400, `${field} must contain at most ${maxItems} keyed items`, "invalid_workflow_evidence");
|
|
258
|
+
}
|
|
259
|
+
const evidence = new Map<string, string>();
|
|
260
|
+
for (const [rawRequirement, rawEvidence] of entries) {
|
|
261
|
+
const requirement = canonicalWorkflowEvidenceKey(
|
|
262
|
+
requireString(rawRequirement, `${field} requirement`, { max: 128 }),
|
|
263
|
+
);
|
|
264
|
+
if (evidence.has(requirement)) {
|
|
265
|
+
throw new ProtocolError(
|
|
266
|
+
400,
|
|
267
|
+
`${field} contains duplicate normalized requirement identity: ${requirement}`,
|
|
268
|
+
"invalid_workflow_evidence",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
evidence.set(requirement, requireString(rawEvidence, `${field}.${requirement}`, { max: 1_000 }));
|
|
272
|
+
}
|
|
273
|
+
return Object.fromEntries(evidence);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function boundedWorkflowEvidenceReferences(
|
|
277
|
+
value: unknown,
|
|
278
|
+
field = "evidenceRefs",
|
|
279
|
+
maxItems = 32,
|
|
280
|
+
): WorkflowEvidenceReferenceInput {
|
|
281
|
+
if (value === undefined) return {};
|
|
282
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
283
|
+
throw new ProtocolError(400, `${field} must be an object keyed by required evidence identity`, "invalid_workflow_evidence_refs");
|
|
284
|
+
}
|
|
285
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
286
|
+
if (entries.length > maxItems) {
|
|
287
|
+
throw new ProtocolError(400, `${field} must contain at most ${maxItems} requirements`, "invalid_workflow_evidence_refs");
|
|
288
|
+
}
|
|
289
|
+
const references = new Map<string, { messageIds: string[] }>();
|
|
290
|
+
for (const [rawRequirement, rawReference] of entries) {
|
|
291
|
+
const requirement = canonicalWorkflowEvidenceKey(
|
|
292
|
+
requireString(rawRequirement, `${field} requirement`, { max: 128 }),
|
|
293
|
+
);
|
|
294
|
+
if (references.has(requirement)) {
|
|
295
|
+
throw new ProtocolError(
|
|
296
|
+
400,
|
|
297
|
+
`${field} contains duplicate normalized requirement identity: ${requirement}`,
|
|
298
|
+
"invalid_workflow_evidence_refs",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (!rawReference || typeof rawReference !== "object" || Array.isArray(rawReference)) {
|
|
302
|
+
throw new ProtocolError(400, `${field}.${requirement} must be an object`, "invalid_workflow_evidence_refs");
|
|
303
|
+
}
|
|
304
|
+
const referenceObject = rawReference as Record<string, unknown>;
|
|
305
|
+
if (Object.keys(referenceObject).some((key) => key !== "messageIds")) {
|
|
306
|
+
throw new ProtocolError(
|
|
307
|
+
400,
|
|
308
|
+
`${field}.${requirement} may contain only messageIds; provenance is hub-derived`,
|
|
309
|
+
"invalid_workflow_evidence_refs",
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
const rawIds = referenceObject.messageIds;
|
|
313
|
+
if (!Array.isArray(rawIds) || rawIds.length < 1 || rawIds.length > 16) {
|
|
314
|
+
throw new ProtocolError(
|
|
315
|
+
400,
|
|
316
|
+
`${field}.${requirement}.messageIds must contain between 1 and 16 IDs`,
|
|
317
|
+
"invalid_workflow_evidence_refs",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
const messageIds = rawIds.map((candidate, index) =>
|
|
321
|
+
requireString(candidate, `${field}.${requirement}.messageIds[${index}]`, { max: 80 }));
|
|
322
|
+
if (new Set(messageIds).size !== messageIds.length) {
|
|
323
|
+
throw new ProtocolError(400, `${field}.${requirement}.messageIds must be unique`, "invalid_workflow_evidence_refs");
|
|
324
|
+
}
|
|
325
|
+
references.set(requirement, { messageIds });
|
|
326
|
+
}
|
|
327
|
+
return Object.fromEntries(references);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function requestedWorkflowMessageContext(value: unknown): Omit<WorkflowMessageContext, "schema"> | undefined {
|
|
331
|
+
if (value === undefined) return undefined;
|
|
332
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
333
|
+
throw new ProtocolError(400, "workflowContext must be an object", "invalid_workflow_context");
|
|
334
|
+
}
|
|
335
|
+
const context = value as Record<string, unknown>;
|
|
336
|
+
if (Object.keys(context).some((key) => !["runId", "stageId", "requirementKey", "attempt"].includes(key))) {
|
|
337
|
+
throw new ProtocolError(400, "workflowContext contains unsupported fields", "invalid_workflow_context");
|
|
338
|
+
}
|
|
339
|
+
if (context.attempt === undefined) {
|
|
340
|
+
throw new ProtocolError(400, "workflowContext.attempt is required", "invalid_workflow_context");
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
runId: requireString(context.runId, "workflowContext.runId", { max: 80 }),
|
|
344
|
+
stageId: requireString(context.stageId, "workflowContext.stageId", { max: 64 }),
|
|
345
|
+
requirementKey: canonicalWorkflowEvidenceKey(
|
|
346
|
+
requireString(context.requirementKey, "workflowContext.requirementKey", { max: 128 }),
|
|
347
|
+
),
|
|
348
|
+
attempt: parseBoundedInteger(context.attempt, "workflowContext.attempt", 1, 1, 20),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function validateWorkflowSignalContext(
|
|
353
|
+
evidence: WorkflowEvidenceInput,
|
|
354
|
+
runId: string,
|
|
355
|
+
stageId: string,
|
|
356
|
+
signalKey: string,
|
|
357
|
+
): void {
|
|
358
|
+
const expectedContext: Record<string, string> = {
|
|
359
|
+
"workflow.run": runId,
|
|
360
|
+
"workflow.stage": stageId,
|
|
361
|
+
"workflow.signal": signalKey,
|
|
362
|
+
};
|
|
363
|
+
for (const [contextKey, expectedValue] of Object.entries(expectedContext)) {
|
|
364
|
+
const suppliedValue = evidence[contextKey];
|
|
365
|
+
if (suppliedValue === undefined || suppliedValue === expectedValue) continue;
|
|
366
|
+
throw new ProtocolError(
|
|
367
|
+
409,
|
|
368
|
+
`evidence ${contextKey} does not match the workflow signal route and active wait`,
|
|
369
|
+
"workflow_signal_context_mismatch",
|
|
370
|
+
{ contextKey },
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function workflowSignalKey(value: unknown): string {
|
|
376
|
+
const key = requireString(value, "signalKey", { max: 128 });
|
|
377
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(key)) {
|
|
378
|
+
throw new ProtocolError(400, "signalKey may contain letters, numbers, dot, underscore, colon, and hyphen", "invalid_signal_key");
|
|
379
|
+
}
|
|
380
|
+
return key;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
384
|
+
const host = options.host ?? "127.0.0.1";
|
|
385
|
+
const port = options.port ?? 7331;
|
|
386
|
+
const authToken = options.authToken?.trim();
|
|
387
|
+
const projectTokens = Object.fromEntries(
|
|
388
|
+
Object.entries(options.projectTokens ?? {}).map(([project, token]) => [project, token.trim()]),
|
|
389
|
+
);
|
|
390
|
+
const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
391
|
+
const defaultMessageTtlMs = options.messageTtlMs ?? DEFAULT_MESSAGE_TTL_MS;
|
|
392
|
+
const messageRetentionMs = options.messageRetentionMs ?? DEFAULT_MESSAGE_RETENTION_MS;
|
|
393
|
+
const cleanupIntervalMs = options.cleanupIntervalMs ?? Math.max(250, Math.floor(staleAfterMs / 3));
|
|
394
|
+
const shutdownGraceMs = options.shutdownGraceMs ?? 5_000;
|
|
395
|
+
const rateLimit = options.rateLimit === false
|
|
396
|
+
? undefined
|
|
397
|
+
: {
|
|
398
|
+
windowMs: options.rateLimit?.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS,
|
|
399
|
+
maxRequests: options.rateLimit?.maxRequests ?? DEFAULT_RATE_LIMIT_MAX,
|
|
400
|
+
};
|
|
401
|
+
const webhookWorkflows = new Map((options.webhookWorkflows ?? []).map((workflow) => [workflow.id, workflow]));
|
|
402
|
+
const logger = options.logger ?? (() => undefined);
|
|
403
|
+
const assetsDir = options.assetsDir;
|
|
404
|
+
const hubRepoRoot = options.repoRoot ?? (options.dataPath && options.dataPath !== ":memory:" ? resolve(dirname(dirname(options.dataPath))) : process.cwd());
|
|
405
|
+
const store = new MeshStore(options.dataPath);
|
|
406
|
+
const agents = store.agents;
|
|
407
|
+
const messages = store.messages;
|
|
408
|
+
function agentLogSide(id: string, fallbackName: string, side: "from" | "to") {
|
|
409
|
+
const agent = agents.get(id);
|
|
410
|
+
return {
|
|
411
|
+
[side]: id,
|
|
412
|
+
[`${side}Name`]: agent?.name ?? fallbackName,
|
|
413
|
+
[`${side}Online`]: Boolean(agent?.online),
|
|
414
|
+
...(agent?.model ? { [`${side}Model`]: agent.model } : {}),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function messageLog(message: MessageRecord, fromId: string, fromName: string, toId: string, toName: string) {
|
|
418
|
+
return {
|
|
419
|
+
project: message.project,
|
|
420
|
+
status: message.status,
|
|
421
|
+
delivery: message.delivery,
|
|
422
|
+
...agentLogSide(fromId, fromName, "from"),
|
|
423
|
+
...agentLogSide(toId, toName, "to"),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
const workflowRuns = store.workflowRuns;
|
|
427
|
+
const journal = store.journal;
|
|
428
|
+
const stateProvider = new NativeStateProvider(store);
|
|
429
|
+
const skillLifecycle = options.skillLifecycle
|
|
430
|
+
?? (options.skillsDir || existsSync(join(process.cwd(), ".kxm", "skills"))
|
|
431
|
+
? new SkillLifecycle(options.skillsDir ?? join(process.cwd(), ".kxm", "skills"))
|
|
432
|
+
: undefined);
|
|
433
|
+
const streams = new Map<string, Set<SseClient>>();
|
|
434
|
+
const opsStreams = new Set<OpsSseClient>();
|
|
435
|
+
const rateBuckets = new Map<string, RateBucket>();
|
|
436
|
+
const counters = {
|
|
437
|
+
requests: 0,
|
|
438
|
+
errors: 0,
|
|
439
|
+
registrations: 0,
|
|
440
|
+
messagesSent: 0,
|
|
441
|
+
messagesReplied: 0,
|
|
442
|
+
messagesCancelled: 0,
|
|
443
|
+
messagesExpired: 0,
|
|
444
|
+
messagesPurged: 0,
|
|
445
|
+
webhooksAccepted: 0,
|
|
446
|
+
workflowCheckpoints: 0,
|
|
447
|
+
workflowWaits: 0,
|
|
448
|
+
workflowSignals: 0,
|
|
449
|
+
workflowWaitTimeouts: 0,
|
|
450
|
+
workflowDegradations: 0,
|
|
451
|
+
journalEntries: 0,
|
|
452
|
+
contextRequests: 0,
|
|
453
|
+
attemptLatencySecondsTotal: 0,
|
|
454
|
+
meteredCostUsdTotal: 0,
|
|
455
|
+
};
|
|
456
|
+
let cleanupTimer: NodeJS.Timeout | undefined;
|
|
457
|
+
let closed = false;
|
|
458
|
+
|
|
459
|
+
function exportTerminalRetrospective(run: WorkflowRun): void {
|
|
460
|
+
if (!assetsDir || (run.status !== "completed" && run.status !== "failed")) return;
|
|
461
|
+
try {
|
|
462
|
+
const entries = [...journal.values()].filter((entry) => entry.runId === run.id);
|
|
463
|
+
const files = writeRetrospective(`${assetsDir}${process.platform === "win32" ? "\\" : "/"}retrospectives`, buildRetrospective(run, entries, run.updatedAt));
|
|
464
|
+
logger({ event: "workflow_retrospective_exported", workflowRunId: run.id, jsonPath: files.jsonPath, markdownPath: files.mdPath });
|
|
465
|
+
} catch (error) {
|
|
466
|
+
logger({ event: "workflow_retrospective_export_failed", workflowRunId: run.id, error: error instanceof Error ? error.message : "retrospective_export_failed" });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (!isLoopback(host) && !authToken) {
|
|
471
|
+
store.close();
|
|
472
|
+
throw new Error("KXM_AUTH_TOKEN is required when binding beyond localhost");
|
|
473
|
+
}
|
|
474
|
+
if (
|
|
475
|
+
staleAfterMs < 100
|
|
476
|
+
|| defaultMessageTtlMs < MIN_MESSAGE_TTL_MS
|
|
477
|
+
|| messageRetentionMs < MIN_MESSAGE_RETENTION_MS
|
|
478
|
+
|| shutdownGraceMs < 0
|
|
479
|
+
) {
|
|
480
|
+
store.close();
|
|
481
|
+
throw new Error("stale and message TTL settings are below supported minimums");
|
|
482
|
+
}
|
|
483
|
+
if (rateLimit && (rateLimit.windowMs < 100 || rateLimit.maxRequests < 1)) {
|
|
484
|
+
store.close();
|
|
485
|
+
throw new Error("rate limit settings are invalid");
|
|
486
|
+
}
|
|
487
|
+
if (webhookWorkflows.size !== (options.webhookWorkflows ?? []).length) {
|
|
488
|
+
store.close();
|
|
489
|
+
throw new Error("webhook workflow IDs must be unique");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
for (const agent of agents.values()) {
|
|
493
|
+
agent.online = false;
|
|
494
|
+
store.saveAgent(agent);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function expectedProjectToken(project: string): string | undefined {
|
|
498
|
+
return projectTokens[project] || authToken;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function requireProjectAuth(request: IncomingMessage, project: string): void {
|
|
502
|
+
const expected = expectedProjectToken(project);
|
|
503
|
+
if (!expected) return;
|
|
504
|
+
if (!safeTokenEqual(bearerToken(request), expected)) {
|
|
505
|
+
throw new ProtocolError(401, "invalid project authentication token", "invalid_auth", {
|
|
506
|
+
operation: "other",
|
|
507
|
+
nextAction: "check_project_token",
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Context callers authenticate either as a registered agent (project-
|
|
513
|
+
* scoped to their own project) or with the administrative token (single
|
|
514
|
+
* explicit project scope per request). Returns the validated project and
|
|
515
|
+
* a stable caller identity for provenance. */
|
|
516
|
+
function contextCallerProject(request: IncomingMessage, requested: unknown): { project: string; caller: string } {
|
|
517
|
+
const project = requireString(requested, "project", { max: 200 });
|
|
518
|
+
const agentHeader = request.headers["x-kxm-agent-id"];
|
|
519
|
+
if (typeof agentHeader === "string" && agentHeader.trim()) {
|
|
520
|
+
const agent = requireAgent(request);
|
|
521
|
+
requireProjectAuth(request, agent.project);
|
|
522
|
+
if (agent.project !== project) {
|
|
523
|
+
throw new ProtocolError(403, "context requests are limited to the agent's project", "context_isolation_violation");
|
|
524
|
+
}
|
|
525
|
+
return { project, caller: agent.id };
|
|
526
|
+
}
|
|
527
|
+
requireAdminAuth(request);
|
|
528
|
+
const callerHeader = request.headers["x-kxm-caller-id"];
|
|
529
|
+
const caller = typeof callerHeader === "string" && callerHeader.trim()
|
|
530
|
+
? callerHeader.trim()
|
|
531
|
+
: "kxm-admin";
|
|
532
|
+
return { project, caller };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function requireAdminAuth(request: IncomingMessage): void {
|
|
536
|
+
if (!authToken && isLoopback(host)) return;
|
|
537
|
+
if (!authToken || !safeTokenEqual(bearerToken(request), authToken)) {
|
|
538
|
+
throw new ProtocolError(401, "invalid administrative authentication token", "invalid_auth", {
|
|
539
|
+
operation: "other",
|
|
540
|
+
nextAction: "check_project_token",
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function requireConfiguredAdminAuth(request: IncomingMessage, purpose = "workflow degradation approval"): void {
|
|
546
|
+
if (!authToken) {
|
|
547
|
+
throw new ProtocolError(
|
|
548
|
+
503,
|
|
549
|
+
`KXM_AUTH_TOKEN must be configured for ${purpose}`,
|
|
550
|
+
"admin_auth_not_configured",
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
requireAdminAuth(request);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function requireAgent(request: IncomingMessage, expectedId?: string): StoredAgent {
|
|
557
|
+
const agentId = expectedId ?? String(request.headers["x-kxm-agent-id"] ?? "");
|
|
558
|
+
const agentKey = String(request.headers["x-kxm-agent-key"] ?? "");
|
|
559
|
+
const agent = agents.get(agentId);
|
|
560
|
+
if (!agent || !agentKey || !safeTokenEqual(agentKey, agent.key)) {
|
|
561
|
+
throw new ProtocolError(401, "invalid agent identity", "invalid_agent_identity", {
|
|
562
|
+
operation: "other",
|
|
563
|
+
nextAction: "reconnect_with_current_agent_key",
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
const wasOffline = !agent.online;
|
|
567
|
+
agent.lastSeenAt = nowIso();
|
|
568
|
+
agent.online = true;
|
|
569
|
+
store.saveAgent(agent);
|
|
570
|
+
if (wasOffline) broadcastPresence(agent);
|
|
571
|
+
return agent;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function checkRateLimit(request: IncomingMessage, response: ServerResponse): void {
|
|
575
|
+
if (!rateLimit) return;
|
|
576
|
+
const key = String(request.headers["x-kxm-agent-id"] ?? request.socket.remoteAddress ?? "unknown");
|
|
577
|
+
const now = Date.now();
|
|
578
|
+
const current = rateBuckets.get(key);
|
|
579
|
+
const bucket = !current || now - current.startedAt >= rateLimit.windowMs
|
|
580
|
+
? { startedAt: now, count: 0 }
|
|
581
|
+
: current;
|
|
582
|
+
bucket.count += 1;
|
|
583
|
+
rateBuckets.set(key, bucket);
|
|
584
|
+
const remaining = Math.max(0, rateLimit.maxRequests - bucket.count);
|
|
585
|
+
response.setHeader("x-ratelimit-limit", String(rateLimit.maxRequests));
|
|
586
|
+
response.setHeader("x-ratelimit-remaining", String(remaining));
|
|
587
|
+
if (bucket.count > rateLimit.maxRequests) {
|
|
588
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((bucket.startedAt + rateLimit.windowMs - now) / 1_000));
|
|
589
|
+
response.setHeader("retry-after", String(retryAfterSeconds));
|
|
590
|
+
throw new ProtocolError(429, "request rate limit exceeded", "rate_limited");
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function publish(agentId: string, event: HubEvent): boolean {
|
|
595
|
+
const clients = streams.get(agentId);
|
|
596
|
+
if (!clients || clients.size === 0) return false;
|
|
597
|
+
let frame: string | undefined;
|
|
598
|
+
let published = false;
|
|
599
|
+
for (const client of clients) {
|
|
600
|
+
if (client.presenceOnly && event.type !== "presence") continue;
|
|
601
|
+
frame ??= `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
602
|
+
client.response.write(frame);
|
|
603
|
+
published = true;
|
|
604
|
+
}
|
|
605
|
+
return published;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function publishOps(project: string, topic: OpsTopic): void {
|
|
609
|
+
const frame = `event: ops\ndata: ${JSON.stringify({ type: "ops", project, topic, at: nowIso() })}\n\n`;
|
|
610
|
+
for (const client of opsStreams) {
|
|
611
|
+
if (client.project === project) client.response.write(frame);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function opsSnapshot(project: string) {
|
|
616
|
+
const projectAgents = [...agents.values()]
|
|
617
|
+
.filter((agent) => agent.project === project)
|
|
618
|
+
.map(publicAgent)
|
|
619
|
+
.sort((left, right) => Number(right.online) - Number(left.online) || left.name.localeCompare(right.name));
|
|
620
|
+
const open = store.getOpenMessages(project);
|
|
621
|
+
const projectRuns = [...workflowRuns.values()]
|
|
622
|
+
.filter((run) => run.project === project)
|
|
623
|
+
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
|
|
624
|
+
return {
|
|
625
|
+
project,
|
|
626
|
+
fetchedAt: nowIso(),
|
|
627
|
+
agents: projectAgents,
|
|
628
|
+
openMessages: open.slice(0, 16).map((message) => ({
|
|
629
|
+
id: message.id,
|
|
630
|
+
status: message.status,
|
|
631
|
+
fromName: message.fromName,
|
|
632
|
+
toName: message.toName,
|
|
633
|
+
delivery: message.delivery,
|
|
634
|
+
createdAt: message.createdAt,
|
|
635
|
+
...(message.correlationId ? { correlationId: message.correlationId } : {}),
|
|
636
|
+
})),
|
|
637
|
+
openMessageTotal: open.length,
|
|
638
|
+
runs: projectRuns.slice(0, 8).map((run) => {
|
|
639
|
+
const stages = (run.stages ?? []).slice(0, 16).map((stage) => ({
|
|
640
|
+
id: stage.id,
|
|
641
|
+
label: stage.label,
|
|
642
|
+
status: stage.status,
|
|
643
|
+
...(stage.attempts ? { attempts: stage.attempts } : {}),
|
|
644
|
+
}));
|
|
645
|
+
const done = stages.filter((stage) => stage.status === "passed" || stage.status === "failed" || stage.status === "warning").length;
|
|
646
|
+
return {
|
|
647
|
+
id: run.id,
|
|
648
|
+
status: run.status,
|
|
649
|
+
definitionId: run.definitionId,
|
|
650
|
+
project: run.project,
|
|
651
|
+
...(run.currentStage ? { currentStage: run.currentStage } : {}),
|
|
652
|
+
...(run.targetAgentName ? { targetAgentName: run.targetAgentName } : {}),
|
|
653
|
+
...(run.updatedAt ? { updatedAt: run.updatedAt } : {}),
|
|
654
|
+
...(stages.length > 0 ? { progress: { done, total: stages.length }, stages } : {}),
|
|
655
|
+
};
|
|
656
|
+
}),
|
|
657
|
+
runTotal: projectRuns.length,
|
|
658
|
+
plans: [...journal.values()]
|
|
659
|
+
.filter((entry) => entry.category === "plan" && projectRuns.some((run) => run.id === entry.runId))
|
|
660
|
+
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
|
|
661
|
+
.slice(0, 16)
|
|
662
|
+
.map((entry) => ({
|
|
663
|
+
id: entry.id,
|
|
664
|
+
runId: entry.runId,
|
|
665
|
+
summary: entry.summary.replace(/\s+/g, " ").trim().slice(0, 120),
|
|
666
|
+
createdAt: entry.createdAt,
|
|
667
|
+
...(entry.stageId ? { stageId: entry.stageId } : {}),
|
|
668
|
+
...(entry.severity ? { severity: entry.severity } : {}),
|
|
669
|
+
})),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function broadcastPresence(agent: StoredAgent): void {
|
|
674
|
+
for (const candidate of agents.values()) {
|
|
675
|
+
if (candidate.project === agent.project && candidate.id !== agent.id && candidate.online) {
|
|
676
|
+
publish(candidate.id, { type: "presence", agent: publicAgent(agent) });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
publishOps(agent.project, "agents");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function findTarget(project: string, target: string): StoredAgent {
|
|
683
|
+
const byId = agents.get(target);
|
|
684
|
+
if (byId?.project === project && byId.online) return byId;
|
|
685
|
+
const byName = [...agents.values()].find(
|
|
686
|
+
(agent) => agent.project === project && agent.online && agent.name.toLowerCase() === target.toLowerCase(),
|
|
687
|
+
);
|
|
688
|
+
if (!byName) throw new ProtocolError(404, `online target not found: ${target}`, "target_not_found");
|
|
689
|
+
return byName;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function findKnownTarget(project: string, target: string): StoredAgent {
|
|
693
|
+
const byId = agents.get(target);
|
|
694
|
+
if (byId?.project === project) return byId;
|
|
695
|
+
const byName = [...agents.values()].find(
|
|
696
|
+
(agent) => agent.project === project && agent.name.toLowerCase() === target.toLowerCase(),
|
|
697
|
+
);
|
|
698
|
+
if (!byName) {
|
|
699
|
+
throw new ProtocolError(409, `workflow target has not registered: ${target}`, "workflow_target_unavailable");
|
|
700
|
+
}
|
|
701
|
+
return byName;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function resolveStageEvidencePolicies(
|
|
705
|
+
stage: WebhookWorkflowDefinition["stages"][number],
|
|
706
|
+
project: string,
|
|
707
|
+
coordinator: StoredAgent,
|
|
708
|
+
): WorkflowStageState["resolvedEvidencePolicies"] {
|
|
709
|
+
if (!stage.evidencePolicies) return undefined;
|
|
710
|
+
const resolved = new Map<string, NonNullable<WorkflowStageState["resolvedEvidencePolicies"]>[string]>();
|
|
711
|
+
for (const [requirementKey, policy] of Object.entries(stage.evidencePolicies)) {
|
|
712
|
+
const eligible = new Map<string, { id: string; name: string }>();
|
|
713
|
+
for (const selector of policy.eligibleAgents) {
|
|
714
|
+
const agent = findKnownTarget(project, selector);
|
|
715
|
+
if (agent.id === coordinator.id) {
|
|
716
|
+
throw new ProtocolError(
|
|
717
|
+
409,
|
|
718
|
+
`workflow evidence policy ${stage.id}/${requirementKey} cannot include the coordinator`,
|
|
719
|
+
"workflow_evidence_policy_invalid",
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
eligible.set(agent.id, { id: agent.id, name: agent.name });
|
|
723
|
+
}
|
|
724
|
+
if (eligible.size < policy.minProducers) {
|
|
725
|
+
throw new ProtocolError(
|
|
726
|
+
409,
|
|
727
|
+
`workflow evidence policy ${stage.id}/${requirementKey} resolves to ${eligible.size} unique producers but requires ${policy.minProducers}`,
|
|
728
|
+
"workflow_evidence_policy_unresolvable",
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
resolved.set(requirementKey, {
|
|
732
|
+
kind: "peer-reply",
|
|
733
|
+
minProducers: policy.minProducers,
|
|
734
|
+
eligibleProducers: [...eligible.values()],
|
|
735
|
+
acceptedStatuses: ["replied"],
|
|
736
|
+
...(policy.degradation ? { degradation: { ...policy.degradation } } : {}),
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
return Object.fromEntries(resolved);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function authorizeWorkflowMessageContext(
|
|
743
|
+
sender: StoredAgent,
|
|
744
|
+
targetInput: string,
|
|
745
|
+
requested: Omit<WorkflowMessageContext, "schema">,
|
|
746
|
+
): WorkflowMessageContext {
|
|
747
|
+
const run = workflowRuns.get(requested.runId);
|
|
748
|
+
if (!run || run.project !== sender.project || run.targetAgentId !== sender.id) {
|
|
749
|
+
throw new ProtocolError(403, "workflow context is not assigned to this coordinator", "workflow_context_forbidden");
|
|
750
|
+
}
|
|
751
|
+
const stage = run.stages.find((candidate) => candidate.id === requested.stageId);
|
|
752
|
+
if (!stage || run.currentStage !== stage.id || run.status !== "running" || stage.status !== "in_progress") {
|
|
753
|
+
throw new ProtocolError(409, "workflow context does not reference the active stage", "workflow_context_inactive");
|
|
754
|
+
}
|
|
755
|
+
const requirementKey = canonicalWorkflowEvidenceKey(requested.requirementKey);
|
|
756
|
+
const policy = stage.resolvedEvidencePolicies?.[requirementKey];
|
|
757
|
+
if (!policy) {
|
|
758
|
+
throw new ProtocolError(
|
|
759
|
+
400,
|
|
760
|
+
`workflow requirement ${requirementKey} does not accept peer evidence`,
|
|
761
|
+
"workflow_evidence_policy_missing",
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
if (requested.attempt !== stage.attempts + 1) {
|
|
765
|
+
throw new ProtocolError(
|
|
766
|
+
409,
|
|
767
|
+
`workflow context attempt ${requested.attempt} does not match active attempt ${stage.attempts + 1}`,
|
|
768
|
+
"workflow_context_attempt_mismatch",
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
const normalizedTarget = targetInput.toLowerCase();
|
|
772
|
+
const eligible = policy.eligibleProducers.find(
|
|
773
|
+
(producer) => producer.id === targetInput || producer.name.toLowerCase() === normalizedTarget,
|
|
774
|
+
);
|
|
775
|
+
if (!eligible) {
|
|
776
|
+
throw new ProtocolError(
|
|
777
|
+
403,
|
|
778
|
+
`target ${targetInput} is not eligible for workflow evidence ${requirementKey}`,
|
|
779
|
+
"workflow_evidence_producer_forbidden",
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
return {
|
|
783
|
+
schema: "pi-mesh.workflow-message-context.v1",
|
|
784
|
+
runId: run.id,
|
|
785
|
+
stageId: stage.id,
|
|
786
|
+
requirementKey,
|
|
787
|
+
attempt: requested.attempt,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function webhookEvent(request: IncomingMessage, payload: Record<string, unknown>): string | undefined {
|
|
792
|
+
const header = request.headers["x-github-event"];
|
|
793
|
+
if (typeof header === "string" && header.trim()) return header.trim();
|
|
794
|
+
const candidate = payload.webhookEvent ?? payload.event;
|
|
795
|
+
return typeof candidate === "string" && candidate.trim() ? candidate.trim() : undefined;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function verifyWebhookSignature(request: IncomingMessage, body: Buffer, secret: string): void {
|
|
799
|
+
const signature = request.headers["x-hub-signature-256"] ?? request.headers["x-hub-signature"];
|
|
800
|
+
if (typeof signature !== "string") {
|
|
801
|
+
throw new ProtocolError(401, "webhook signature is required", "webhook_signature_missing");
|
|
802
|
+
}
|
|
803
|
+
const separator = signature.indexOf("=");
|
|
804
|
+
const algorithm = separator > 0 ? signature.slice(0, separator).toLowerCase() : "";
|
|
805
|
+
if (algorithm !== "sha256") {
|
|
806
|
+
throw new ProtocolError(401, "webhook signature must use sha256", "webhook_signature_unsupported");
|
|
807
|
+
}
|
|
808
|
+
const expected = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
809
|
+
if (!safeTokenEqual(signature, expected)) {
|
|
810
|
+
throw new ProtocolError(401, "webhook signature is invalid", "webhook_signature_invalid");
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function workflowPrompt(
|
|
815
|
+
definition: WebhookWorkflowDefinition,
|
|
816
|
+
runId: string,
|
|
817
|
+
payload: Record<string, unknown>,
|
|
818
|
+
): string {
|
|
819
|
+
const rendered = renderWorkflowPrompt(definition.promptTemplate, payload);
|
|
820
|
+
const stageList = definition.stages.map((stage, index) => {
|
|
821
|
+
const peerPolicies = Object.entries(stage.evidencePolicies ?? {}).map(([requirement, policy]) =>
|
|
822
|
+
` Peer evidence ${requirement}: ${policy.minProducers} unique replied producer(s) from ${policy.eligibleAgents.join(", ")}`);
|
|
823
|
+
return [
|
|
824
|
+
`${index + 1}. ${stage.label} (stageId: ${stage.id}, maxAttempts: ${stage.maxAttempts})`,
|
|
825
|
+
` ${stage.instructions}`,
|
|
826
|
+
` Required evidence keys: ${stage.requiredEvidence.join(", ") || "none"}`,
|
|
827
|
+
...peerPolicies,
|
|
828
|
+
].join("\n");
|
|
829
|
+
}).join("\n");
|
|
830
|
+
return [
|
|
831
|
+
`Durable workflow run: ${runId}`,
|
|
832
|
+
`Workflow: ${definition.id}`,
|
|
833
|
+
"",
|
|
834
|
+
rendered,
|
|
835
|
+
"",
|
|
836
|
+
"Execute these stages in order:",
|
|
837
|
+
stageList,
|
|
838
|
+
"",
|
|
839
|
+
"At every stage, record material plans, decisions, contradictions, errors, and lessons with kxm_workflow_record.",
|
|
840
|
+
"Keep repository-local configuration in .kxm/config, logs in .kxm/logs, and durable workflow artifacts in .kxm/assets; never commit runtime logs, state, or secrets.",
|
|
841
|
+
"Complete each stage with kxm_workflow_checkpoint. Supply evidence as an object whose keys exactly match the stage's required evidence keys. Unrelated keys never satisfy a requirement. A warning or failure must be corrected and checkpointed again until it passes or the attempt limit is reached.",
|
|
842
|
+
"For a peer-evidence requirement, send or fan out with workflowContext containing this run ID, the exact stage ID, requirement key, and current 1-based attempt. At checkpoint, cite only the returned message IDs under evidenceRefs; the hub derives producer and reply provenance.",
|
|
843
|
+
"When an external system must finish asynchronously, call kxm_workflow_wait with a stable signal key and any already-verified keyed evidence. That evidence is accumulated with the signed callback before the stage can pass; then settle the turn.",
|
|
844
|
+
"For multi-agent planning, delegate to the requested peers, compare their proposals, record contradictions, and synthesize the strongest evidence-backed plan.",
|
|
845
|
+
"Do not claim the workflow is complete until the checkpoint response reports completed=true.",
|
|
846
|
+
].join("\n");
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function createWorkflowResumeMessage(
|
|
850
|
+
run: WorkflowRun,
|
|
851
|
+
definition: WebhookWorkflowDefinition,
|
|
852
|
+
deliveryId: string,
|
|
853
|
+
signalKey: string,
|
|
854
|
+
status: WorkflowCheckpointStatus,
|
|
855
|
+
summary: string,
|
|
856
|
+
evidence: WorkflowEvidenceInput,
|
|
857
|
+
retry: boolean,
|
|
858
|
+
): MessageRecord {
|
|
859
|
+
const createdAt = nowIso();
|
|
860
|
+
const ttlMs = parseBoundedInteger(
|
|
861
|
+
definition.ttlMs,
|
|
862
|
+
"workflow.ttlMs",
|
|
863
|
+
defaultMessageTtlMs,
|
|
864
|
+
MIN_MESSAGE_TTL_MS,
|
|
865
|
+
MAX_MESSAGE_TTL_MS,
|
|
866
|
+
);
|
|
867
|
+
const nextInstruction = retry
|
|
868
|
+
? `Correct the ${status} result, rerun the external check, then wait for a new signal or checkpoint the stage.`
|
|
869
|
+
: `Continue with stage ${run.currentStage}.`;
|
|
870
|
+
const content = requireString([
|
|
871
|
+
`Resume durable workflow run: ${run.id}`,
|
|
872
|
+
`Workflow: ${definition.id}`,
|
|
873
|
+
`External signal: ${signalKey}`,
|
|
874
|
+
`Result: ${status}`,
|
|
875
|
+
`Summary: ${summary}`,
|
|
876
|
+
`Evidence: ${workflowEvidenceStrings(evidence).join(", ") || "none supplied"}`,
|
|
877
|
+
"",
|
|
878
|
+
nextInstruction,
|
|
879
|
+
"Review the run with kxm_workflow_get and keep recording material plans, decisions, contradictions, errors, and lessons.",
|
|
880
|
+
"Do not claim the workflow is complete until the checkpoint response reports completed=true.",
|
|
881
|
+
].join("\n"), "workflow resume prompt", { max: MAX_CONTENT_CHARS });
|
|
882
|
+
const seq = store.nextAgentSequence(run.targetAgentId);
|
|
883
|
+
const message: MessageRecord = {
|
|
884
|
+
id: newId("msg"),
|
|
885
|
+
project: run.project,
|
|
886
|
+
from: `workflow:${run.id}`,
|
|
887
|
+
fromName: `signal:${definition.id}`,
|
|
888
|
+
to: run.targetAgentId,
|
|
889
|
+
toName: run.targetAgentName,
|
|
890
|
+
content,
|
|
891
|
+
delivery: definition.delivery,
|
|
892
|
+
hops: 0,
|
|
893
|
+
maxHops: DEFAULT_MAX_HOPS,
|
|
894
|
+
seq,
|
|
895
|
+
correlationId: run.id,
|
|
896
|
+
workflowRunId: run.id,
|
|
897
|
+
idempotencyKey: `${definition.id}:signal:${deliveryId}`,
|
|
898
|
+
createdAt,
|
|
899
|
+
expiresAt: new Date(Date.parse(createdAt) + ttlMs).toISOString(),
|
|
900
|
+
status: "queued",
|
|
901
|
+
};
|
|
902
|
+
run.messageId = message.id;
|
|
903
|
+
run.updatedAt = createdAt;
|
|
904
|
+
return message;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function expireWorkflowWaits(): void {
|
|
908
|
+
const timestamp = nowIso();
|
|
909
|
+
const now = Date.parse(timestamp);
|
|
910
|
+
for (const run of workflowRuns.values()) {
|
|
911
|
+
if (run.status !== "waiting" || !run.waiting || Date.parse(run.waiting.expiresAt) > now) continue;
|
|
912
|
+
const transition = structuredClone(run);
|
|
913
|
+
const waiting = transition.waiting!;
|
|
914
|
+
const stage = transition.stages.find((candidate) => candidate.id === waiting.stageId);
|
|
915
|
+
if (stage) {
|
|
916
|
+
stage.status = "failed";
|
|
917
|
+
stage.summary = `Timed out waiting for external signal ${waiting.signalKey}`;
|
|
918
|
+
stage.updatedAt = timestamp;
|
|
919
|
+
}
|
|
920
|
+
transition.status = "failed";
|
|
921
|
+
delete transition.currentStage;
|
|
922
|
+
delete transition.waiting;
|
|
923
|
+
transition.updatedAt = timestamp;
|
|
924
|
+
const entry: WorkflowJournalEntry = {
|
|
925
|
+
id: newId("journal"),
|
|
926
|
+
runId: transition.id,
|
|
927
|
+
agentId: transition.targetAgentId,
|
|
928
|
+
category: "error",
|
|
929
|
+
area: stage?.area ?? "harness",
|
|
930
|
+
severity: "error",
|
|
931
|
+
summary: `External workflow signal timed out: ${waiting.signalKey}`,
|
|
932
|
+
evidence: [`wait-created:${waiting.createdAt}`, `wait-expired:${waiting.expiresAt}`],
|
|
933
|
+
relatedEntryIds: [],
|
|
934
|
+
createdAt: timestamp,
|
|
935
|
+
};
|
|
936
|
+
const definition = webhookWorkflows.get(transition.definitionId);
|
|
937
|
+
const ttlMs = parseBoundedInteger(
|
|
938
|
+
definition?.ttlMs,
|
|
939
|
+
"workflow.ttlMs",
|
|
940
|
+
defaultMessageTtlMs,
|
|
941
|
+
MIN_MESSAGE_TTL_MS,
|
|
942
|
+
MAX_MESSAGE_TTL_MS,
|
|
943
|
+
);
|
|
944
|
+
const seq = store.nextAgentSequence(transition.targetAgentId);
|
|
945
|
+
const message: MessageRecord = {
|
|
946
|
+
id: newId("msg"),
|
|
947
|
+
project: transition.project,
|
|
948
|
+
from: `workflow:${transition.id}`,
|
|
949
|
+
fromName: `timeout:${transition.definitionId}`,
|
|
950
|
+
to: transition.targetAgentId,
|
|
951
|
+
toName: transition.targetAgentName,
|
|
952
|
+
content: [
|
|
953
|
+
`Durable workflow run ${transition.id} failed while waiting for external signal ${waiting.signalKey}.`,
|
|
954
|
+
`Stage: ${waiting.stageId}`,
|
|
955
|
+
`Expected: ${waiting.summary}`,
|
|
956
|
+
`Deadline: ${waiting.expiresAt}`,
|
|
957
|
+
"Review whether the external action completed, record any follow-up outside this terminal run, and escalate or start a new retry-safe workflow only when appropriate.",
|
|
958
|
+
].join("\n"),
|
|
959
|
+
delivery: definition?.delivery ?? "followUp",
|
|
960
|
+
hops: 0,
|
|
961
|
+
maxHops: DEFAULT_MAX_HOPS,
|
|
962
|
+
seq,
|
|
963
|
+
correlationId: transition.id,
|
|
964
|
+
workflowRunId: transition.id,
|
|
965
|
+
idempotencyKey: `${transition.definitionId}:timeout:${waiting.signalKey}:${waiting.expiresAt}`,
|
|
966
|
+
createdAt: timestamp,
|
|
967
|
+
expiresAt: new Date(Date.parse(timestamp) + ttlMs).toISOString(),
|
|
968
|
+
status: "queued",
|
|
969
|
+
};
|
|
970
|
+
transition.messageId = message.id;
|
|
971
|
+
store.saveWorkflowTransition(transition, message, entry);
|
|
972
|
+
publishOps(transition.project, "workflows");
|
|
973
|
+
publishOps(transition.project, "messages");
|
|
974
|
+
exportTerminalRetrospective(transition);
|
|
975
|
+
if (agents.get(transition.targetAgentId)?.online) {
|
|
976
|
+
publish(transition.targetAgentId, { type: "message", message });
|
|
977
|
+
}
|
|
978
|
+
counters.workflowWaitTimeouts += 1;
|
|
979
|
+
counters.journalEntries += 1;
|
|
980
|
+
logger({
|
|
981
|
+
event: "workflow_wait_timed_out",
|
|
982
|
+
workflowRunId: transition.id,
|
|
983
|
+
stageId: waiting.stageId,
|
|
984
|
+
signalKey: waiting.signalKey,
|
|
985
|
+
messageId: message.id,
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function flushPending(agentId: string): void {
|
|
991
|
+
expireMessages();
|
|
992
|
+
const cursor = store.getConsumerCursor(agentId);
|
|
993
|
+
const pending = store.getPendingMessages(agentId, cursor);
|
|
994
|
+
for (const message of pending) {
|
|
995
|
+
publish(agentId, { type: "message", message });
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function expireMessages(): void {
|
|
1000
|
+
const now = Date.now();
|
|
1001
|
+
for (const message of store.getExpiringMessages(now)) {
|
|
1002
|
+
if ((message.status === "queued" || message.status === "delivered") && Date.parse(message.expiresAt) <= now) {
|
|
1003
|
+
message.status = "expired";
|
|
1004
|
+
message.error = "message expired before a reply was received";
|
|
1005
|
+
store.saveMessage(message);
|
|
1006
|
+
publishOps(message.project, "messages");
|
|
1007
|
+
publish(message.from, { type: "expired", message });
|
|
1008
|
+
publish(message.to, { type: "expired", message });
|
|
1009
|
+
counters.messagesExpired += 1;
|
|
1010
|
+
logger({ event: "message_expired", messageId: message.id, ...messageLog(message, message.from, message.fromName, message.to, message.toName) });
|
|
1011
|
+
const run = [...workflowRuns.values()].find(
|
|
1012
|
+
(candidate) => candidate.messageId === message.id && candidate.status === "running",
|
|
1013
|
+
);
|
|
1014
|
+
if (run) {
|
|
1015
|
+
run.status = "failed";
|
|
1016
|
+
delete run.currentStage;
|
|
1017
|
+
run.updatedAt = nowIso();
|
|
1018
|
+
store.saveWorkflowRun(run);
|
|
1019
|
+
publishOps(run.project, "workflows");
|
|
1020
|
+
const entry: WorkflowJournalEntry = {
|
|
1021
|
+
id: newId("journal"),
|
|
1022
|
+
runId: run.id,
|
|
1023
|
+
agentId: run.targetAgentId,
|
|
1024
|
+
category: "error",
|
|
1025
|
+
area: "harness",
|
|
1026
|
+
severity: "error",
|
|
1027
|
+
summary: "Workflow coordinator prompt expired before completion",
|
|
1028
|
+
evidence: [`message:${message.id}`],
|
|
1029
|
+
relatedEntryIds: [],
|
|
1030
|
+
createdAt: run.updatedAt,
|
|
1031
|
+
};
|
|
1032
|
+
store.saveJournalEntry(entry);
|
|
1033
|
+
counters.journalEntries += 1;
|
|
1034
|
+
exportTerminalRetrospective(run);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function purgeTerminalMessages(): void {
|
|
1041
|
+
const swept = store.sweepRetention(messageRetentionMs);
|
|
1042
|
+
for (const message of swept.purgedMessages) {
|
|
1043
|
+
publishOps(message.project, "messages");
|
|
1044
|
+
counters.messagesPurged += 1;
|
|
1045
|
+
logger({ event: "message_purged", messageId: message.id, ...messageLog(message, message.from, message.fromName, message.to, message.toName) });
|
|
1046
|
+
}
|
|
1047
|
+
for (const runId of swept.purgedRuns) {
|
|
1048
|
+
logger({ event: "workflow_run_purged", runId });
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function metricsBody(): string {
|
|
1053
|
+
const onlineAgents = [...agents.values()].filter((agent) => agent.online).length;
|
|
1054
|
+
return [
|
|
1055
|
+
"# HELP kxm_online_agents Current online agent count.",
|
|
1056
|
+
"# TYPE kxm_online_agents gauge",
|
|
1057
|
+
`kxm_online_agents ${onlineAgents}`,
|
|
1058
|
+
"# HELP kxm_messages Current retained message count.",
|
|
1059
|
+
"# TYPE kxm_messages gauge",
|
|
1060
|
+
`kxm_messages ${store.countMessages()}`,
|
|
1061
|
+
"# TYPE kxm_requests_total counter",
|
|
1062
|
+
`kxm_requests_total ${counters.requests}`,
|
|
1063
|
+
"# TYPE kxm_errors_total counter",
|
|
1064
|
+
`kxm_errors_total ${counters.errors}`,
|
|
1065
|
+
"# TYPE kxm_registrations_total counter",
|
|
1066
|
+
`kxm_registrations_total ${counters.registrations}`,
|
|
1067
|
+
"# TYPE kxm_messages_sent_total counter",
|
|
1068
|
+
`kxm_messages_sent_total ${counters.messagesSent}`,
|
|
1069
|
+
"# TYPE kxm_messages_replied_total counter",
|
|
1070
|
+
`kxm_messages_replied_total ${counters.messagesReplied}`,
|
|
1071
|
+
"# TYPE kxm_messages_cancelled_total counter",
|
|
1072
|
+
`kxm_messages_cancelled_total ${counters.messagesCancelled}`,
|
|
1073
|
+
"# TYPE kxm_messages_expired_total counter",
|
|
1074
|
+
`kxm_messages_expired_total ${counters.messagesExpired}`,
|
|
1075
|
+
"# TYPE kxm_messages_purged_total counter",
|
|
1076
|
+
`kxm_messages_purged_total ${counters.messagesPurged}`,
|
|
1077
|
+
"# TYPE kxm_webhooks_accepted_total counter",
|
|
1078
|
+
`kxm_webhooks_accepted_total ${counters.webhooksAccepted}`,
|
|
1079
|
+
"# TYPE kxm_workflow_checkpoints_total counter",
|
|
1080
|
+
`kxm_workflow_checkpoints_total ${counters.workflowCheckpoints}`,
|
|
1081
|
+
"# TYPE kxm_workflow_waits_total counter",
|
|
1082
|
+
`kxm_workflow_waits_total ${counters.workflowWaits}`,
|
|
1083
|
+
"# TYPE kxm_workflow_signals_total counter",
|
|
1084
|
+
`kxm_workflow_signals_total ${counters.workflowSignals}`,
|
|
1085
|
+
"# TYPE kxm_workflow_wait_timeouts_total counter",
|
|
1086
|
+
`kxm_workflow_wait_timeouts_total ${counters.workflowWaitTimeouts}`,
|
|
1087
|
+
"# TYPE kxm_workflow_degradations_total counter",
|
|
1088
|
+
`kxm_workflow_degradations_total ${counters.workflowDegradations}`,
|
|
1089
|
+
"# TYPE kxm_workflow_journal_entries_total counter",
|
|
1090
|
+
`kxm_workflow_journal_entries_total ${counters.journalEntries}`,
|
|
1091
|
+
"# TYPE kxm_context_requests_total counter",
|
|
1092
|
+
`kxm_context_requests_total ${counters.contextRequests}`,
|
|
1093
|
+
"# TYPE kxm_attempt_latency_seconds_total counter",
|
|
1094
|
+
`kxm_attempt_latency_seconds_total ${counters.attemptLatencySecondsTotal}`,
|
|
1095
|
+
"# TYPE kxm_metered_cost_usd_total counter",
|
|
1096
|
+
`kxm_metered_cost_usd_total ${counters.meteredCostUsdTotal}`,
|
|
1097
|
+
"",
|
|
1098
|
+
].join("\n");
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const server = createServer(async (request, response) => {
|
|
1102
|
+
const requestIdHeader = request.headers["x-request-id"];
|
|
1103
|
+
const requestId = typeof requestIdHeader === "string" && /^[A-Za-z0-9._-]{1,80}$/.test(requestIdHeader)
|
|
1104
|
+
? requestIdHeader
|
|
1105
|
+
: newId("req");
|
|
1106
|
+
response.setHeader("x-request-id", requestId);
|
|
1107
|
+
response.setHeader("referrer-policy", "no-referrer");
|
|
1108
|
+
counters.requests += 1;
|
|
1109
|
+
try {
|
|
1110
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
1111
|
+
const method = request.method ?? "GET";
|
|
1112
|
+
|
|
1113
|
+
if (method === "GET" && url.pathname === "/health") {
|
|
1114
|
+
json(response, 200, { ok: true, agents: [...agents.values()].filter((agent) => agent.online).length });
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
if (method === "GET" && url.pathname === "/ready") {
|
|
1118
|
+
const ready = store.healthy();
|
|
1119
|
+
json(response, ready ? 200 : 503, { ok: ready, storage: store.persistent ? "sqlite" : "memory" });
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
checkRateLimit(request, response);
|
|
1124
|
+
|
|
1125
|
+
const signalMatch = url.pathname.match(/^\/v1\/webhooks\/([^/]+)\/runs\/([^/]+)\/signals\/([^/]+)$/);
|
|
1126
|
+
if (method === "POST" && signalMatch) {
|
|
1127
|
+
const definitionId = decodeURIComponent(signalMatch[1]!);
|
|
1128
|
+
const runId = decodeURIComponent(signalMatch[2]!);
|
|
1129
|
+
const signalKey = workflowSignalKey(decodeURIComponent(signalMatch[3]!));
|
|
1130
|
+
const definition = webhookWorkflows.get(definitionId);
|
|
1131
|
+
if (!definition) throw new ProtocolError(404, "webhook workflow not found", "webhook_not_found");
|
|
1132
|
+
const rawBody = await readBody(request);
|
|
1133
|
+
verifyWebhookSignature(request, rawBody, definition.signalSecret ?? definition.secret);
|
|
1134
|
+
const body = parseJsonBody(rawBody);
|
|
1135
|
+
const run = workflowRuns.get(runId);
|
|
1136
|
+
if (!run || run.definitionId !== definition.id) {
|
|
1137
|
+
throw new ProtocolError(404, "workflow run not found", "workflow_not_found");
|
|
1138
|
+
}
|
|
1139
|
+
const deliveryHeader = request.headers["x-atlassian-webhook-identifier"]
|
|
1140
|
+
?? request.headers["x-github-delivery"]
|
|
1141
|
+
?? request.headers["x-kxm-delivery-id"];
|
|
1142
|
+
const deliveryId = requireString(deliveryHeader, "webhook delivery identifier", { max: 128 });
|
|
1143
|
+
const payloadHash = createHash("sha256").update(rawBody).digest("hex");
|
|
1144
|
+
const existingReceipt = run.signalReceipts?.find((receipt) => receipt.deliveryId === deliveryId);
|
|
1145
|
+
if (existingReceipt) {
|
|
1146
|
+
if (existingReceipt.signalKey !== signalKey || existingReceipt.payloadHash !== payloadHash) {
|
|
1147
|
+
throw new ProtocolError(
|
|
1148
|
+
409,
|
|
1149
|
+
"webhook delivery identifier was already used for a different signal",
|
|
1150
|
+
"workflow_signal_delivery_conflict",
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
json(response, 200, {
|
|
1154
|
+
duplicate: true,
|
|
1155
|
+
status: existingReceipt.status,
|
|
1156
|
+
stageId: existingReceipt.stageId,
|
|
1157
|
+
signalKey: existingReceipt.signalKey,
|
|
1158
|
+
receivedAt: existingReceipt.receivedAt,
|
|
1159
|
+
degraded: existingReceipt.degraded === true,
|
|
1160
|
+
degradedRequirements: existingReceipt.degradedRequirements ?? [],
|
|
1161
|
+
resumed: Boolean(existingReceipt.messageId),
|
|
1162
|
+
});
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
expireWorkflowWaits();
|
|
1166
|
+
const status = requireString(body.status, "status", { max: 16 }) as WorkflowCheckpointStatus;
|
|
1167
|
+
if (status !== "passed" && status !== "warning" && status !== "failed") {
|
|
1168
|
+
throw new ProtocolError(400, "status must be passed, warning, or failed", "invalid_checkpoint_status");
|
|
1169
|
+
}
|
|
1170
|
+
const summary = requireString(body.summary, "summary", { max: 4_000 });
|
|
1171
|
+
const evidence = boundedWorkflowEvidence(body.evidence);
|
|
1172
|
+
const receivedAt = nowIso();
|
|
1173
|
+
const transition = structuredClone(workflowRuns.get(runId)!);
|
|
1174
|
+
if (transition.status === "waiting" && transition.waiting) {
|
|
1175
|
+
validateWorkflowSignalContext(
|
|
1176
|
+
evidence,
|
|
1177
|
+
runId,
|
|
1178
|
+
transition.waiting.stageId,
|
|
1179
|
+
signalKey,
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
const result = resumeWorkflowFromSignal(transition, signalKey, status, summary, evidence, receivedAt);
|
|
1183
|
+
const receipt: WorkflowSignalReceipt = {
|
|
1184
|
+
deliveryId,
|
|
1185
|
+
payloadHash,
|
|
1186
|
+
signalKey,
|
|
1187
|
+
stageId: result.stageId,
|
|
1188
|
+
status,
|
|
1189
|
+
receivedAt,
|
|
1190
|
+
};
|
|
1191
|
+
if (result.degraded) {
|
|
1192
|
+
const degradedStage = transition.stages.find((candidate) => candidate.id === result.stageId)!;
|
|
1193
|
+
receipt.degraded = true;
|
|
1194
|
+
receipt.degradedRequirements = [...(degradedStage.degradedRequirements ?? [])];
|
|
1195
|
+
}
|
|
1196
|
+
(transition.signalReceipts ??= []).push(receipt);
|
|
1197
|
+
let message: MessageRecord | undefined;
|
|
1198
|
+
let entry: WorkflowJournalEntry | undefined;
|
|
1199
|
+
if (status !== "passed") {
|
|
1200
|
+
const stage = transition.stages.find((candidate) => candidate.id === result.stageId)!;
|
|
1201
|
+
entry = {
|
|
1202
|
+
id: newId("journal"),
|
|
1203
|
+
runId: transition.id,
|
|
1204
|
+
agentId: transition.targetAgentId,
|
|
1205
|
+
category: "error",
|
|
1206
|
+
area: stage.area ?? "gates",
|
|
1207
|
+
severity: status === "failed" ? "error" : "warning",
|
|
1208
|
+
summary: `${stage.label}: ${summary}`,
|
|
1209
|
+
evidence: workflowEvidenceStrings(evidence),
|
|
1210
|
+
relatedEntryIds: [],
|
|
1211
|
+
createdAt: receivedAt,
|
|
1212
|
+
};
|
|
1213
|
+
} else if (result.degraded) {
|
|
1214
|
+
const stage = transition.stages.find((candidate) => candidate.id === result.stageId)!;
|
|
1215
|
+
entry = {
|
|
1216
|
+
id: newId("journal"),
|
|
1217
|
+
runId: transition.id,
|
|
1218
|
+
agentId: transition.targetAgentId,
|
|
1219
|
+
category: "decision",
|
|
1220
|
+
area: stage.area ?? "security",
|
|
1221
|
+
severity: "warning",
|
|
1222
|
+
summary: `${stage.label} passed from a signed callback using a previously approved degraded peer quorum`,
|
|
1223
|
+
evidence: [
|
|
1224
|
+
"class:workflow_quorum_degradation_used",
|
|
1225
|
+
...(stage.degradedRequirements ?? []).map((requirement) => `requirement:${requirement}`),
|
|
1226
|
+
],
|
|
1227
|
+
relatedEntryIds: [],
|
|
1228
|
+
createdAt: receivedAt,
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
if (transition.status === "running") {
|
|
1232
|
+
message = createWorkflowResumeMessage(
|
|
1233
|
+
transition,
|
|
1234
|
+
definition,
|
|
1235
|
+
deliveryId,
|
|
1236
|
+
signalKey,
|
|
1237
|
+
status,
|
|
1238
|
+
summary,
|
|
1239
|
+
evidence,
|
|
1240
|
+
result.retry,
|
|
1241
|
+
);
|
|
1242
|
+
receipt.messageId = message.id;
|
|
1243
|
+
}
|
|
1244
|
+
store.saveWorkflowTransition(transition, message, entry);
|
|
1245
|
+
publishOps(transition.project, "workflows");
|
|
1246
|
+
if (message) publishOps(transition.project, "messages");
|
|
1247
|
+
exportTerminalRetrospective(transition);
|
|
1248
|
+
if (entry) counters.journalEntries += 1;
|
|
1249
|
+
if (message && agents.get(transition.targetAgentId)?.online) {
|
|
1250
|
+
publish(transition.targetAgentId, { type: "message", message });
|
|
1251
|
+
}
|
|
1252
|
+
counters.workflowSignals += 1;
|
|
1253
|
+
counters.workflowCheckpoints += 1;
|
|
1254
|
+
logger({
|
|
1255
|
+
event: "workflow_signal_received",
|
|
1256
|
+
workflowRunId: transition.id,
|
|
1257
|
+
deliveryId,
|
|
1258
|
+
signalKey,
|
|
1259
|
+
stageId: result.stageId,
|
|
1260
|
+
status,
|
|
1261
|
+
retry: result.retry,
|
|
1262
|
+
completed: result.completed,
|
|
1263
|
+
...(message ? { messageId: message.id } : {}),
|
|
1264
|
+
});
|
|
1265
|
+
json(response, message ? 202 : 200, {
|
|
1266
|
+
duplicate: false,
|
|
1267
|
+
status,
|
|
1268
|
+
stageId: result.stageId,
|
|
1269
|
+
signalKey,
|
|
1270
|
+
retry: result.retry,
|
|
1271
|
+
completed: result.completed,
|
|
1272
|
+
degraded: result.degraded === true,
|
|
1273
|
+
degradedRequirements: receipt.degradedRequirements ?? [],
|
|
1274
|
+
resumed: Boolean(message),
|
|
1275
|
+
});
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const webhookMatch = url.pathname.match(/^\/v1\/webhooks\/([^/]+)$/);
|
|
1280
|
+
if (method === "POST" && webhookMatch) {
|
|
1281
|
+
const definitionId = decodeURIComponent(webhookMatch[1]!);
|
|
1282
|
+
const definition = webhookWorkflows.get(definitionId);
|
|
1283
|
+
if (!definition) throw new ProtocolError(404, "webhook workflow not found", "webhook_not_found");
|
|
1284
|
+
const rawBody = await readBody(request);
|
|
1285
|
+
verifyWebhookSignature(request, rawBody, definition.secret);
|
|
1286
|
+
const payload = parseJsonBody(rawBody);
|
|
1287
|
+
const event = webhookEvent(request, payload);
|
|
1288
|
+
if (definition.event && event !== definition.event) {
|
|
1289
|
+
response.writeHead(204, { "cache-control": "no-store" }).end();
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
if (definition.filter && String(valueAtPath(payload, definition.filter.path) ?? "") !== definition.filter.equals) {
|
|
1293
|
+
response.writeHead(204, { "cache-control": "no-store" }).end();
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
const deliveryHeader = request.headers["x-atlassian-webhook-identifier"]
|
|
1297
|
+
?? request.headers["x-github-delivery"]
|
|
1298
|
+
?? request.headers["x-kxm-delivery-id"];
|
|
1299
|
+
const deliveryId = requireString(deliveryHeader, "webhook delivery identifier", { max: 128 });
|
|
1300
|
+
const existing = [...workflowRuns.values()].find(
|
|
1301
|
+
(run) => run.definitionId === definition.id && run.deliveryId === deliveryId,
|
|
1302
|
+
);
|
|
1303
|
+
if (existing) {
|
|
1304
|
+
json(response, 200, { run: existing, duplicate: true });
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
const target = findKnownTarget(definition.project, definition.target);
|
|
1308
|
+
const createdAt = nowIso();
|
|
1309
|
+
const runId = newId("run");
|
|
1310
|
+
const ttlMs = parseBoundedInteger(
|
|
1311
|
+
definition.ttlMs,
|
|
1312
|
+
"workflow.ttlMs",
|
|
1313
|
+
defaultMessageTtlMs,
|
|
1314
|
+
MIN_MESSAGE_TTL_MS,
|
|
1315
|
+
MAX_MESSAGE_TTL_MS,
|
|
1316
|
+
);
|
|
1317
|
+
const content = requireString(workflowPrompt(definition, runId, payload), "workflow prompt", {
|
|
1318
|
+
max: MAX_CONTENT_CHARS,
|
|
1319
|
+
});
|
|
1320
|
+
const seq = store.nextAgentSequence(target.id);
|
|
1321
|
+
const message: MessageRecord = {
|
|
1322
|
+
id: newId("msg"),
|
|
1323
|
+
project: definition.project,
|
|
1324
|
+
from: `workflow:${runId}`,
|
|
1325
|
+
fromName: `webhook:${definition.id}`,
|
|
1326
|
+
to: target.id,
|
|
1327
|
+
toName: target.name,
|
|
1328
|
+
content,
|
|
1329
|
+
delivery: definition.delivery,
|
|
1330
|
+
hops: 0,
|
|
1331
|
+
maxHops: DEFAULT_MAX_HOPS,
|
|
1332
|
+
seq,
|
|
1333
|
+
correlationId: runId,
|
|
1334
|
+
workflowRunId: runId,
|
|
1335
|
+
idempotencyKey: `${definition.id}:${deliveryId}`,
|
|
1336
|
+
createdAt,
|
|
1337
|
+
expiresAt: new Date(Date.parse(createdAt) + ttlMs).toISOString(),
|
|
1338
|
+
status: "queued",
|
|
1339
|
+
};
|
|
1340
|
+
const stages = definition.stages.map((stage, index) => {
|
|
1341
|
+
const resolvedEvidencePolicies = resolveStageEvidencePolicies(stage, definition.project, target);
|
|
1342
|
+
return {
|
|
1343
|
+
...stage,
|
|
1344
|
+
...(resolvedEvidencePolicies ? { resolvedEvidencePolicies } : {}),
|
|
1345
|
+
status: index === 0 ? "in_progress" as const : "pending" as const,
|
|
1346
|
+
attempts: 0,
|
|
1347
|
+
evidence: {},
|
|
1348
|
+
...(index === 0 ? { startedAt: createdAt, updatedAt: createdAt } : {}),
|
|
1349
|
+
};
|
|
1350
|
+
});
|
|
1351
|
+
const run: WorkflowRun = {
|
|
1352
|
+
id: runId,
|
|
1353
|
+
definitionId: definition.id,
|
|
1354
|
+
source: definition.source,
|
|
1355
|
+
deliveryId,
|
|
1356
|
+
payloadHash: createHash("sha256").update(rawBody).digest("hex"),
|
|
1357
|
+
definitionHash: workflowDefinitionHash(definition),
|
|
1358
|
+
...(event ? { event } : {}),
|
|
1359
|
+
project: definition.project,
|
|
1360
|
+
targetAgentId: target.id,
|
|
1361
|
+
targetAgentName: target.name,
|
|
1362
|
+
messageId: message.id,
|
|
1363
|
+
status: "running",
|
|
1364
|
+
currentStage: stages[0]!.id,
|
|
1365
|
+
stages,
|
|
1366
|
+
...(definition.maxTransitions !== undefined ? { maxTransitions: definition.maxTransitions } : {}),
|
|
1367
|
+
...(definition.reproOracle ? { reproOracle: definition.reproOracle } : {}),
|
|
1368
|
+
...(definition.planHash ? { planHashConfig: definition.planHash } : {}),
|
|
1369
|
+
...(definition.requirePlanHash ? { requirePlanHash: definition.requirePlanHash } : {}),
|
|
1370
|
+
createdAt,
|
|
1371
|
+
updatedAt: createdAt,
|
|
1372
|
+
};
|
|
1373
|
+
store.saveMessage(message);
|
|
1374
|
+
store.saveWorkflowRun(run);
|
|
1375
|
+
publishOps(run.project, "messages");
|
|
1376
|
+
publishOps(run.project, "workflows");
|
|
1377
|
+
if (target.online) publish(target.id, { type: "message", message });
|
|
1378
|
+
counters.webhooksAccepted += 1;
|
|
1379
|
+
logger({
|
|
1380
|
+
event: "webhook_workflow_started",
|
|
1381
|
+
workflowId: definition.id,
|
|
1382
|
+
workflowRunId: run.id,
|
|
1383
|
+
deliveryId,
|
|
1384
|
+
project: definition.project,
|
|
1385
|
+
target: target.id,
|
|
1386
|
+
});
|
|
1387
|
+
json(response, 202, { run, duplicate: false });
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
if (method === "GET" && url.pathname === "/metrics") {
|
|
1392
|
+
requireAdminAuth(request);
|
|
1393
|
+
text(response, 200, metricsBody(), "text/plain; version=0.0.4; charset=utf-8");
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
if (method === "GET" && url.pathname === "/v1/ops/snapshot") {
|
|
1398
|
+
requireConfiguredAdminAuth(request, "operations metadata");
|
|
1399
|
+
const project = requireString(url.searchParams.get("project"), "project", { max: 128 });
|
|
1400
|
+
expireMessages();
|
|
1401
|
+
expireWorkflowWaits();
|
|
1402
|
+
purgeTerminalMessages();
|
|
1403
|
+
json(response, 200, opsSnapshot(project));
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
if (method === "GET" && url.pathname === "/v1/ops/events") {
|
|
1408
|
+
requireConfiguredAdminAuth(request, "operations metadata");
|
|
1409
|
+
const project = requireString(url.searchParams.get("project"), "project", { max: 128 });
|
|
1410
|
+
response.writeHead(200, {
|
|
1411
|
+
"content-type": "text/event-stream",
|
|
1412
|
+
"cache-control": "no-cache, no-transform",
|
|
1413
|
+
connection: "keep-alive",
|
|
1414
|
+
"x-accel-buffering": "no",
|
|
1415
|
+
"x-content-type-options": "nosniff",
|
|
1416
|
+
});
|
|
1417
|
+
response.write(`event: ready\ndata: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}\n\n`);
|
|
1418
|
+
const client: OpsSseClient = {
|
|
1419
|
+
project,
|
|
1420
|
+
response,
|
|
1421
|
+
heartbeat: setInterval(() => response.write(": heartbeat\n\n"), 15_000),
|
|
1422
|
+
};
|
|
1423
|
+
client.heartbeat.unref();
|
|
1424
|
+
opsStreams.add(client);
|
|
1425
|
+
request.on("close", () => {
|
|
1426
|
+
clearInterval(client.heartbeat);
|
|
1427
|
+
opsStreams.delete(client);
|
|
1428
|
+
});
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
if (method === "GET" && url.pathname === "/v1/workflows") {
|
|
1433
|
+
expireWorkflowWaits();
|
|
1434
|
+
const agent = requireAgent(request);
|
|
1435
|
+
requireProjectAuth(request, agent.project);
|
|
1436
|
+
const runs = [...workflowRuns.values()].filter(
|
|
1437
|
+
(run) => run.project === agent.project && run.targetAgentId === agent.id,
|
|
1438
|
+
);
|
|
1439
|
+
json(response, 200, { runs });
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
const workflowMatch = url.pathname.match(/^\/v1\/workflows\/([^/]+)$/);
|
|
1444
|
+
if (method === "GET" && workflowMatch) {
|
|
1445
|
+
expireWorkflowWaits();
|
|
1446
|
+
const agent = requireAgent(request);
|
|
1447
|
+
requireProjectAuth(request, agent.project);
|
|
1448
|
+
const run = workflowRuns.get(decodeURIComponent(workflowMatch[1]!));
|
|
1449
|
+
if (!run) throw new ProtocolError(404, "workflow run not found", "workflow_not_found");
|
|
1450
|
+
if (run.project !== agent.project || run.targetAgentId !== agent.id) {
|
|
1451
|
+
throw new ProtocolError(
|
|
1452
|
+
403,
|
|
1453
|
+
"workflow run is not visible to this agent",
|
|
1454
|
+
"workflow_forbidden",
|
|
1455
|
+
workflowScopeExtras("get", run.targetAgentName),
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
const entries = [...journal.values()].filter((entry) => entry.runId === run.id);
|
|
1459
|
+
json(response, 200, { run, journal: entries });
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
const degradationMatch = url.pathname.match(/^\/v1\/workflows\/([^/]+)\/degradations$/);
|
|
1464
|
+
if (method === "POST" && degradationMatch) {
|
|
1465
|
+
requireConfiguredAdminAuth(request);
|
|
1466
|
+
const body = await readJson(request);
|
|
1467
|
+
// Resolve the mutable run only after the final await. Concurrent exact
|
|
1468
|
+
// retries then observe the first committed approval and remain
|
|
1469
|
+
// idempotent instead of cloning the same stale pre-approval state.
|
|
1470
|
+
const run = workflowRuns.get(decodeURIComponent(degradationMatch[1]!));
|
|
1471
|
+
if (!run) throw new ProtocolError(404, "workflow run not found", "workflow_not_found");
|
|
1472
|
+
const stageId = requireString(body.stageId, "stageId", { max: 64 });
|
|
1473
|
+
const requirementKey = canonicalWorkflowEvidenceKey(
|
|
1474
|
+
requireString(body.requirementKey, "requirementKey", { max: 128 }),
|
|
1475
|
+
);
|
|
1476
|
+
const reason = requireString(body.reason, "reason", { max: 1_000 });
|
|
1477
|
+
const timestamp = nowIso();
|
|
1478
|
+
const transition = structuredClone(run);
|
|
1479
|
+
const result = approveWorkflowDegradation(
|
|
1480
|
+
transition,
|
|
1481
|
+
stageId,
|
|
1482
|
+
requirementKey,
|
|
1483
|
+
reason,
|
|
1484
|
+
newId("approval"),
|
|
1485
|
+
timestamp,
|
|
1486
|
+
);
|
|
1487
|
+
if (result.created) {
|
|
1488
|
+
const stage = transition.stages.find((candidate) => candidate.id === stageId)!;
|
|
1489
|
+
const entry: WorkflowJournalEntry = {
|
|
1490
|
+
id: newId("journal"),
|
|
1491
|
+
runId: transition.id,
|
|
1492
|
+
agentId: "kxm-admin",
|
|
1493
|
+
category: "decision",
|
|
1494
|
+
area: stage.area ?? "security",
|
|
1495
|
+
severity: "warning",
|
|
1496
|
+
summary: `Approved degraded peer quorum for ${stageId}/${requirementKey} attempt ${result.approval.attempt}`,
|
|
1497
|
+
details: reason,
|
|
1498
|
+
evidence: [
|
|
1499
|
+
"class:workflow_quorum_degradation_approved",
|
|
1500
|
+
`approval:${result.approval.id}`,
|
|
1501
|
+
`policy-min:${result.approval.policyMinProducers}`,
|
|
1502
|
+
`approved-min:${result.approval.approvedMinProducers}`,
|
|
1503
|
+
],
|
|
1504
|
+
relatedEntryIds: [],
|
|
1505
|
+
createdAt: timestamp,
|
|
1506
|
+
};
|
|
1507
|
+
store.saveWorkflowTransition(transition, undefined, entry);
|
|
1508
|
+
publishOps(transition.project, "workflows");
|
|
1509
|
+
counters.workflowDegradations += 1;
|
|
1510
|
+
counters.journalEntries += 1;
|
|
1511
|
+
logger({
|
|
1512
|
+
event: "workflow_degradation_approved",
|
|
1513
|
+
workflowRunId: transition.id,
|
|
1514
|
+
stageId,
|
|
1515
|
+
requirementKey,
|
|
1516
|
+
attempt: result.approval.attempt,
|
|
1517
|
+
approvalId: result.approval.id,
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
json(response, result.created ? 201 : 200, {
|
|
1521
|
+
run: result.created ? transition : run,
|
|
1522
|
+
approval: result.approval,
|
|
1523
|
+
duplicate: !result.created,
|
|
1524
|
+
});
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// ----- Context operating-system surface (v0.5, issue #34) -----
|
|
1529
|
+
|
|
1530
|
+
/** Pool for one project: stored context items plus journal evidence
|
|
1531
|
+
* mapped to context items. Agents never see provider internals. */
|
|
1532
|
+
function projectContextPool(project: string, journalCategories?: JournalCategory[]): { pool: ContextItem[]; contradictionIds: string[] } {
|
|
1533
|
+
const pool = store.listContextItems(project);
|
|
1534
|
+
try {
|
|
1535
|
+
const authored = loadAuthoredMemory(hubRepoRoot);
|
|
1536
|
+
for (const rec of authored) {
|
|
1537
|
+
pool.push(memoryRecordToContextItem(rec, project));
|
|
1538
|
+
}
|
|
1539
|
+
} catch {
|
|
1540
|
+
// ignore if .kxm/memory does not exist
|
|
1541
|
+
}
|
|
1542
|
+
const categoryFilter = journalCategories ? new Set<string>(journalCategories) : undefined;
|
|
1543
|
+
const runIds = new Set(
|
|
1544
|
+
[...workflowRuns.values()].filter((run) => run.project === project).map((run) => run.id),
|
|
1545
|
+
);
|
|
1546
|
+
const contradictionIds: string[] = [];
|
|
1547
|
+
for (const entry of journal.values()) {
|
|
1548
|
+
if (!runIds.has(entry.runId)) continue;
|
|
1549
|
+
if (categoryFilter && !categoryFilter.has(entry.category)) continue;
|
|
1550
|
+
pool.push(journalEntryToContextItem(entry, project));
|
|
1551
|
+
if (entry.category === "contradiction") contradictionIds.push(`journal_${entry.id}`);
|
|
1552
|
+
}
|
|
1553
|
+
for (const contradiction of stateProvider.contradictions()) {
|
|
1554
|
+
if (contradiction.project !== project) continue;
|
|
1555
|
+
contradictionIds.push(...contradiction.competingCurrentIds, ...contradiction.competingProposalIds);
|
|
1556
|
+
}
|
|
1557
|
+
return { pool, contradictionIds };
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/** Full source pool for wiki compilation: every journal-derived item
|
|
1561
|
+
* (unfiltered) plus the authoritative state layer. */
|
|
1562
|
+
function contextWikiPool(project: string, compiledAt: string): WikiSourcePool {
|
|
1563
|
+
const { pool, contradictionIds } = projectContextPool(project);
|
|
1564
|
+
const stateItems = store.listContextItems(project, ["state"]);
|
|
1565
|
+
const contradictions = stateProvider.contradictions().filter((entry) => entry.project === project);
|
|
1566
|
+
return {
|
|
1567
|
+
project,
|
|
1568
|
+
stateItems,
|
|
1569
|
+
contextItems: pool,
|
|
1570
|
+
contradictions,
|
|
1571
|
+
openContradictionItemIds: contradictionIds,
|
|
1572
|
+
compiledAt,
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
const contextGetMatch = url.pathname.match(/^\/v1\/context\/get$/);
|
|
1577
|
+
if (method === "POST" && contextGetMatch) {
|
|
1578
|
+
const body = await readJson(request);
|
|
1579
|
+
const { project: callerProject, caller: callerId } = contextCallerProject(request, body.project);
|
|
1580
|
+
const policy = rolePolicy(typeof body.role === "string" ? body.role : "");
|
|
1581
|
+
const { pool, contradictionIds } = projectContextPool(callerProject, policy.journalCategories);
|
|
1582
|
+
const outcome = arbitrate(body, pool, {
|
|
1583
|
+
contradictionIds,
|
|
1584
|
+
...(skillLifecycle ? { skillLifecycle } : {}),
|
|
1585
|
+
});
|
|
1586
|
+
counters.contextRequests += 1;
|
|
1587
|
+
logger({
|
|
1588
|
+
event: "context_packet_assembled",
|
|
1589
|
+
...outcome.audit.request,
|
|
1590
|
+
selectedIds: outcome.audit.selectedIds,
|
|
1591
|
+
provenanceSummary: outcome.audit.provenanceSummary,
|
|
1592
|
+
estimatedTokens: outcome.audit.estimatedTokens,
|
|
1593
|
+
budgetTokens: outcome.audit.budgetTokens,
|
|
1594
|
+
candidateCount: outcome.audit.candidateCount,
|
|
1595
|
+
excludedSuperseded: outcome.audit.excludedSuperseded,
|
|
1596
|
+
});
|
|
1597
|
+
json(response, 200, { packet: outcome.packet, audit: outcome.audit });
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
const contextRecallMatch = url.pathname.match(/^\/v1\/context\/recall$/);
|
|
1602
|
+
if (method === "POST" && contextRecallMatch) {
|
|
1603
|
+
const body = await readJson(request);
|
|
1604
|
+
const { project: callerProject, caller: callerId } = contextCallerProject(request, body.project);
|
|
1605
|
+
const query = requireString(body.query ?? "", "query", { max: 500, allowEmpty: true }).toLowerCase();
|
|
1606
|
+
const kinds = Array.isArray(body.kinds)
|
|
1607
|
+
? body.kinds.filter((kind: unknown): kind is string => typeof kind === "string")
|
|
1608
|
+
: undefined;
|
|
1609
|
+
const limit = parseBoundedInteger(body.limit, "limit", 25, 1, 100);
|
|
1610
|
+
const { pool } = projectContextPool(callerProject);
|
|
1611
|
+
const recalled = pool
|
|
1612
|
+
.filter((item) => item.status !== "superseded" && item.status !== "rejected")
|
|
1613
|
+
.filter((item) => kinds === undefined || kinds.includes(item.kind))
|
|
1614
|
+
.filter((item) => query === "" || item.summary.toLowerCase().includes(query) || (item.stateKey ?? "").toLowerCase().includes(query))
|
|
1615
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
1616
|
+
.slice(0, limit);
|
|
1617
|
+
counters.contextRequests += 1;
|
|
1618
|
+
logger({ event: "context_recall", project: callerProject, query, limit, results: recalled.length });
|
|
1619
|
+
json(response, 200, { items: recalled.map(contextItemAuditMetadata), unresolvedGaps: recalled.length === 0 ? ["no matching context records"] : [] });
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
const contextStateMatch = url.pathname.match(/^\/v1\/context\/state$/);
|
|
1624
|
+
if (method === "POST" && contextStateMatch) {
|
|
1625
|
+
const body = await readJson(request);
|
|
1626
|
+
const { project: callerProject, caller: callerId } = contextCallerProject(request, body.project);
|
|
1627
|
+
const key = requireString(body.key, "key", { max: 200 });
|
|
1628
|
+
const asOf = body.asOf === undefined || body.asOf === null ? undefined : requireString(body.asOf, "asOf", { max: 64 });
|
|
1629
|
+
const current = await stateProvider.get(callerProject, key, asOf);
|
|
1630
|
+
counters.contextRequests += 1;
|
|
1631
|
+
json(response, 200, { state: current, key });
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
const contextStateProposeMatch = url.pathname.match(/^\/v1\/context\/state\/propose$/);
|
|
1636
|
+
if (method === "POST" && contextStateProposeMatch) {
|
|
1637
|
+
const body = await readJson(request);
|
|
1638
|
+
const { project: callerProject, caller: callerId } = contextCallerProject(request, body.project);
|
|
1639
|
+
const proposalId = await stateProvider.propose({
|
|
1640
|
+
schema: "kxm.state-change-proposal.v1",
|
|
1641
|
+
project: callerProject,
|
|
1642
|
+
key: requireString(body.key, "key", { max: 200 }),
|
|
1643
|
+
summary: requireString(body.summary, "summary", { max: 4_000 }),
|
|
1644
|
+
authority: parseContextAuthority(body.authority),
|
|
1645
|
+
confidence: parseContextConfidence(body.confidence),
|
|
1646
|
+
evidenceRefs: boundedStringList(body.evidenceRefs, "evidenceRefs", 32),
|
|
1647
|
+
proposedBy: (typeof body.proposedBy === "string" && body.proposedBy.trim()) ? body.proposedBy.trim() : callerId,
|
|
1648
|
+
});
|
|
1649
|
+
counters.contextRequests += 1;
|
|
1650
|
+
publishOps(callerProject, "workflows");
|
|
1651
|
+
logger({ event: "context_state_proposed", project: callerProject, proposalId, proposedBy: callerId });
|
|
1652
|
+
json(response, 201, { proposalId });
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
const contextStatePromoteMatch = url.pathname.match(/^\/v1\/context\/state\/promote$/);
|
|
1657
|
+
if (method === "POST" && contextStatePromoteMatch) {
|
|
1658
|
+
// Agents may propose state but never silently promote it: promotion is
|
|
1659
|
+
// an authorized control-plane decision with durable evidence.
|
|
1660
|
+
requireConfiguredAdminAuth(request, "state promotion");
|
|
1661
|
+
const body = await readJson(request);
|
|
1662
|
+
const proposalId = requireString(body.proposalId, "proposalId", { max: 128 });
|
|
1663
|
+
const project = requireString(body.project, "project", { max: 200 });
|
|
1664
|
+
const evidence = boundedStringList(body.evidence, "evidence", 32);
|
|
1665
|
+
const promoter = (typeof body.promotedBy === "string" && body.promotedBy.trim())
|
|
1666
|
+
? body.promotedBy.trim()
|
|
1667
|
+
: (typeof body.caller === "string" && body.caller.trim())
|
|
1668
|
+
? body.caller.trim()
|
|
1669
|
+
: (typeof request.headers["x-kxm-caller-id"] === "string" && request.headers["x-kxm-caller-id"].trim())
|
|
1670
|
+
? request.headers["x-kxm-caller-id"].trim()
|
|
1671
|
+
: "kxm-admin";
|
|
1672
|
+
const promoted = await stateProvider.promote(proposalId, evidence, promoter);
|
|
1673
|
+
counters.contextRequests += 1;
|
|
1674
|
+
publishOps(project, "workflows");
|
|
1675
|
+
logger({ event: "context_state_promoted", project, proposalId, promotedId: promoted.id, stateKey: promoted.stateKey, promotedBy: promoter });
|
|
1676
|
+
json(response, 200, { state: promoted });
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
const contextEpisodeMatch = url.pathname.match(/^\/v1\/context\/episode$/);
|
|
1681
|
+
if (method === "POST" && contextEpisodeMatch) {
|
|
1682
|
+
const body = await readJson(request);
|
|
1683
|
+
const { project: callerProject, caller: callerId } = contextCallerProject(request, body.project);
|
|
1684
|
+
const runId = body.workflowRunId === undefined || body.workflowRunId === null
|
|
1685
|
+
? undefined
|
|
1686
|
+
: requireString(body.workflowRunId, "workflowRunId", { max: 128 });
|
|
1687
|
+
const runIds = new Set(
|
|
1688
|
+
[...workflowRuns.values()]
|
|
1689
|
+
.filter((run) => run.project === callerProject && (runId === undefined || run.id === runId))
|
|
1690
|
+
.map((run) => run.id),
|
|
1691
|
+
);
|
|
1692
|
+
const episodes = [...journal.values()]
|
|
1693
|
+
.filter((entry) => runIds.has(entry.runId))
|
|
1694
|
+
.filter((entry) => entry.category === "error" || entry.category === "lesson" || entry.category === "observation" || entry.category === "experiment")
|
|
1695
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id))
|
|
1696
|
+
.map((entry) => journalEntryToContextItem(entry, callerProject))
|
|
1697
|
+
.slice(0, 50);
|
|
1698
|
+
counters.contextRequests += 1;
|
|
1699
|
+
json(response, 200, { episodes });
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const contextExplainMatch = url.pathname.match(/^\/v1\/context\/explain$/);
|
|
1704
|
+
if (method === "POST" && contextExplainMatch) {
|
|
1705
|
+
const body = await readJson(request);
|
|
1706
|
+
const { project: callerProject, caller: callerId } = contextCallerProject(request, body.project);
|
|
1707
|
+
const id = requireString(body.id, "id", { max: 128 });
|
|
1708
|
+
const { pool } = projectContextPool(callerProject);
|
|
1709
|
+
const explanation = explainContextItem(id, pool);
|
|
1710
|
+
counters.contextRequests += 1;
|
|
1711
|
+
json(response, 200, {
|
|
1712
|
+
found: explanation.item !== undefined,
|
|
1713
|
+
lineage: explanation.lineage,
|
|
1714
|
+
evidenceRefs: explanation.evidenceRefs,
|
|
1715
|
+
sources: explanation.sources,
|
|
1716
|
+
});
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
const contextWikiCompileMatch = url.pathname.match(/^\/v1\/context\/wiki\/compile$/);
|
|
1721
|
+
if (method === "POST" && contextWikiCompileMatch) {
|
|
1722
|
+
const body = await readJson(request);
|
|
1723
|
+
const { project: callerProject } = contextCallerProject(request, body.project);
|
|
1724
|
+
const wikiPool = contextWikiPool(callerProject, nowIso());
|
|
1725
|
+
const wiki = compileKnowledgeWiki(wikiPool);
|
|
1726
|
+
counters.contextRequests += 1;
|
|
1727
|
+
logger({
|
|
1728
|
+
event: "context_wiki_compiled",
|
|
1729
|
+
project: callerProject,
|
|
1730
|
+
pages: wiki.audit.pages.length,
|
|
1731
|
+
contradictions: wiki.audit.contradictions,
|
|
1732
|
+
});
|
|
1733
|
+
json(response, 200, {
|
|
1734
|
+
audit: wiki.audit,
|
|
1735
|
+
lint: lintKnowledgeWiki(wiki.pages, wikiPool),
|
|
1736
|
+
pages: [...wiki.pages].sort(([left], [right]) => left.localeCompare(right)).map(([path, content]) => ({ path, content })),
|
|
1737
|
+
});
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
const journalPromotionMatch = url.pathname.match(/^\/v1\/journal\/([^/]+)\/promotion$/);
|
|
1742
|
+
if (method === "POST" && journalPromotionMatch) {
|
|
1743
|
+
// Governed promotion is a control-plane decision. Only hub admins
|
|
1744
|
+
// may decide it, and the deciding principal can never be the entry
|
|
1745
|
+
// author. Journal entries remain evidence: this endpoint changes a
|
|
1746
|
+
// learning lifecycle state, never gates, workflow policy, or
|
|
1747
|
+
// permissions.
|
|
1748
|
+
requireAdminAuth(request);
|
|
1749
|
+
const body = await readJson(request);
|
|
1750
|
+
const entryId = decodeURIComponent(journalPromotionMatch[1]!);
|
|
1751
|
+
const entry = journal.get(entryId);
|
|
1752
|
+
if (!entry) throw new ProtocolError(404, "journal entry not found", "journal_not_found");
|
|
1753
|
+
const to = requireString(body.to, "to", { max: 24 });
|
|
1754
|
+
if (to !== "approved" && to !== "rejected" && to !== "quarantined") {
|
|
1755
|
+
throw new ProtocolError(
|
|
1756
|
+
400,
|
|
1757
|
+
"journal promotion target must be approved, rejected, or quarantined",
|
|
1758
|
+
"invalid_journal_promotion",
|
|
1759
|
+
);
|
|
1760
|
+
}
|
|
1761
|
+
const evidenceRefs = boundedStringList(body.evidenceRefs, "evidenceRefs", 32);
|
|
1762
|
+
const updated = applyJournalPromotion(
|
|
1763
|
+
entry,
|
|
1764
|
+
{
|
|
1765
|
+
to,
|
|
1766
|
+
evidenceRefs,
|
|
1767
|
+
decidedBy: "kxm-admin",
|
|
1768
|
+
reason: requireString(body.reason, "reason", { max: 1_000 }),
|
|
1769
|
+
},
|
|
1770
|
+
nowIso(),
|
|
1771
|
+
);
|
|
1772
|
+
store.saveJournalEntry(updated);
|
|
1773
|
+
publishOps(entry.runId, "workflows");
|
|
1774
|
+
logger({
|
|
1775
|
+
event: "journal_promotion_recorded",
|
|
1776
|
+
journalEntryId: entry.id,
|
|
1777
|
+
workflowRunId: entry.runId,
|
|
1778
|
+
category: entry.category,
|
|
1779
|
+
to,
|
|
1780
|
+
});
|
|
1781
|
+
json(response, 200, { entry: updated });
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
const waitMatch = url.pathname.match(/^\/v1\/workflows\/([^/]+)\/waits$/);
|
|
1786
|
+
if (method === "POST" && waitMatch) {
|
|
1787
|
+
expireWorkflowWaits();
|
|
1788
|
+
const agent = requireAgent(request);
|
|
1789
|
+
requireProjectAuth(request, agent.project);
|
|
1790
|
+
const body = await readJson(request);
|
|
1791
|
+
// Do not retain a workflow object across body I/O; another request may
|
|
1792
|
+
// have advanced this run while the body was still arriving.
|
|
1793
|
+
const run = workflowRuns.get(decodeURIComponent(waitMatch[1]!));
|
|
1794
|
+
if (!run) throw new ProtocolError(404, "workflow run not found", "workflow_not_found");
|
|
1795
|
+
if (run.project !== agent.project || run.targetAgentId !== agent.id) {
|
|
1796
|
+
throw new ProtocolError(
|
|
1797
|
+
403,
|
|
1798
|
+
"only the assigned coordinator can wait this workflow",
|
|
1799
|
+
"workflow_forbidden",
|
|
1800
|
+
workflowScopeExtras("wait", run.targetAgentName),
|
|
1801
|
+
);
|
|
1802
|
+
}
|
|
1803
|
+
const stageId = requireString(body.stageId, "stageId", { max: 64 });
|
|
1804
|
+
const signalKey = workflowSignalKey(body.signalKey);
|
|
1805
|
+
const summary = requireString(body.summary, "summary", { max: 4_000 });
|
|
1806
|
+
const evidence = boundedWorkflowEvidence(body.evidence);
|
|
1807
|
+
const evidenceRefs = boundedWorkflowEvidenceReferences(body.evidenceRefs);
|
|
1808
|
+
const timeoutMs = parseBoundedInteger(
|
|
1809
|
+
body.timeoutMs,
|
|
1810
|
+
"timeoutMs",
|
|
1811
|
+
DEFAULT_WORKFLOW_WAIT_TIMEOUT_MS,
|
|
1812
|
+
MIN_WORKFLOW_WAIT_TIMEOUT_MS,
|
|
1813
|
+
MAX_WORKFLOW_WAIT_TIMEOUT_MS,
|
|
1814
|
+
);
|
|
1815
|
+
const createdAt = nowIso();
|
|
1816
|
+
const transition = structuredClone(run);
|
|
1817
|
+
const stage = transition.stages.find((candidate) => candidate.id === stageId);
|
|
1818
|
+
const verifiedEvidence: WorkflowVerifiedEvidence = stage && Object.keys(evidenceRefs).length
|
|
1819
|
+
? verifyWorkflowEvidenceReferences(
|
|
1820
|
+
transition,
|
|
1821
|
+
stage,
|
|
1822
|
+
evidenceRefs,
|
|
1823
|
+
{ getMessage: (messageId) => messages.get(messageId) },
|
|
1824
|
+
createdAt,
|
|
1825
|
+
)
|
|
1826
|
+
: {};
|
|
1827
|
+
waitForWorkflowSignal(
|
|
1828
|
+
transition,
|
|
1829
|
+
stageId,
|
|
1830
|
+
signalKey,
|
|
1831
|
+
summary,
|
|
1832
|
+
createdAt,
|
|
1833
|
+
new Date(Date.parse(createdAt) + timeoutMs).toISOString(),
|
|
1834
|
+
evidence,
|
|
1835
|
+
verifiedEvidence,
|
|
1836
|
+
);
|
|
1837
|
+
store.saveWorkflowTransition(transition);
|
|
1838
|
+
publishOps(transition.project, "workflows");
|
|
1839
|
+
counters.workflowWaits += 1;
|
|
1840
|
+
logger({
|
|
1841
|
+
event: "workflow_wait_started",
|
|
1842
|
+
workflowRunId: transition.id,
|
|
1843
|
+
stageId,
|
|
1844
|
+
signalKey,
|
|
1845
|
+
expiresAt: transition.waiting?.expiresAt,
|
|
1846
|
+
});
|
|
1847
|
+
json(response, 202, {
|
|
1848
|
+
run: transition,
|
|
1849
|
+
instruction: "The agent may now settle this turn. A signed external signal will checkpoint the stage and resume the coordinator when more work is required.",
|
|
1850
|
+
});
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
const checkpointMatch = url.pathname.match(/^\/v1\/workflows\/([^/]+)\/checkpoints$/);
|
|
1855
|
+
if (method === "POST" && checkpointMatch) {
|
|
1856
|
+
const agent = requireAgent(request);
|
|
1857
|
+
requireProjectAuth(request, agent.project);
|
|
1858
|
+
const body = await readJson(request);
|
|
1859
|
+
// Re-read authoritative state after body I/O so concurrent attempts
|
|
1860
|
+
// cannot checkpoint from the same stale run snapshot.
|
|
1861
|
+
const run = workflowRuns.get(decodeURIComponent(checkpointMatch[1]!));
|
|
1862
|
+
if (!run) throw new ProtocolError(404, "workflow run not found", "workflow_not_found");
|
|
1863
|
+
if (run.project !== agent.project || run.targetAgentId !== agent.id) {
|
|
1864
|
+
throw new ProtocolError(
|
|
1865
|
+
403,
|
|
1866
|
+
"only the assigned coordinator can checkpoint this workflow",
|
|
1867
|
+
"workflow_forbidden",
|
|
1868
|
+
workflowScopeExtras("checkpoint", run.targetAgentName),
|
|
1869
|
+
);
|
|
1870
|
+
}
|
|
1871
|
+
const stageId = requireString(body.stageId, "stageId", { max: 64 });
|
|
1872
|
+
const status = requireString(body.status, "status", { max: 16 }) as WorkflowCheckpointStatus;
|
|
1873
|
+
if (status !== "passed" && status !== "warning" && status !== "failed") {
|
|
1874
|
+
throw new ProtocolError(400, "status must be passed, warning, or failed", "invalid_checkpoint_status");
|
|
1875
|
+
}
|
|
1876
|
+
const summary = requireString(body.summary, "summary", { max: 4_000 });
|
|
1877
|
+
const evidence = boundedWorkflowEvidence(body.evidence);
|
|
1878
|
+
const evidenceRefs = boundedWorkflowEvidenceReferences(body.evidenceRefs);
|
|
1879
|
+
if (status !== "passed" && Object.keys(evidenceRefs).length) {
|
|
1880
|
+
throw new ProtocolError(
|
|
1881
|
+
400,
|
|
1882
|
+
"evidenceRefs are accepted only for a passing checkpoint or workflow wait",
|
|
1883
|
+
"invalid_workflow_evidence_refs",
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1886
|
+
const timestamp = nowIso();
|
|
1887
|
+
const transition = structuredClone(run);
|
|
1888
|
+
const stage = transition.stages.find((candidate) => candidate.id === stageId);
|
|
1889
|
+
const verifiedEvidence: WorkflowVerifiedEvidence = stage && Object.keys(evidenceRefs).length
|
|
1890
|
+
? verifyWorkflowEvidenceReferences(
|
|
1891
|
+
transition,
|
|
1892
|
+
stage,
|
|
1893
|
+
evidenceRefs,
|
|
1894
|
+
{ getMessage: (messageId) => messages.get(messageId) },
|
|
1895
|
+
timestamp,
|
|
1896
|
+
)
|
|
1897
|
+
: {};
|
|
1898
|
+
const result = checkpointRun(
|
|
1899
|
+
transition,
|
|
1900
|
+
stageId,
|
|
1901
|
+
status,
|
|
1902
|
+
summary,
|
|
1903
|
+
evidence,
|
|
1904
|
+
timestamp,
|
|
1905
|
+
verifiedEvidence,
|
|
1906
|
+
typeof body.outcome === "string" && body.outcome.trim()
|
|
1907
|
+
? requireString(body.outcome, "outcome", { max: 64 })
|
|
1908
|
+
: undefined,
|
|
1909
|
+
);
|
|
1910
|
+
const checkpointStage = transition.stages.find((candidate) => candidate.id === stageId)!;
|
|
1911
|
+
if (result.transition) {
|
|
1912
|
+
const transitionEntry: WorkflowJournalEntry = {
|
|
1913
|
+
id: newId("journal"),
|
|
1914
|
+
runId: transition.id,
|
|
1915
|
+
agentId: agent.id,
|
|
1916
|
+
category: "state-change",
|
|
1917
|
+
area: checkpointStage?.area ?? "workflow",
|
|
1918
|
+
severity: "info",
|
|
1919
|
+
summary: `typed transition ${result.transition.fromStage} -> ${result.transition.toStage} (${result.transition.outcome})`,
|
|
1920
|
+
evidence: result.transition.evidenceKeys.map((key) => `requirement:${key}`),
|
|
1921
|
+
relatedEntryIds: [],
|
|
1922
|
+
createdAt: timestamp,
|
|
1923
|
+
};
|
|
1924
|
+
store.saveWorkflowTransition(transition, undefined, transitionEntry);
|
|
1925
|
+
counters.journalEntries += 1;
|
|
1926
|
+
}
|
|
1927
|
+
if (result.exhausted) {
|
|
1928
|
+
const exhaustEntry: WorkflowJournalEntry = {
|
|
1929
|
+
id: newId("journal"),
|
|
1930
|
+
runId: transition.id,
|
|
1931
|
+
agentId: agent.id,
|
|
1932
|
+
category: "error",
|
|
1933
|
+
area: checkpointStage?.area ?? "workflow",
|
|
1934
|
+
severity: "error",
|
|
1935
|
+
summary: `transition budget exhausted at ${stageId} (outcome ${body.outcome ?? status}); run failed safely`,
|
|
1936
|
+
evidence: [`class:transition_budget_exhausted`, `stage:${stageId}`],
|
|
1937
|
+
relatedEntryIds: [],
|
|
1938
|
+
createdAt: timestamp,
|
|
1939
|
+
};
|
|
1940
|
+
store.saveWorkflowTransition(transition, undefined, exhaustEntry);
|
|
1941
|
+
counters.journalEntries += 1;
|
|
1942
|
+
}
|
|
1943
|
+
let entry: WorkflowJournalEntry | undefined;
|
|
1944
|
+
if (status !== "passed") {
|
|
1945
|
+
entry = {
|
|
1946
|
+
id: newId("journal"),
|
|
1947
|
+
runId: transition.id,
|
|
1948
|
+
agentId: agent.id,
|
|
1949
|
+
category: "error",
|
|
1950
|
+
area: checkpointStage.area ?? "workflow",
|
|
1951
|
+
severity: status === "failed" ? "error" : "warning",
|
|
1952
|
+
summary: `${checkpointStage.label}: ${summary}`,
|
|
1953
|
+
evidence: workflowEvidenceStrings(evidence),
|
|
1954
|
+
relatedEntryIds: [],
|
|
1955
|
+
createdAt: transition.updatedAt,
|
|
1956
|
+
};
|
|
1957
|
+
} else if (result.degraded) {
|
|
1958
|
+
entry = {
|
|
1959
|
+
id: newId("journal"),
|
|
1960
|
+
runId: transition.id,
|
|
1961
|
+
agentId: agent.id,
|
|
1962
|
+
category: "decision",
|
|
1963
|
+
area: checkpointStage.area ?? "security",
|
|
1964
|
+
severity: "warning",
|
|
1965
|
+
summary: `${checkpointStage.label} passed using an explicitly approved degraded peer quorum`,
|
|
1966
|
+
evidence: [
|
|
1967
|
+
"class:workflow_quorum_degradation_used",
|
|
1968
|
+
...(checkpointStage.degradedRequirements ?? []).map((requirement) => `requirement:${requirement}`),
|
|
1969
|
+
],
|
|
1970
|
+
relatedEntryIds: [],
|
|
1971
|
+
createdAt: transition.updatedAt,
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
store.saveWorkflowTransition(transition, undefined, entry);
|
|
1975
|
+
publishOps(transition.project, "workflows");
|
|
1976
|
+
counters.workflowCheckpoints += 1;
|
|
1977
|
+
if (entry) counters.journalEntries += 1;
|
|
1978
|
+
exportTerminalRetrospective(transition);
|
|
1979
|
+
logger({
|
|
1980
|
+
event: "workflow_checkpoint",
|
|
1981
|
+
workflowRunId: transition.id,
|
|
1982
|
+
stageId,
|
|
1983
|
+
status,
|
|
1984
|
+
attempt: transition.stages.find((candidate) => candidate.id === stageId)?.attempts,
|
|
1985
|
+
retry: result.retry,
|
|
1986
|
+
completed: result.completed,
|
|
1987
|
+
});
|
|
1988
|
+
json(response, 200, {
|
|
1989
|
+
run: transition,
|
|
1990
|
+
retry: result.retry,
|
|
1991
|
+
completed: result.completed,
|
|
1992
|
+
instruction: result.retry
|
|
1993
|
+
? "Correct the warning or failure, record what changed, rerun the relevant checks, and checkpoint this stage again."
|
|
1994
|
+
: result.completed
|
|
1995
|
+
? "Workflow checkpoints are complete. Return the final outcome with links and evidence."
|
|
1996
|
+
: `Continue with stage ${transition.currentStage}.`,
|
|
1997
|
+
});
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
const journalMatch = url.pathname.match(/^\/v1\/workflows\/([^/]+)\/journal$/);
|
|
2002
|
+
if (method === "POST" && journalMatch) {
|
|
2003
|
+
const agent = requireAgent(request);
|
|
2004
|
+
requireProjectAuth(request, agent.project);
|
|
2005
|
+
const body = await readJson(request);
|
|
2006
|
+
const run = workflowRuns.get(decodeURIComponent(journalMatch[1]!));
|
|
2007
|
+
if (!run) throw new ProtocolError(404, "workflow run not found", "workflow_not_found");
|
|
2008
|
+
if (run.project !== agent.project || run.targetAgentId !== agent.id) {
|
|
2009
|
+
throw new ProtocolError(
|
|
2010
|
+
403,
|
|
2011
|
+
"only the assigned coordinator can journal this workflow",
|
|
2012
|
+
"workflow_forbidden",
|
|
2013
|
+
workflowScopeExtras("journal", run.targetAgentName),
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
2016
|
+
const category = parseJournalCategory(body.category);
|
|
2017
|
+
const area = requireString(body.area, "area", { max: 24 }) as ImprovementArea;
|
|
2018
|
+
if (!["harness", "gates", "implementation", "workflow", "documentation", "security", "other"].includes(area)) {
|
|
2019
|
+
throw new ProtocolError(400, "invalid improvement area", "invalid_improvement_area");
|
|
2020
|
+
}
|
|
2021
|
+
const severity = requireString(body.severity ?? "info", "severity", { max: 16 });
|
|
2022
|
+
if (severity !== "info" && severity !== "warning" && severity !== "error") {
|
|
2023
|
+
throw new ProtocolError(400, "severity must be info, warning, or error", "invalid_journal_severity");
|
|
2024
|
+
}
|
|
2025
|
+
const relatedEntryIds = boundedStringList(body.relatedEntryIds, "relatedEntryIds", 16);
|
|
2026
|
+
if (relatedEntryIds.some((id) => journal.get(id)?.runId !== run.id)) {
|
|
2027
|
+
throw new ProtocolError(
|
|
2028
|
+
400,
|
|
2029
|
+
"relatedEntryIds must reference journal entries in the same workflow run",
|
|
2030
|
+
"invalid_journal_relation",
|
|
2031
|
+
);
|
|
2032
|
+
}
|
|
2033
|
+
const evidence = boundedStringList(body.evidence, "evidence");
|
|
2034
|
+
if (journalEvidenceRequired(category) && evidence.length < 1) {
|
|
2035
|
+
throw new ProtocolError(
|
|
2036
|
+
400,
|
|
2037
|
+
`journal category ${category} requires at least one durable evidence reference`,
|
|
2038
|
+
"journal_evidence_required",
|
|
2039
|
+
);
|
|
2040
|
+
}
|
|
2041
|
+
let stageId: string | undefined;
|
|
2042
|
+
let attempt: number | undefined;
|
|
2043
|
+
if (body.stageId !== undefined && body.stageId !== null) {
|
|
2044
|
+
stageId = requireString(body.stageId, "stageId", { max: 128 });
|
|
2045
|
+
const stage = run.stages.find((candidate) => candidate.id === stageId);
|
|
2046
|
+
if (!stage) {
|
|
2047
|
+
throw new ProtocolError(400, `stageId ${stageId} is not part of this workflow run`, "invalid_journal_relation");
|
|
2048
|
+
}
|
|
2049
|
+
attempt = stage.attempts + 1;
|
|
2050
|
+
}
|
|
2051
|
+
const entry: WorkflowJournalEntry = {
|
|
2052
|
+
id: newId("journal"),
|
|
2053
|
+
runId: run.id,
|
|
2054
|
+
agentId: agent.id,
|
|
2055
|
+
category,
|
|
2056
|
+
area,
|
|
2057
|
+
severity,
|
|
2058
|
+
summary: requireString(body.summary, "summary", { max: 1_000 }),
|
|
2059
|
+
...(body.details ? { details: requireString(body.details, "details", { max: 8_000 }) } : {}),
|
|
2060
|
+
evidence,
|
|
2061
|
+
relatedEntryIds,
|
|
2062
|
+
createdAt: nowIso(),
|
|
2063
|
+
...(stageId !== undefined ? { stageId } : {}),
|
|
2064
|
+
...(attempt !== undefined ? { attempt } : {}),
|
|
2065
|
+
};
|
|
2066
|
+
store.saveJournalEntry(entry);
|
|
2067
|
+
counters.journalEntries += 1;
|
|
2068
|
+
logger({
|
|
2069
|
+
event: "workflow_journal_recorded",
|
|
2070
|
+
workflowRunId: run.id,
|
|
2071
|
+
journalEntryId: entry.id,
|
|
2072
|
+
category,
|
|
2073
|
+
area,
|
|
2074
|
+
severity,
|
|
2075
|
+
});
|
|
2076
|
+
json(response, 201, { entry });
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
if (method === "GET" && url.pathname === "/v1/improvements") {
|
|
2081
|
+
const agent = requireAgent(request);
|
|
2082
|
+
requireProjectAuth(request, agent.project);
|
|
2083
|
+
const visibleRuns = new Set(
|
|
2084
|
+
[...workflowRuns.values()]
|
|
2085
|
+
.filter((run) => run.project === agent.project)
|
|
2086
|
+
.map((run) => run.id),
|
|
2087
|
+
);
|
|
2088
|
+
const entries = [...journal.values()].filter((entry) => visibleRuns.has(entry.runId));
|
|
2089
|
+
json(response, 200, { reports: improvementReport(entries), entries: entries.length });
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
if (method === "POST" && url.pathname === "/v1/agents/register") {
|
|
2094
|
+
const body = await readJson(request);
|
|
2095
|
+
const name = requireString(body.name, "name", { max: 64 });
|
|
2096
|
+
const purpose = requireString(body.purpose ?? "General-purpose Pi agent", "purpose", { max: 256 });
|
|
2097
|
+
const project = requireString(body.project, "project", { max: 128 });
|
|
2098
|
+
requireProjectAuth(request, project);
|
|
2099
|
+
const model = optionalString(body.model, "model", 128);
|
|
2100
|
+
const existing = [...agents.values()].find(
|
|
2101
|
+
(agent) => agent.project === project && agent.name.toLowerCase() === name.toLowerCase(),
|
|
2102
|
+
);
|
|
2103
|
+
if (existing?.online) {
|
|
2104
|
+
throw new ProtocolError(409, `agent name already active in project: ${name}`, "duplicate_agent_name");
|
|
2105
|
+
}
|
|
2106
|
+
const timestamp = nowIso();
|
|
2107
|
+
const agent: StoredAgent = existing ?? {
|
|
2108
|
+
id: newId("agt"),
|
|
2109
|
+
key: newId("key"),
|
|
2110
|
+
name,
|
|
2111
|
+
purpose,
|
|
2112
|
+
project,
|
|
2113
|
+
connectedAt: timestamp,
|
|
2114
|
+
lastSeenAt: timestamp,
|
|
2115
|
+
online: true,
|
|
2116
|
+
};
|
|
2117
|
+
agent.key = newId("key");
|
|
2118
|
+
agent.name = name;
|
|
2119
|
+
agent.purpose = purpose;
|
|
2120
|
+
agent.project = project;
|
|
2121
|
+
agent.connectedAt = timestamp;
|
|
2122
|
+
agent.lastSeenAt = timestamp;
|
|
2123
|
+
agent.online = true;
|
|
2124
|
+
if (model) agent.model = model;
|
|
2125
|
+
else delete agent.model;
|
|
2126
|
+
store.saveAgent(agent);
|
|
2127
|
+
counters.registrations += 1;
|
|
2128
|
+
logger({ event: existing ? "agent_resumed" : "agent_registered", agentId: agent.id, name, project });
|
|
2129
|
+
broadcastPresence(agent);
|
|
2130
|
+
json(response, existing ? 200 : 201, { agent: publicAgent(agent), agentKey: agent.key, resumed: Boolean(existing) });
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
if (method === "GET" && url.pathname === "/v1/agents") {
|
|
2135
|
+
const current = requireAgent(request);
|
|
2136
|
+
requireProjectAuth(request, current.project);
|
|
2137
|
+
const result = [...agents.values()]
|
|
2138
|
+
.filter((agent) => agent.project === current.project && agent.online)
|
|
2139
|
+
.map(publicAgent);
|
|
2140
|
+
json(response, 200, { agents: result });
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
const heartbeatMatch = url.pathname.match(/^\/v1\/agents\/([^/]+)\/heartbeat$/);
|
|
2145
|
+
if (method === "POST" && heartbeatMatch) {
|
|
2146
|
+
const current = requireAgent(request, decodeURIComponent(heartbeatMatch[1]!));
|
|
2147
|
+
requireProjectAuth(request, current.project);
|
|
2148
|
+
await readJson(request);
|
|
2149
|
+
json(response, 200, { agent: publicAgent(current) });
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
const agentMatch = url.pathname.match(/^\/v1\/agents\/([^/]+)$/);
|
|
2154
|
+
if (method === "DELETE" && agentMatch) {
|
|
2155
|
+
const current = requireAgent(request, decodeURIComponent(agentMatch[1]!));
|
|
2156
|
+
requireProjectAuth(request, current.project);
|
|
2157
|
+
current.online = false;
|
|
2158
|
+
current.lastSeenAt = nowIso();
|
|
2159
|
+
store.saveAgent(current);
|
|
2160
|
+
broadcastPresence(current);
|
|
2161
|
+
logger({ event: "agent_unregistered", agentId: current.id, project: current.project });
|
|
2162
|
+
response.writeHead(204, { "cache-control": "no-store" }).end();
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
if (method === "GET" && url.pathname === "/v1/events") {
|
|
2167
|
+
const agentId = requireString(url.searchParams.get("agentId"), "agentId", { max: 80 });
|
|
2168
|
+
const current = requireAgent(request, agentId);
|
|
2169
|
+
requireProjectAuth(request, current.project);
|
|
2170
|
+
const presenceOnly = url.searchParams.get("presenceOnly") === "true";
|
|
2171
|
+
if (presenceOnly && current.model !== "kxm-tui") {
|
|
2172
|
+
throw new ProtocolError(403, "presence-only streams are reserved for metadata observers", "presence_stream_forbidden");
|
|
2173
|
+
}
|
|
2174
|
+
response.writeHead(200, {
|
|
2175
|
+
"content-type": "text/event-stream",
|
|
2176
|
+
"cache-control": "no-cache, no-transform",
|
|
2177
|
+
connection: "keep-alive",
|
|
2178
|
+
"x-accel-buffering": "no",
|
|
2179
|
+
"x-content-type-options": "nosniff",
|
|
2180
|
+
...(presenceOnly ? { "x-kxm-events-mode": "presence" } : {}),
|
|
2181
|
+
});
|
|
2182
|
+
response.write(`event: ready\ndata: ${JSON.stringify({ agent: publicAgent(current) })}\n\n`);
|
|
2183
|
+
const client: SseClient = {
|
|
2184
|
+
response,
|
|
2185
|
+
heartbeat: setInterval(() => response.write(": heartbeat\n\n"), 15_000),
|
|
2186
|
+
...(presenceOnly ? { presenceOnly: true } : {}),
|
|
2187
|
+
};
|
|
2188
|
+
client.heartbeat.unref();
|
|
2189
|
+
const clients = streams.get(agentId) ?? new Set<SseClient>();
|
|
2190
|
+
clients.add(client);
|
|
2191
|
+
streams.set(agentId, clients);
|
|
2192
|
+
if (!presenceOnly) flushPending(agentId);
|
|
2193
|
+
request.on("close", () => {
|
|
2194
|
+
clearInterval(client.heartbeat);
|
|
2195
|
+
clients.delete(client);
|
|
2196
|
+
if (clients.size === 0) streams.delete(agentId);
|
|
2197
|
+
});
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
if (method === "POST" && url.pathname === "/v1/messages") {
|
|
2202
|
+
const sender = requireAgent(request);
|
|
2203
|
+
requireProjectAuth(request, sender.project);
|
|
2204
|
+
const body = await readJson(request);
|
|
2205
|
+
const targetInput = requireString(body.target, "target", { max: 80 });
|
|
2206
|
+
const content = requireString(body.content, "content", { max: MAX_CONTENT_CHARS });
|
|
2207
|
+
const delivery = parseDeliveryMode(body.delivery);
|
|
2208
|
+
const requestedContext = requestedWorkflowMessageContext(body.workflowContext);
|
|
2209
|
+
const workflowContext = requestedContext
|
|
2210
|
+
? authorizeWorkflowMessageContext(sender, targetInput, requestedContext)
|
|
2211
|
+
: undefined;
|
|
2212
|
+
const callerCorrelationId = optionalString(body.correlationId, "correlationId", 128);
|
|
2213
|
+
if (workflowContext && callerCorrelationId && callerCorrelationId !== workflowContext.runId) {
|
|
2214
|
+
throw new ProtocolError(
|
|
2215
|
+
409,
|
|
2216
|
+
"correlationId must match workflowContext.runId",
|
|
2217
|
+
"workflow_context_correlation_mismatch",
|
|
2218
|
+
);
|
|
2219
|
+
}
|
|
2220
|
+
const correlationId = workflowContext?.runId ?? callerCorrelationId;
|
|
2221
|
+
const replyTo = optionalString(body.replyTo, "replyTo", 80);
|
|
2222
|
+
const hops = parseBoundedInteger(body.hops, "hops", 0, 0, 100);
|
|
2223
|
+
const maxHops = parseBoundedInteger(body.maxHops, "maxHops", DEFAULT_MAX_HOPS, 1, 20);
|
|
2224
|
+
if (hops >= maxHops) {
|
|
2225
|
+
throw new ProtocolError(400, `hop limit reached (${hops}/${maxHops})`, "hop_limit_reached");
|
|
2226
|
+
}
|
|
2227
|
+
const ttlMs = parseBoundedInteger(
|
|
2228
|
+
body.ttlMs,
|
|
2229
|
+
"ttlMs",
|
|
2230
|
+
defaultMessageTtlMs,
|
|
2231
|
+
MIN_MESSAGE_TTL_MS,
|
|
2232
|
+
MAX_MESSAGE_TTL_MS,
|
|
2233
|
+
);
|
|
2234
|
+
const idempotencyKey = optionalString(body.idempotencyKey, "idempotencyKey", 128);
|
|
2235
|
+
if (idempotencyKey) {
|
|
2236
|
+
const existing = store.findMessageByIdempotency(sender.id, idempotencyKey);
|
|
2237
|
+
if (existing) {
|
|
2238
|
+
if (!sameIdempotentRequest(
|
|
2239
|
+
existing,
|
|
2240
|
+
targetInput,
|
|
2241
|
+
content,
|
|
2242
|
+
delivery,
|
|
2243
|
+
correlationId,
|
|
2244
|
+
replyTo,
|
|
2245
|
+
hops,
|
|
2246
|
+
maxHops,
|
|
2247
|
+
ttlMs,
|
|
2248
|
+
workflowContext,
|
|
2249
|
+
)) {
|
|
2250
|
+
throw new ProtocolError(409, "idempotency key was already used for another request", "idempotency_conflict");
|
|
2251
|
+
}
|
|
2252
|
+
json(response, 200, { message: existing, idempotent: true });
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
const target = findTarget(sender.project, targetInput);
|
|
2257
|
+
if (target.id === sender.id) {
|
|
2258
|
+
throw new ProtocolError(400, "cannot send a request to yourself", "self_target");
|
|
2259
|
+
}
|
|
2260
|
+
const createdAt = nowIso();
|
|
2261
|
+
const seq = store.nextAgentSequence(target.id);
|
|
2262
|
+
const message: MessageRecord = {
|
|
2263
|
+
id: newId("msg"),
|
|
2264
|
+
project: sender.project,
|
|
2265
|
+
from: sender.id,
|
|
2266
|
+
fromName: sender.name,
|
|
2267
|
+
to: target.id,
|
|
2268
|
+
toName: target.name,
|
|
2269
|
+
content,
|
|
2270
|
+
delivery,
|
|
2271
|
+
hops,
|
|
2272
|
+
maxHops,
|
|
2273
|
+
seq,
|
|
2274
|
+
...(correlationId ? { correlationId } : {}),
|
|
2275
|
+
...(replyTo ? { replyTo } : {}),
|
|
2276
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
2277
|
+
...(workflowContext ? { workflowRunId: workflowContext.runId, workflowContext } : {}),
|
|
2278
|
+
createdAt,
|
|
2279
|
+
expiresAt: new Date(Date.parse(createdAt) + ttlMs).toISOString(),
|
|
2280
|
+
status: "queued",
|
|
2281
|
+
};
|
|
2282
|
+
store.saveMessage(message);
|
|
2283
|
+
publishOps(message.project, "messages");
|
|
2284
|
+
publish(target.id, { type: "message", message });
|
|
2285
|
+
counters.messagesSent += 1;
|
|
2286
|
+
logger({ event: "message_sent", messageId: message.id, hops, ...messageLog(message, sender.id, sender.name, target.id, target.name) });
|
|
2287
|
+
json(response, 202, { message, idempotent: false });
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
const ackMatch = url.pathname.match(/^\/v1\/messages\/([^/]+)\/ack$/);
|
|
2292
|
+
if (method === "POST" && ackMatch) {
|
|
2293
|
+
const receiver = requireAgent(request);
|
|
2294
|
+
requireProjectAuth(request, receiver.project);
|
|
2295
|
+
await readJson(request);
|
|
2296
|
+
expireMessages();
|
|
2297
|
+
expireWorkflowWaits();
|
|
2298
|
+
purgeTerminalMessages();
|
|
2299
|
+
const message = messages.get(decodeURIComponent(ackMatch[1]!));
|
|
2300
|
+
if (!message) throw new ProtocolError(404, "message not found", "message_not_found");
|
|
2301
|
+
if (message.to !== receiver.id) {
|
|
2302
|
+
throw new ProtocolError(403, "only the recipient can acknowledge this message", "message_forbidden");
|
|
2303
|
+
}
|
|
2304
|
+
if (message.status !== "queued" && message.status !== "delivered") {
|
|
2305
|
+
throw new ProtocolError(409, `cannot acknowledge a ${message.status} message`, "invalid_message_state");
|
|
2306
|
+
}
|
|
2307
|
+
if (message.status === "queued") {
|
|
2308
|
+
message.status = "delivered";
|
|
2309
|
+
message.deliveredAt = nowIso();
|
|
2310
|
+
store.saveMessage(message);
|
|
2311
|
+
publishOps(message.project, "messages");
|
|
2312
|
+
}
|
|
2313
|
+
if (typeof message.seq === "number") {
|
|
2314
|
+
store.advanceCursor(receiver.id, message.seq);
|
|
2315
|
+
}
|
|
2316
|
+
json(response, 200, { message });
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
const replyMatch = url.pathname.match(/^\/v1\/messages\/([^/]+)\/reply$/);
|
|
2321
|
+
if (method === "POST" && replyMatch) {
|
|
2322
|
+
const receiver = requireAgent(request);
|
|
2323
|
+
requireProjectAuth(request, receiver.project);
|
|
2324
|
+
const body = await readJson(request);
|
|
2325
|
+
expireMessages();
|
|
2326
|
+
const message = messages.get(decodeURIComponent(replyMatch[1]!));
|
|
2327
|
+
if (!message) throw new ProtocolError(404, "message not found", "message_not_found");
|
|
2328
|
+
if (message.to !== receiver.id) {
|
|
2329
|
+
throw new ProtocolError(403, "only the recipient can reply to this message", "message_forbidden");
|
|
2330
|
+
}
|
|
2331
|
+
if (message.status === "replied") {
|
|
2332
|
+
throw new ProtocolError(409, "message already has a reply", "duplicate_reply");
|
|
2333
|
+
}
|
|
2334
|
+
if (message.status === "cancelled" || message.status === "expired" || message.status === "error") {
|
|
2335
|
+
throw new ProtocolError(409, `cannot reply to a ${message.status} message`, "invalid_message_state");
|
|
2336
|
+
}
|
|
2337
|
+
message.reply = {
|
|
2338
|
+
content: requireString(body.content, "content", { max: MAX_CONTENT_CHARS }),
|
|
2339
|
+
createdAt: nowIso(),
|
|
2340
|
+
};
|
|
2341
|
+
message.repliedAt = message.reply.createdAt;
|
|
2342
|
+
message.status = "replied";
|
|
2343
|
+
store.saveMessage(message);
|
|
2344
|
+
publishOps(message.project, "messages");
|
|
2345
|
+
const workflowRun = [...workflowRuns.values()].find((run) => run.messageId === message.id);
|
|
2346
|
+
if (workflowRun?.status === "running") {
|
|
2347
|
+
workflowRun.status = "failed";
|
|
2348
|
+
delete workflowRun.currentStage;
|
|
2349
|
+
workflowRun.updatedAt = message.repliedAt;
|
|
2350
|
+
store.saveWorkflowRun(workflowRun);
|
|
2351
|
+
publishOps(workflowRun.project, "workflows");
|
|
2352
|
+
const entry: WorkflowJournalEntry = {
|
|
2353
|
+
id: newId("journal"),
|
|
2354
|
+
runId: workflowRun.id,
|
|
2355
|
+
agentId: receiver.id,
|
|
2356
|
+
category: "error",
|
|
2357
|
+
area: "workflow",
|
|
2358
|
+
severity: "error",
|
|
2359
|
+
summary: "Coordinator settled before all required workflow checkpoints passed",
|
|
2360
|
+
evidence: [`message:${message.id}`],
|
|
2361
|
+
relatedEntryIds: [],
|
|
2362
|
+
createdAt: message.repliedAt,
|
|
2363
|
+
};
|
|
2364
|
+
store.saveJournalEntry(entry);
|
|
2365
|
+
counters.journalEntries += 1;
|
|
2366
|
+
exportTerminalRetrospective(workflowRun);
|
|
2367
|
+
}
|
|
2368
|
+
publish(message.from, { type: "reply", message });
|
|
2369
|
+
counters.messagesReplied += 1;
|
|
2370
|
+
logger({ event: "message_replied", messageId: message.id, ...messageLog(message, receiver.id, receiver.name, message.from, message.fromName) });
|
|
2371
|
+
json(response, 200, { message });
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
const messageMatch = url.pathname.match(/^\/v1\/messages\/([^/]+)$/);
|
|
2376
|
+
if (method === "DELETE" && messageMatch) {
|
|
2377
|
+
const current = requireAgent(request);
|
|
2378
|
+
requireProjectAuth(request, current.project);
|
|
2379
|
+
expireMessages();
|
|
2380
|
+
const message = messages.get(decodeURIComponent(messageMatch[1]!));
|
|
2381
|
+
if (!message) throw new ProtocolError(404, "message not found", "message_not_found");
|
|
2382
|
+
if (message.from !== current.id) {
|
|
2383
|
+
throw new ProtocolError(403, "only the sender can cancel this message", "message_forbidden");
|
|
2384
|
+
}
|
|
2385
|
+
if (message.status === "cancelled") {
|
|
2386
|
+
json(response, 200, { message });
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
if (message.status !== "queued" && message.status !== "delivered") {
|
|
2390
|
+
throw new ProtocolError(409, `cannot cancel a ${message.status} message`, "invalid_message_state");
|
|
2391
|
+
}
|
|
2392
|
+
message.status = "cancelled";
|
|
2393
|
+
message.cancelledAt = nowIso();
|
|
2394
|
+
message.error = "message cancelled by sender";
|
|
2395
|
+
store.saveMessage(message);
|
|
2396
|
+
publishOps(message.project, "messages");
|
|
2397
|
+
publish(message.to, { type: "cancelled", message });
|
|
2398
|
+
counters.messagesCancelled += 1;
|
|
2399
|
+
logger({ event: "message_cancelled", messageId: message.id, ...messageLog(message, current.id, current.name, message.to, message.toName) });
|
|
2400
|
+
json(response, 200, { message });
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
if (method === "GET" && messageMatch) {
|
|
2405
|
+
const current = requireAgent(request);
|
|
2406
|
+
requireProjectAuth(request, current.project);
|
|
2407
|
+
expireMessages();
|
|
2408
|
+
const message = messages.get(decodeURIComponent(messageMatch[1]!));
|
|
2409
|
+
if (!message) throw new ProtocolError(404, "message not found", "message_not_found");
|
|
2410
|
+
if (message.from !== current.id && message.to !== current.id) {
|
|
2411
|
+
throw new ProtocolError(403, "message is not visible to this agent", "message_forbidden");
|
|
2412
|
+
}
|
|
2413
|
+
json(response, 200, { message });
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
throw new ProtocolError(404, "route not found", "route_not_found");
|
|
2418
|
+
} catch (error) {
|
|
2419
|
+
const statusCode = error instanceof ProtocolError ? error.statusCode : 500;
|
|
2420
|
+
const code = error instanceof ProtocolError ? error.code : "internal_error";
|
|
2421
|
+
const internalMessage = error instanceof Error ? error.message : "unknown error";
|
|
2422
|
+
const publicMessage = statusCode >= 500 ? "internal server error" : internalMessage;
|
|
2423
|
+
counters.errors += 1;
|
|
2424
|
+
logger({ event: "request_error", requestId, statusCode, code, ...(statusCode >= 500 ? { message: internalMessage } : {}) });
|
|
2425
|
+
if (!response.headersSent) {
|
|
2426
|
+
json(response, statusCode, {
|
|
2427
|
+
error: publicMessage,
|
|
2428
|
+
code,
|
|
2429
|
+
requestId,
|
|
2430
|
+
...(error instanceof ProtocolError && error.extras ? error.extras : {}),
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
else response.end();
|
|
2434
|
+
}
|
|
2435
|
+
});
|
|
2436
|
+
|
|
2437
|
+
return {
|
|
2438
|
+
server,
|
|
2439
|
+
state: { agents, messages, workflowRuns, journal, persistent: store.persistent },
|
|
2440
|
+
async start() {
|
|
2441
|
+
if (closed) throw new Error("hub is closed");
|
|
2442
|
+
await new Promise<void>((resolve, reject) => {
|
|
2443
|
+
server.once("error", reject);
|
|
2444
|
+
server.listen(port, host, () => {
|
|
2445
|
+
server.off("error", reject);
|
|
2446
|
+
resolve();
|
|
2447
|
+
});
|
|
2448
|
+
});
|
|
2449
|
+
cleanupTimer = setInterval(() => {
|
|
2450
|
+
const cutoff = Date.now() - staleAfterMs;
|
|
2451
|
+
for (const agent of agents.values()) {
|
|
2452
|
+
if (agent.online && Date.parse(agent.lastSeenAt) < cutoff) {
|
|
2453
|
+
agent.online = false;
|
|
2454
|
+
store.saveAgent(agent);
|
|
2455
|
+
broadcastPresence(agent);
|
|
2456
|
+
logger({ event: "agent_stale", agentId: agent.id, project: agent.project });
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
expireMessages();
|
|
2460
|
+
expireWorkflowWaits();
|
|
2461
|
+
purgeTerminalMessages();
|
|
2462
|
+
const rateCutoff = Date.now() - (rateLimit?.windowMs ?? 0);
|
|
2463
|
+
for (const [key, bucket] of rateBuckets) {
|
|
2464
|
+
if (bucket.startedAt < rateCutoff) rateBuckets.delete(key);
|
|
2465
|
+
}
|
|
2466
|
+
}, cleanupIntervalMs);
|
|
2467
|
+
cleanupTimer.unref();
|
|
2468
|
+
const address = server.address();
|
|
2469
|
+
if (!address || typeof address === "string") throw new Error("hub did not expose a TCP address");
|
|
2470
|
+
return { host, port: address.port, url: `http://${host}:${address.port}` };
|
|
2471
|
+
},
|
|
2472
|
+
async close() {
|
|
2473
|
+
if (closed) return;
|
|
2474
|
+
closed = true;
|
|
2475
|
+
if (cleanupTimer) clearInterval(cleanupTimer);
|
|
2476
|
+
const serverClosed = server.listening
|
|
2477
|
+
? new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
|
2478
|
+
: Promise.resolve();
|
|
2479
|
+
const forceClose = setTimeout(() => server.closeAllConnections(), shutdownGraceMs);
|
|
2480
|
+
forceClose.unref();
|
|
2481
|
+
for (const clients of streams.values()) {
|
|
2482
|
+
for (const client of clients) {
|
|
2483
|
+
clearInterval(client.heartbeat);
|
|
2484
|
+
client.response.end();
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
streams.clear();
|
|
2488
|
+
for (const client of opsStreams) {
|
|
2489
|
+
clearInterval(client.heartbeat);
|
|
2490
|
+
client.response.end();
|
|
2491
|
+
}
|
|
2492
|
+
opsStreams.clear();
|
|
2493
|
+
server.closeIdleConnections();
|
|
2494
|
+
try {
|
|
2495
|
+
await serverClosed;
|
|
2496
|
+
} finally {
|
|
2497
|
+
clearTimeout(forceClose);
|
|
2498
|
+
}
|
|
2499
|
+
store.close();
|
|
2500
|
+
},
|
|
2501
|
+
};
|
|
2502
|
+
}
|