@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,1548 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
MAX_MESSAGE_TTL_MS,
|
|
4
|
+
MIN_MESSAGE_TTL_MS,
|
|
5
|
+
ProtocolError,
|
|
6
|
+
newId,
|
|
7
|
+
requireString,
|
|
8
|
+
type ImprovementArea,
|
|
9
|
+
type JournalCategory,
|
|
10
|
+
type MessageRecord,
|
|
11
|
+
type WorkflowCheckpointStatus,
|
|
12
|
+
type WorkflowEvidenceInput,
|
|
13
|
+
type WorkflowEvidenceReference,
|
|
14
|
+
type WorkflowEvidenceReferenceInput,
|
|
15
|
+
type WorkflowMessageContext,
|
|
16
|
+
} from "./protocol.ts";
|
|
17
|
+
|
|
18
|
+
export type {
|
|
19
|
+
ImprovementArea,
|
|
20
|
+
JournalCategory,
|
|
21
|
+
WorkflowCheckpointStatus,
|
|
22
|
+
WorkflowEvidenceInput,
|
|
23
|
+
WorkflowEvidenceReference,
|
|
24
|
+
WorkflowEvidenceReferenceInput,
|
|
25
|
+
} from "./protocol.ts";
|
|
26
|
+
|
|
27
|
+
export type WorkflowRunStatus = "running" | "waiting" | "completed" | "failed";
|
|
28
|
+
export type WorkflowStageStatus = "pending" | "in_progress" | "waiting" | WorkflowCheckpointStatus;
|
|
29
|
+
|
|
30
|
+
/** Every journal category this runtime accepts. v0.4 records only use the
|
|
31
|
+
* first five; the v0.5 additions turn the journal into the canonical episode
|
|
32
|
+
* and learning substrate. */
|
|
33
|
+
export const JOURNAL_CATEGORIES: readonly JournalCategory[] = [
|
|
34
|
+
"plan",
|
|
35
|
+
"decision",
|
|
36
|
+
"contradiction",
|
|
37
|
+
"error",
|
|
38
|
+
"lesson",
|
|
39
|
+
"observation",
|
|
40
|
+
"hypothesis",
|
|
41
|
+
"experiment",
|
|
42
|
+
"state-change",
|
|
43
|
+
"skill-candidate",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** Categories that must cite durable evidence references. A lesson without
|
|
47
|
+
* evidence is an opinion; a skill-candidate without verified receipts is a
|
|
48
|
+
* wish. Both fail closed at the hub. */
|
|
49
|
+
export const EVIDENCE_REQUIRED_JOURNAL_CATEGORIES: readonly JournalCategory[] = ["lesson", "skill-candidate"];
|
|
50
|
+
|
|
51
|
+
/** Categories that participate in the governed promotion lifecycle. */
|
|
52
|
+
export const PROMOTABLE_JOURNAL_CATEGORIES: readonly JournalCategory[] = ["skill-candidate", "hypothesis", "experiment"];
|
|
53
|
+
|
|
54
|
+
export function parseJournalCategory(value: unknown): JournalCategory {
|
|
55
|
+
if (typeof value !== "string" || !JOURNAL_CATEGORIES.includes(value as JournalCategory)) {
|
|
56
|
+
throw new ProtocolError(
|
|
57
|
+
400,
|
|
58
|
+
`invalid journal category: must be one of ${JOURNAL_CATEGORIES.join(", ")}`,
|
|
59
|
+
"invalid_journal_category",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return value as JournalCategory;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function journalEvidenceRequired(category: JournalCategory): boolean {
|
|
66
|
+
return EVIDENCE_REQUIRED_JOURNAL_CATEGORIES.includes(category);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type JournalPromotionState = "proposed" | "approved" | "rejected" | "quarantined";
|
|
70
|
+
export const JOURNAL_PROMOTION_STATES: readonly JournalPromotionState[] = ["proposed", "approved", "rejected", "quarantined"];
|
|
71
|
+
const JOURNAL_PROMOTION_TERMINAL: readonly JournalPromotionState[] = ["approved", "rejected", "quarantined"];
|
|
72
|
+
|
|
73
|
+
/** Durable, append-only promotion decision for a journal entry. Journals are
|
|
74
|
+
* evidence, never executable policy: a promotion record changes the learning
|
|
75
|
+
* lifecycle of an entry, and nothing else. */
|
|
76
|
+
export interface JournalPromotionRecord {
|
|
77
|
+
schema: "kxm.journal-promotion.v1";
|
|
78
|
+
from: JournalPromotionState;
|
|
79
|
+
to: JournalPromotionState;
|
|
80
|
+
evidenceRefs: string[];
|
|
81
|
+
decidedBy: string;
|
|
82
|
+
reason: string;
|
|
83
|
+
decidedAt: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Durable evidence accumulated across local work, retries, and an external
|
|
87
|
+
* signal. A legacy string array can still be read from pre-0.4 databases, but
|
|
88
|
+
* it never satisfies a keyed requirement. */
|
|
89
|
+
export type WorkflowEvidence = Record<string, string[]>;
|
|
90
|
+
|
|
91
|
+
export interface PeerReplyDegradationPolicy {
|
|
92
|
+
minProducers: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface PeerReplyEvidencePolicy {
|
|
96
|
+
kind: "peer-reply";
|
|
97
|
+
minProducers: number;
|
|
98
|
+
eligibleAgents: string[];
|
|
99
|
+
acceptedStatuses?: ["replied"];
|
|
100
|
+
degradation?: PeerReplyDegradationPolicy;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type WorkflowEvidencePolicy = PeerReplyEvidencePolicy;
|
|
104
|
+
export type WorkflowEvidencePolicies = Record<string, WorkflowEvidencePolicy>;
|
|
105
|
+
|
|
106
|
+
export interface EligiblePeerProducer {
|
|
107
|
+
id: string;
|
|
108
|
+
name: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface ResolvedPeerReplyEvidencePolicy {
|
|
112
|
+
kind: "peer-reply";
|
|
113
|
+
minProducers: number;
|
|
114
|
+
eligibleProducers: EligiblePeerProducer[];
|
|
115
|
+
acceptedStatuses: ["replied"];
|
|
116
|
+
degradation?: PeerReplyDegradationPolicy;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type ResolvedWorkflowEvidencePolicies = Record<string, ResolvedPeerReplyEvidencePolicy>;
|
|
120
|
+
|
|
121
|
+
export interface VerifiedPeerEvidenceSnapshot {
|
|
122
|
+
schema: "pi-mesh.verified-peer-evidence.v1";
|
|
123
|
+
messageId: string;
|
|
124
|
+
producerId: string;
|
|
125
|
+
producerName: string;
|
|
126
|
+
context: WorkflowMessageContext;
|
|
127
|
+
status: "replied";
|
|
128
|
+
requestSha256: string;
|
|
129
|
+
replySha256: string;
|
|
130
|
+
createdAt: string;
|
|
131
|
+
replyCreatedAt: string;
|
|
132
|
+
repliedAt: string;
|
|
133
|
+
verifiedAt: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type WorkflowVerifiedEvidence = Record<string, VerifiedPeerEvidenceSnapshot[]>;
|
|
137
|
+
|
|
138
|
+
export interface WorkflowDegradationApproval {
|
|
139
|
+
schema: "pi-mesh.workflow-degradation-approval.v1";
|
|
140
|
+
id: string;
|
|
141
|
+
requirementKey: string;
|
|
142
|
+
attempt: number;
|
|
143
|
+
policyMinProducers: number;
|
|
144
|
+
approvedMinProducers: number;
|
|
145
|
+
approvedBy: "kxm-admin";
|
|
146
|
+
reason: string;
|
|
147
|
+
approvedAt: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface WorkflowStageDefinition {
|
|
151
|
+
id: string;
|
|
152
|
+
label: string;
|
|
153
|
+
instructions: string;
|
|
154
|
+
requiredEvidence: string[];
|
|
155
|
+
maxAttempts: number;
|
|
156
|
+
area?: ImprovementArea;
|
|
157
|
+
evidencePolicies?: WorkflowEvidencePolicies;
|
|
158
|
+
/** Typed outcome map (v0.5). Keys are outcome identities ("passed",
|
|
159
|
+
* "failed", or declared custom outcomes); values are stage IDs or
|
|
160
|
+
* "$terminal". Only declared keys create transitions; undeclared outcomes
|
|
161
|
+
* keep the v0.4 default edges (forward-next, attempt-bounded retry). */
|
|
162
|
+
on?: WorkflowOutcomeMap;
|
|
163
|
+
/** Per-stage budget on transitions taken from this stage. */
|
|
164
|
+
maxTransitions?: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A declared transition: a target stage ID, "$terminal", or a rule with a
|
|
168
|
+
* per-edge budget. */
|
|
169
|
+
export interface WorkflowTransitionRule {
|
|
170
|
+
target: string;
|
|
171
|
+
maxTransitions?: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type WorkflowOutcomeValue = string | WorkflowTransitionRule;
|
|
175
|
+
export type WorkflowOutcomeMap = Record<string, WorkflowOutcomeValue>;
|
|
176
|
+
|
|
177
|
+
export const WORKFLOW_TERMINAL_TARGET = "$terminal";
|
|
178
|
+
|
|
179
|
+
/** One durably journaled typed transition. */
|
|
180
|
+
export interface WorkflowTransitionRecord {
|
|
181
|
+
id: string;
|
|
182
|
+
fromStage: string;
|
|
183
|
+
toStage: string;
|
|
184
|
+
outcome: string;
|
|
185
|
+
attempt: number;
|
|
186
|
+
evidenceKeys: string[];
|
|
187
|
+
at: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function normalizeOutcomeValue(value: WorkflowOutcomeValue, field: string): WorkflowTransitionRule {
|
|
191
|
+
if (typeof value === "string") return { target: value };
|
|
192
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
193
|
+
throw new Error(`${field} must be a stage ID, "$terminal", or a { target, maxTransitions } rule`);
|
|
194
|
+
}
|
|
195
|
+
const rule = value as Partial<WorkflowTransitionRule>;
|
|
196
|
+
if (typeof rule.target !== "string" || !rule.target.trim()) {
|
|
197
|
+
throw new Error(`${field}.target must be a non-empty stage ID or "$terminal"`);
|
|
198
|
+
}
|
|
199
|
+
if (rule.maxTransitions !== undefined
|
|
200
|
+
&& (!Number.isInteger(rule.maxTransitions) || (rule.maxTransitions as number) < 1 || (rule.maxTransitions as number) > 100)) {
|
|
201
|
+
throw new Error(`${field}.maxTransitions must be an integer between 1 and 100`);
|
|
202
|
+
}
|
|
203
|
+
return { target: rule.target, ...(rule.maxTransitions !== undefined ? { maxTransitions: rule.maxTransitions } : {}) };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface WebhookWorkflowDefinition {
|
|
207
|
+
id: string;
|
|
208
|
+
source: "jira" | "github" | "generic";
|
|
209
|
+
project: string;
|
|
210
|
+
target: string;
|
|
211
|
+
secret: string;
|
|
212
|
+
signalSecret?: string;
|
|
213
|
+
event?: string;
|
|
214
|
+
filter?: { path: string; equals: string };
|
|
215
|
+
delivery: "steer" | "followUp";
|
|
216
|
+
ttlMs?: number;
|
|
217
|
+
promptTemplate: string;
|
|
218
|
+
stages: WorkflowStageDefinition[];
|
|
219
|
+
/** Global budget on typed transitions per run. Required whenever any stage
|
|
220
|
+
* declares a back-edge (a cycle without a budget is rejected at load). */
|
|
221
|
+
maxTransitions?: number;
|
|
222
|
+
/** Immutable reproduction oracle (v0.5 /fix): when `stageId` passes, the
|
|
223
|
+
* sha256 of `evidenceKey`'s value is captured on the run. Any later
|
|
224
|
+
* checkpoint citing the same evidence key with different values is
|
|
225
|
+
* rejected — a confirmed reproduction may not be weakened to make the
|
|
226
|
+
* fix pass. */
|
|
227
|
+
reproOracle?: WorkflowOracleConfig;
|
|
228
|
+
/** Approved-plan hash: when `stageId` passes, the sha256 of `evidenceKey`'s
|
|
229
|
+
* value is captured on the run. */
|
|
230
|
+
planHash?: WorkflowOracleConfig;
|
|
231
|
+
/** Stages that cannot checkpoint until the plan hash is captured. */
|
|
232
|
+
requirePlanHash?: string[];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface WorkflowOracleConfig {
|
|
236
|
+
stageId: string;
|
|
237
|
+
evidenceKey: string;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface WorkflowStageState extends WorkflowStageDefinition {
|
|
241
|
+
status: WorkflowStageStatus;
|
|
242
|
+
attempts: number;
|
|
243
|
+
summary?: string;
|
|
244
|
+
evidence: WorkflowEvidence | string[];
|
|
245
|
+
resolvedEvidencePolicies?: ResolvedWorkflowEvidencePolicies;
|
|
246
|
+
verifiedEvidence?: WorkflowVerifiedEvidence;
|
|
247
|
+
degradationApprovals?: WorkflowDegradationApproval[];
|
|
248
|
+
degraded?: boolean;
|
|
249
|
+
degradedRequirements?: string[];
|
|
250
|
+
startedAt?: string;
|
|
251
|
+
completedAt?: string;
|
|
252
|
+
updatedAt?: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface CapturedOracle {
|
|
256
|
+
evidenceKey: string;
|
|
257
|
+
sha256: string;
|
|
258
|
+
capturedAt: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface WorkflowWaitState {
|
|
262
|
+
stageId: string;
|
|
263
|
+
signalKey: string;
|
|
264
|
+
summary: string;
|
|
265
|
+
createdAt: string;
|
|
266
|
+
expiresAt: string;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface WorkflowSignalReceipt {
|
|
270
|
+
deliveryId: string;
|
|
271
|
+
payloadHash: string;
|
|
272
|
+
signalKey: string;
|
|
273
|
+
stageId: string;
|
|
274
|
+
status: WorkflowCheckpointStatus;
|
|
275
|
+
degraded?: boolean;
|
|
276
|
+
degradedRequirements?: string[];
|
|
277
|
+
messageId?: string;
|
|
278
|
+
receivedAt: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface WorkflowRun {
|
|
282
|
+
id: string;
|
|
283
|
+
definitionId: string;
|
|
284
|
+
source: WebhookWorkflowDefinition["source"];
|
|
285
|
+
deliveryId: string;
|
|
286
|
+
payloadHash: string;
|
|
287
|
+
/** sha256 of the canonical workflow definition JSON, stamped at run
|
|
288
|
+
* creation. Optional on read: runs persisted before this field existed
|
|
289
|
+
* still load, they simply have no hash to compare. */
|
|
290
|
+
definitionHash?: string;
|
|
291
|
+
event?: string;
|
|
292
|
+
project: string;
|
|
293
|
+
targetAgentId: string;
|
|
294
|
+
targetAgentName: string;
|
|
295
|
+
messageId: string;
|
|
296
|
+
status: WorkflowRunStatus;
|
|
297
|
+
currentStage?: string;
|
|
298
|
+
waiting?: WorkflowWaitState;
|
|
299
|
+
signalReceipts?: WorkflowSignalReceipt[];
|
|
300
|
+
stages: WorkflowStageState[];
|
|
301
|
+
/** Durable typed-transition journal (v0.5), oldest first, bounded by the
|
|
302
|
+
* definition's maxTransitions. Absent on v0.4 runs. */
|
|
303
|
+
transitions?: WorkflowTransitionRecord[];
|
|
304
|
+
/** Global transition budget copied from the definition at run creation. */
|
|
305
|
+
maxTransitions?: number;
|
|
306
|
+
/** Captured immutable reproduction oracle (sha256 of the repro evidence). */
|
|
307
|
+
oracle?: CapturedOracle;
|
|
308
|
+
/** Captured approved-plan hash. */
|
|
309
|
+
planHash?: CapturedOracle;
|
|
310
|
+
/** Stages that require the plan hash before checkpointing. */
|
|
311
|
+
requirePlanHash?: string[];
|
|
312
|
+
/** Oracle config copied from the definition at run creation. */
|
|
313
|
+
reproOracle?: WorkflowOracleConfig;
|
|
314
|
+
planHashConfig?: WorkflowOracleConfig;
|
|
315
|
+
createdAt: string;
|
|
316
|
+
updatedAt: string;
|
|
317
|
+
completedAt?: string;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export interface WorkflowJournalEntry {
|
|
321
|
+
id: string;
|
|
322
|
+
runId: string;
|
|
323
|
+
agentId: string;
|
|
324
|
+
category: JournalCategory;
|
|
325
|
+
area: ImprovementArea;
|
|
326
|
+
severity: "info" | "warning" | "error";
|
|
327
|
+
summary: string;
|
|
328
|
+
details?: string;
|
|
329
|
+
evidence: string[];
|
|
330
|
+
relatedEntryIds: string[];
|
|
331
|
+
createdAt: string;
|
|
332
|
+
/** Stage the entry was recorded against. Optional: v0.4 entries and
|
|
333
|
+
n * run-level entries have no stage binding. */
|
|
334
|
+
stageId?: string;
|
|
335
|
+
/** Attempt the entry was recorded against, when stage-bound. */
|
|
336
|
+
attempt?: number;
|
|
337
|
+
/** Governed promotion history. Absent on v0.4 records and on entries that
|
|
338
|
+
* never entered the promotion lifecycle. */
|
|
339
|
+
promotion?: JournalPromotionRecord[];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Current promotion lifecycle state of an entry. Entries without promotion
|
|
343
|
+
* records are implicitly `proposed` when they belong to a promotable
|
|
344
|
+
* category. */
|
|
345
|
+
export function journalPromotionState(entry: WorkflowJournalEntry): JournalPromotionState | undefined {
|
|
346
|
+
if (!PROMOTABLE_JOURNAL_CATEGORIES.includes(entry.category)) return undefined;
|
|
347
|
+
const records = entry.promotion ?? [];
|
|
348
|
+
return records.length === 0 ? "proposed" : records[records.length - 1]?.to;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export interface JournalPromotionDecision {
|
|
352
|
+
to: Exclude<JournalPromotionState, "proposed">;
|
|
353
|
+
evidenceRefs: string[];
|
|
354
|
+
decidedBy: string;
|
|
355
|
+
reason: string;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Apply a governed promotion transition. Returns a new entry; never mutates
|
|
359
|
+
* in place. Rules: only promotable categories; `proposed` is the only
|
|
360
|
+
* non-terminal state; the author of an entry can never decide its promotion;
|
|
361
|
+
* evidence references are required. */
|
|
362
|
+
export function applyJournalPromotion(
|
|
363
|
+
entry: WorkflowJournalEntry,
|
|
364
|
+
decision: JournalPromotionDecision,
|
|
365
|
+
decidedAt: string,
|
|
366
|
+
): WorkflowJournalEntry {
|
|
367
|
+
if (!PROMOTABLE_JOURNAL_CATEGORIES.includes(entry.category)) {
|
|
368
|
+
throw new ProtocolError(
|
|
369
|
+
400,
|
|
370
|
+
`journal entries of category ${entry.category} do not participate in promotion`,
|
|
371
|
+
"journal_promotion_invalid",
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (decision.decidedBy === entry.agentId) {
|
|
375
|
+
throw new ProtocolError(
|
|
376
|
+
400,
|
|
377
|
+
"the author of a journal entry cannot decide its promotion",
|
|
378
|
+
"journal_promotion_invalid",
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
if (!Array.isArray(decision.evidenceRefs) || decision.evidenceRefs.length < 1
|
|
382
|
+
|| decision.evidenceRefs.some((ref) => typeof ref !== "string" || !ref.trim())) {
|
|
383
|
+
throw new ProtocolError(
|
|
384
|
+
400,
|
|
385
|
+
"journal promotion requires at least one durable evidence reference",
|
|
386
|
+
"journal_promotion_invalid",
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
const current = journalPromotionState(entry);
|
|
390
|
+
if (current !== "proposed") {
|
|
391
|
+
throw new ProtocolError(
|
|
392
|
+
400,
|
|
393
|
+
`journal entry promotion already reached terminal state ${current}`,
|
|
394
|
+
"journal_promotion_invalid",
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
const record: JournalPromotionRecord = {
|
|
398
|
+
schema: "kxm.journal-promotion.v1",
|
|
399
|
+
from: "proposed",
|
|
400
|
+
to: decision.to,
|
|
401
|
+
evidenceRefs: decision.evidenceRefs.map((ref) => ref.trim()),
|
|
402
|
+
decidedBy: decision.decidedBy,
|
|
403
|
+
reason: decision.reason,
|
|
404
|
+
decidedAt,
|
|
405
|
+
};
|
|
406
|
+
return { ...entry, promotion: [...(entry.promotion ?? []), record] };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export interface ImprovementAreaReport {
|
|
410
|
+
area: ImprovementArea;
|
|
411
|
+
total: number;
|
|
412
|
+
errors: number;
|
|
413
|
+
contradictions: number;
|
|
414
|
+
lessons: number;
|
|
415
|
+
priorities: WorkflowJournalEntry[];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function improvementReport(entries: WorkflowJournalEntry[]): ImprovementAreaReport[] {
|
|
419
|
+
const areas: ImprovementArea[] = [
|
|
420
|
+
"harness",
|
|
421
|
+
"gates",
|
|
422
|
+
"implementation",
|
|
423
|
+
"workflow",
|
|
424
|
+
"documentation",
|
|
425
|
+
"security",
|
|
426
|
+
"other",
|
|
427
|
+
];
|
|
428
|
+
const severityWeight = { error: 3, warning: 2, info: 1 } as const;
|
|
429
|
+
return areas.map((area) => {
|
|
430
|
+
const matching = entries.filter((entry) => entry.area === area);
|
|
431
|
+
const priorities = [...matching]
|
|
432
|
+
.filter((entry) => entry.category === "error" || entry.category === "contradiction" || entry.category === "lesson" || entry.category === "skill-candidate")
|
|
433
|
+
.sort((left, right) => severityWeight[right.severity] - severityWeight[left.severity])
|
|
434
|
+
.slice(0, 10);
|
|
435
|
+
return {
|
|
436
|
+
area,
|
|
437
|
+
total: matching.length,
|
|
438
|
+
errors: matching.filter((entry) => entry.category === "error").length,
|
|
439
|
+
contradictions: matching.filter((entry) => entry.category === "contradiction").length,
|
|
440
|
+
lessons: matching.filter((entry) => entry.category === "lesson").length,
|
|
441
|
+
priorities,
|
|
442
|
+
};
|
|
443
|
+
}).filter((report) => report.total > 0);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function object(value: unknown, name: string): Record<string, unknown> {
|
|
447
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} must be an object`);
|
|
448
|
+
return value as Record<string, unknown>;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function stringArray(value: unknown, name: string): string[] {
|
|
452
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item.trim())) {
|
|
453
|
+
throw new Error(`${name} must be an array of non-empty strings`);
|
|
454
|
+
}
|
|
455
|
+
return value.map((item) => (item as string).trim());
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Canonical requirement identity used for matching and durable storage. */
|
|
459
|
+
export function canonicalWorkflowEvidenceKey(value: string): string {
|
|
460
|
+
return value.trim().replace(/\s+/gu, " ").toLowerCase();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function normalizeWorkflowEvidence(value: unknown): WorkflowEvidence {
|
|
464
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
465
|
+
const normalized = new Map<string, string[]>();
|
|
466
|
+
for (const [requirement, candidate] of Object.entries(value as Record<string, unknown>)) {
|
|
467
|
+
const key = canonicalWorkflowEvidenceKey(requirement);
|
|
468
|
+
if (!key) continue;
|
|
469
|
+
const values = Array.isArray(candidate) ? candidate : [candidate];
|
|
470
|
+
const safeValues = values
|
|
471
|
+
.filter((item): item is string => typeof item === "string" && Boolean(item.trim()))
|
|
472
|
+
.map((item) => item.trim());
|
|
473
|
+
if (safeValues.length > 0) {
|
|
474
|
+
normalized.set(key, [...new Set([...(normalized.get(key) ?? []), ...safeValues])]);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return Object.fromEntries(normalized);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function normalizeVerifiedWorkflowEvidence(value: unknown): WorkflowVerifiedEvidence {
|
|
481
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
482
|
+
const result = new Map<string, VerifiedPeerEvidenceSnapshot[]>();
|
|
483
|
+
for (const [rawRequirement, rawSnapshots] of Object.entries(value as Record<string, unknown>)) {
|
|
484
|
+
const requirement = canonicalWorkflowEvidenceKey(rawRequirement);
|
|
485
|
+
if (!requirement || !Array.isArray(rawSnapshots)) continue;
|
|
486
|
+
const snapshots = rawSnapshots.filter((candidate): candidate is VerifiedPeerEvidenceSnapshot => {
|
|
487
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return false;
|
|
488
|
+
const snapshot = candidate as Partial<VerifiedPeerEvidenceSnapshot>;
|
|
489
|
+
return snapshot.schema === "pi-mesh.verified-peer-evidence.v1"
|
|
490
|
+
&& typeof snapshot.messageId === "string"
|
|
491
|
+
&& typeof snapshot.producerId === "string"
|
|
492
|
+
&& typeof snapshot.producerName === "string"
|
|
493
|
+
&& snapshot.status === "replied"
|
|
494
|
+
&& typeof snapshot.requestSha256 === "string"
|
|
495
|
+
&& typeof snapshot.replySha256 === "string"
|
|
496
|
+
&& typeof snapshot.createdAt === "string"
|
|
497
|
+
&& typeof snapshot.replyCreatedAt === "string"
|
|
498
|
+
&& typeof snapshot.repliedAt === "string"
|
|
499
|
+
&& typeof snapshot.verifiedAt === "string"
|
|
500
|
+
&& snapshot.context?.schema === "pi-mesh.workflow-message-context.v1";
|
|
501
|
+
});
|
|
502
|
+
if (snapshots.length) result.set(requirement, snapshots);
|
|
503
|
+
}
|
|
504
|
+
return Object.fromEntries(result);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function mergeVerifiedWorkflowEvidence(
|
|
508
|
+
current: WorkflowVerifiedEvidence | undefined,
|
|
509
|
+
incoming: WorkflowVerifiedEvidence = {},
|
|
510
|
+
): WorkflowVerifiedEvidence {
|
|
511
|
+
const merged = new Map(Object.entries(normalizeVerifiedWorkflowEvidence(current)));
|
|
512
|
+
for (const [rawRequirement, snapshots] of Object.entries(incoming)) {
|
|
513
|
+
const requirement = canonicalWorkflowEvidenceKey(rawRequirement);
|
|
514
|
+
if (!requirement) continue;
|
|
515
|
+
const values = [...(merged.get(requirement) ?? [])];
|
|
516
|
+
for (const snapshot of snapshots) {
|
|
517
|
+
if (!values.some((candidate) => candidate.messageId === snapshot.messageId)) values.push(snapshot);
|
|
518
|
+
}
|
|
519
|
+
if (values.length) merged.set(requirement, values);
|
|
520
|
+
}
|
|
521
|
+
return Object.fromEntries(merged);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function mergeWorkflowEvidence(
|
|
525
|
+
current: WorkflowEvidence | string[] | undefined,
|
|
526
|
+
incoming: WorkflowEvidenceInput = {},
|
|
527
|
+
): WorkflowEvidence {
|
|
528
|
+
const merged = new Map(Object.entries(normalizeWorkflowEvidence(current)));
|
|
529
|
+
const seen = new Set<string>();
|
|
530
|
+
for (const [rawRequirement, rawValue] of Object.entries(incoming)) {
|
|
531
|
+
const requirement = canonicalWorkflowEvidenceKey(rawRequirement);
|
|
532
|
+
if (!requirement || typeof rawValue !== "string" || !rawValue.trim()) {
|
|
533
|
+
throw new ProtocolError(400, "evidence must contain non-empty keyed string values", "invalid_workflow_evidence");
|
|
534
|
+
}
|
|
535
|
+
if (seen.has(requirement)) {
|
|
536
|
+
throw new ProtocolError(
|
|
537
|
+
400,
|
|
538
|
+
`evidence contains duplicate normalized requirement identity: ${requirement}`,
|
|
539
|
+
"invalid_workflow_evidence",
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
seen.add(requirement);
|
|
543
|
+
const value = rawValue.trim();
|
|
544
|
+
const values = merged.get(requirement) ?? [];
|
|
545
|
+
if (!values.includes(value)) values.push(value);
|
|
546
|
+
merged.set(requirement, values);
|
|
547
|
+
}
|
|
548
|
+
return Object.fromEntries(merged);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function missingWorkflowEvidence(required: string[], evidence: WorkflowEvidence): string[] {
|
|
552
|
+
return required
|
|
553
|
+
.map(canonicalWorkflowEvidenceKey)
|
|
554
|
+
.filter((requirement) => !evidence[requirement]?.length);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function workflowEvidenceStrings(evidence: WorkflowEvidenceInput | WorkflowEvidence): string[] {
|
|
558
|
+
return Object.entries(evidence).flatMap(([requirement, candidate]) => {
|
|
559
|
+
const values = Array.isArray(candidate) ? candidate : [candidate];
|
|
560
|
+
return values.map((value) => `${requirement}: ${value}`);
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function activeWorkflowAttempt(stage: Pick<WorkflowStageState, "attempts">): number {
|
|
565
|
+
return stage.attempts + 1;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export interface EvidenceLookup {
|
|
569
|
+
getMessage(id: string): MessageRecord | undefined;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function validIsoTimestamp(value: string | undefined): number | undefined {
|
|
573
|
+
if (!value) return undefined;
|
|
574
|
+
const parsed = Date.parse(value);
|
|
575
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Verify a complete reference batch without mutating the run. */
|
|
579
|
+
export function verifyWorkflowEvidenceReferences(
|
|
580
|
+
run: WorkflowRun,
|
|
581
|
+
stage: WorkflowStageState,
|
|
582
|
+
references: WorkflowEvidenceReferenceInput,
|
|
583
|
+
lookup: EvidenceLookup,
|
|
584
|
+
verifiedAt: string,
|
|
585
|
+
): WorkflowVerifiedEvidence {
|
|
586
|
+
const expectedAttempt = activeWorkflowAttempt(stage);
|
|
587
|
+
const result = new Map<string, VerifiedPeerEvidenceSnapshot[]>();
|
|
588
|
+
const seenRequirements = new Set<string>();
|
|
589
|
+
const seenMessageIds = new Set<string>();
|
|
590
|
+
|
|
591
|
+
for (const [rawRequirement, reference] of Object.entries(references)) {
|
|
592
|
+
const requirementKey = canonicalWorkflowEvidenceKey(rawRequirement);
|
|
593
|
+
if (!requirementKey || seenRequirements.has(requirementKey)) {
|
|
594
|
+
throw new ProtocolError(
|
|
595
|
+
400,
|
|
596
|
+
`evidenceRefs contains duplicate or empty requirement identity: ${requirementKey || "(empty)"}`,
|
|
597
|
+
"invalid_workflow_evidence_refs",
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
seenRequirements.add(requirementKey);
|
|
601
|
+
const policy = stage.resolvedEvidencePolicies?.[requirementKey];
|
|
602
|
+
if (!policy) {
|
|
603
|
+
throw new ProtocolError(
|
|
604
|
+
400,
|
|
605
|
+
`requirement ${requirementKey} does not declare a resolved peer evidence policy`,
|
|
606
|
+
"workflow_evidence_policy_missing",
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
if (!reference || !Array.isArray(reference.messageIds) || reference.messageIds.length < 1 || reference.messageIds.length > 16) {
|
|
610
|
+
throw new ProtocolError(
|
|
611
|
+
400,
|
|
612
|
+
`evidenceRefs.${requirementKey}.messageIds must contain between 1 and 16 message IDs`,
|
|
613
|
+
"invalid_workflow_evidence_refs",
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const eligibleProducerIds = new Set(policy.eligibleProducers.map((producer) => producer.id));
|
|
618
|
+
const snapshots: VerifiedPeerEvidenceSnapshot[] = [];
|
|
619
|
+
for (const rawMessageId of reference.messageIds) {
|
|
620
|
+
const messageId = typeof rawMessageId === "string" ? rawMessageId.trim() : "";
|
|
621
|
+
if (!messageId || seenMessageIds.has(messageId)) {
|
|
622
|
+
throw new ProtocolError(
|
|
623
|
+
400,
|
|
624
|
+
`evidenceRefs contains an empty or duplicate message ID: ${messageId || "(empty)"}`,
|
|
625
|
+
"invalid_workflow_evidence_refs",
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
seenMessageIds.add(messageId);
|
|
629
|
+
const message = lookup.getMessage(messageId);
|
|
630
|
+
if (!message) {
|
|
631
|
+
throw new ProtocolError(400, `peer evidence message not found: ${messageId}`, "workflow_provenance_invalid");
|
|
632
|
+
}
|
|
633
|
+
const context = message.workflowContext;
|
|
634
|
+
if (
|
|
635
|
+
context?.schema !== "pi-mesh.workflow-message-context.v1"
|
|
636
|
+
|| context.runId !== run.id
|
|
637
|
+
|| context.stageId !== stage.id
|
|
638
|
+
|| context.requirementKey !== requirementKey
|
|
639
|
+
|| context.attempt !== expectedAttempt
|
|
640
|
+
) {
|
|
641
|
+
throw new ProtocolError(
|
|
642
|
+
400,
|
|
643
|
+
`peer evidence message ${messageId} is not bound to ${run.id}/${stage.id}/${requirementKey}/attempt-${expectedAttempt}`,
|
|
644
|
+
"workflow_provenance_invalid",
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
if (message.project !== run.project || message.from !== run.targetAgentId || message.to === run.targetAgentId) {
|
|
648
|
+
throw new ProtocolError(
|
|
649
|
+
400,
|
|
650
|
+
`peer evidence message ${messageId} has an invalid project or direction`,
|
|
651
|
+
"workflow_provenance_invalid",
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
if (!eligibleProducerIds.has(message.to)) {
|
|
655
|
+
throw new ProtocolError(
|
|
656
|
+
400,
|
|
657
|
+
`peer evidence producer ${message.toName} is not eligible for ${requirementKey}`,
|
|
658
|
+
"workflow_provenance_invalid",
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
if (message.correlationId !== run.id || message.status !== "replied" || !message.reply?.content.trim()) {
|
|
662
|
+
throw new ProtocolError(
|
|
663
|
+
400,
|
|
664
|
+
`peer evidence message ${messageId} is not a replied message for run ${run.id}`,
|
|
665
|
+
"workflow_provenance_invalid",
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
const createdAt = validIsoTimestamp(message.createdAt);
|
|
669
|
+
const deliveredAt = message.deliveredAt === undefined ? undefined : validIsoTimestamp(message.deliveredAt);
|
|
670
|
+
const replyCreatedAt = validIsoTimestamp(message.reply.createdAt);
|
|
671
|
+
const repliedAt = validIsoTimestamp(message.repliedAt);
|
|
672
|
+
if (
|
|
673
|
+
createdAt === undefined
|
|
674
|
+
|| replyCreatedAt === undefined
|
|
675
|
+
|| repliedAt === undefined
|
|
676
|
+
|| (message.deliveredAt !== undefined && deliveredAt === undefined)
|
|
677
|
+
|| (deliveredAt !== undefined && (deliveredAt < createdAt || deliveredAt > repliedAt))
|
|
678
|
+
|| replyCreatedAt < createdAt
|
|
679
|
+
|| repliedAt < replyCreatedAt
|
|
680
|
+
) {
|
|
681
|
+
throw new ProtocolError(
|
|
682
|
+
400,
|
|
683
|
+
`peer evidence message ${messageId} has incoherent reply timestamps`,
|
|
684
|
+
"workflow_provenance_invalid",
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
snapshots.push({
|
|
688
|
+
schema: "pi-mesh.verified-peer-evidence.v1",
|
|
689
|
+
messageId: message.id,
|
|
690
|
+
producerId: message.to,
|
|
691
|
+
producerName: message.toName,
|
|
692
|
+
context: { ...context },
|
|
693
|
+
status: "replied",
|
|
694
|
+
requestSha256: createHash("sha256").update(message.content, "utf8").digest("hex"),
|
|
695
|
+
replySha256: createHash("sha256").update(message.reply.content, "utf8").digest("hex"),
|
|
696
|
+
createdAt: message.createdAt,
|
|
697
|
+
replyCreatedAt: message.reply.createdAt,
|
|
698
|
+
repliedAt: message.repliedAt!,
|
|
699
|
+
verifiedAt,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
result.set(requirementKey, snapshots);
|
|
703
|
+
}
|
|
704
|
+
return Object.fromEntries(result);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export interface PeerEvidenceRequirementStatus {
|
|
708
|
+
requirementKey: string;
|
|
709
|
+
policyMinProducers: number;
|
|
710
|
+
effectiveMinProducers: number;
|
|
711
|
+
producers: string[];
|
|
712
|
+
met: boolean;
|
|
713
|
+
degraded: boolean;
|
|
714
|
+
approval?: WorkflowDegradationApproval;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function peerEvidenceRequirementStatus(
|
|
718
|
+
stage: WorkflowStageState,
|
|
719
|
+
requirementKey: string,
|
|
720
|
+
runId: string,
|
|
721
|
+
verifiedEvidence: WorkflowVerifiedEvidence = normalizeVerifiedWorkflowEvidence(stage.verifiedEvidence),
|
|
722
|
+
): PeerEvidenceRequirementStatus | undefined {
|
|
723
|
+
const canonicalKey = canonicalWorkflowEvidenceKey(requirementKey);
|
|
724
|
+
const policy = stage.resolvedEvidencePolicies?.[canonicalKey];
|
|
725
|
+
if (!policy) return undefined;
|
|
726
|
+
const attempt = activeWorkflowAttempt(stage);
|
|
727
|
+
const eligibleIds = new Set(policy.eligibleProducers.map((producer) => producer.id));
|
|
728
|
+
const producers = new Set<string>();
|
|
729
|
+
for (const snapshot of verifiedEvidence[canonicalKey] ?? []) {
|
|
730
|
+
if (
|
|
731
|
+
snapshot.schema === "pi-mesh.verified-peer-evidence.v1"
|
|
732
|
+
&& snapshot.status === "replied"
|
|
733
|
+
&& snapshot.context?.schema === "pi-mesh.workflow-message-context.v1"
|
|
734
|
+
&& snapshot.context.runId === runId
|
|
735
|
+
&& snapshot.context.stageId === stage.id
|
|
736
|
+
&& snapshot.context.requirementKey === canonicalKey
|
|
737
|
+
&& snapshot.context.attempt === attempt
|
|
738
|
+
&& eligibleIds.has(snapshot.producerId)
|
|
739
|
+
&& /^[a-f0-9]{64}$/.test(snapshot.requestSha256)
|
|
740
|
+
&& /^[a-f0-9]{64}$/.test(snapshot.replySha256)
|
|
741
|
+
&& validIsoTimestamp(snapshot.createdAt) !== undefined
|
|
742
|
+
&& validIsoTimestamp(snapshot.replyCreatedAt) !== undefined
|
|
743
|
+
&& validIsoTimestamp(snapshot.repliedAt) !== undefined
|
|
744
|
+
&& validIsoTimestamp(snapshot.verifiedAt) !== undefined
|
|
745
|
+
) producers.add(snapshot.producerId);
|
|
746
|
+
}
|
|
747
|
+
const approval = stage.degradationApprovals?.find(
|
|
748
|
+
(candidate) => candidate.requirementKey === canonicalKey && candidate.attempt === attempt,
|
|
749
|
+
);
|
|
750
|
+
const effectiveMinProducers = approval?.approvedMinProducers ?? policy.minProducers;
|
|
751
|
+
return {
|
|
752
|
+
requirementKey: canonicalKey,
|
|
753
|
+
policyMinProducers: policy.minProducers,
|
|
754
|
+
effectiveMinProducers,
|
|
755
|
+
producers: [...producers],
|
|
756
|
+
met: producers.size >= effectiveMinProducers,
|
|
757
|
+
degraded: Boolean(approval && producers.size < policy.minProducers && producers.size >= effectiveMinProducers),
|
|
758
|
+
...(approval ? { approval } : {}),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function requireCompleteEvidence(
|
|
763
|
+
stage: WorkflowStageState,
|
|
764
|
+
evidence: WorkflowEvidence,
|
|
765
|
+
verifiedEvidence: WorkflowVerifiedEvidence,
|
|
766
|
+
runId: string,
|
|
767
|
+
): PeerEvidenceRequirementStatus[] {
|
|
768
|
+
const missing: string[] = [];
|
|
769
|
+
const peerStatuses: PeerEvidenceRequirementStatus[] = [];
|
|
770
|
+
for (const rawRequirement of stage.requiredEvidence) {
|
|
771
|
+
const requirement = canonicalWorkflowEvidenceKey(rawRequirement);
|
|
772
|
+
const peerStatus = peerEvidenceRequirementStatus(stage, requirement, runId, verifiedEvidence);
|
|
773
|
+
if (peerStatus) {
|
|
774
|
+
peerStatuses.push(peerStatus);
|
|
775
|
+
if (!peerStatus.met) missing.push(requirement);
|
|
776
|
+
} else if (stage.evidencePolicies?.[requirement]) {
|
|
777
|
+
throw new ProtocolError(
|
|
778
|
+
409,
|
|
779
|
+
`stage ${stage.id} evidence policy ${requirement} was not resolved when the run started`,
|
|
780
|
+
"workflow_evidence_policy_unresolved",
|
|
781
|
+
);
|
|
782
|
+
} else if (!evidence[requirement]?.length) {
|
|
783
|
+
missing.push(requirement);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (missing.length === 0) return peerStatuses;
|
|
787
|
+
throw new ProtocolError(
|
|
788
|
+
400,
|
|
789
|
+
`stage ${stage.id} is missing required evidence: ${missing.join(", ")}`,
|
|
790
|
+
"workflow_evidence_incomplete",
|
|
791
|
+
{
|
|
792
|
+
missingRequirements: missing,
|
|
793
|
+
providedRequirements: Object.keys(evidence),
|
|
794
|
+
peerRequirements: peerStatuses,
|
|
795
|
+
},
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function parseWorkflowEvidencePolicies(
|
|
800
|
+
value: unknown,
|
|
801
|
+
stageId: string,
|
|
802
|
+
requiredEvidence: string[],
|
|
803
|
+
workflowId: string,
|
|
804
|
+
targetName: string,
|
|
805
|
+
warn: (message: string) => void,
|
|
806
|
+
): WorkflowEvidencePolicies | undefined {
|
|
807
|
+
if (value === undefined) return undefined;
|
|
808
|
+
const rawPolicies = object(value, `stage ${stageId} evidencePolicies`);
|
|
809
|
+
const policies = new Map<string, WorkflowEvidencePolicy>();
|
|
810
|
+
for (const [rawRequirement, rawPolicy] of Object.entries(rawPolicies)) {
|
|
811
|
+
const requirementKey = canonicalWorkflowEvidenceKey(
|
|
812
|
+
requireString(rawRequirement, `stage ${stageId} evidencePolicies requirement`, { max: 128 }),
|
|
813
|
+
);
|
|
814
|
+
if (!requiredEvidence.includes(requirementKey)) {
|
|
815
|
+
throw new Error(`stage ${stageId} evidence policy ${requirementKey} must match requiredEvidence`);
|
|
816
|
+
}
|
|
817
|
+
if (policies.has(requirementKey)) {
|
|
818
|
+
throw new Error(`stage ${stageId} evidencePolicies keys must be unique after normalization`);
|
|
819
|
+
}
|
|
820
|
+
const policy = object(rawPolicy, `stage ${stageId} evidencePolicies.${requirementKey}`);
|
|
821
|
+
const supportedPolicyFields = new Set([
|
|
822
|
+
"kind",
|
|
823
|
+
"minProducers",
|
|
824
|
+
"eligibleAgents",
|
|
825
|
+
"acceptedStatuses",
|
|
826
|
+
"degradation",
|
|
827
|
+
]);
|
|
828
|
+
const unsupportedPolicyFields = Object.keys(policy).filter((field) => !supportedPolicyFields.has(field));
|
|
829
|
+
if (unsupportedPolicyFields.length) {
|
|
830
|
+
throw new Error(
|
|
831
|
+
`stage ${stageId} evidencePolicies.${requirementKey} contains unsupported fields: ${unsupportedPolicyFields.join(", ")}`,
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
if (policy.kind !== "peer-reply") {
|
|
835
|
+
throw new Error(`stage ${stageId} evidencePolicies.${requirementKey}.kind must be peer-reply`);
|
|
836
|
+
}
|
|
837
|
+
const minProducers = policy.minProducers;
|
|
838
|
+
if (!Number.isInteger(minProducers) || (minProducers as number) < 1 || (minProducers as number) > 8) {
|
|
839
|
+
throw new Error(`stage ${stageId} evidencePolicies.${requirementKey}.minProducers must be an integer between 1 and 8`);
|
|
840
|
+
}
|
|
841
|
+
const eligibleAgents = stringArray(
|
|
842
|
+
policy.eligibleAgents,
|
|
843
|
+
`stage ${stageId} evidencePolicies.${requirementKey}.eligibleAgents`,
|
|
844
|
+
);
|
|
845
|
+
if (eligibleAgents.length < 1 || eligibleAgents.length > 16) {
|
|
846
|
+
throw new Error(`stage ${stageId} evidencePolicies.${requirementKey}.eligibleAgents must contain between 1 and 16 selectors`);
|
|
847
|
+
}
|
|
848
|
+
const normalizedSelectors = eligibleAgents.map((selector) => selector.toLowerCase());
|
|
849
|
+
if (new Set(normalizedSelectors).size !== normalizedSelectors.length) {
|
|
850
|
+
throw new Error(`stage ${stageId} evidencePolicies.${requirementKey}.eligibleAgents must be unique`);
|
|
851
|
+
}
|
|
852
|
+
// The workflow target can never produce peer evidence for its own run:
|
|
853
|
+
// the hub requires evidence messages from the target to a peer that is
|
|
854
|
+
// not the target, so a coordinator in its own quorum is structurally
|
|
855
|
+
// unable to produce. Reject the configuration instead of shipping a
|
|
856
|
+
// stage that can only pass via degradation.
|
|
857
|
+
if (normalizedSelectors.includes(targetName)) {
|
|
858
|
+
throw new Error(
|
|
859
|
+
`workflow ${workflowId} stage ${stageId} evidencePolicies.${requirementKey}.eligibleAgents must not include the workflow target ${targetName}: the target cannot produce peer evidence for its own run`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
if ((minProducers as number) > normalizedSelectors.length) {
|
|
863
|
+
throw new Error(`stage ${stageId} evidencePolicies.${requirementKey}.minProducers exceeds eligibleAgents`);
|
|
864
|
+
}
|
|
865
|
+
if (policy.acceptedStatuses !== undefined) {
|
|
866
|
+
const statuses = stringArray(
|
|
867
|
+
policy.acceptedStatuses,
|
|
868
|
+
`stage ${stageId} evidencePolicies.${requirementKey}.acceptedStatuses`,
|
|
869
|
+
);
|
|
870
|
+
if (statuses.length !== 1 || statuses[0] !== "replied") {
|
|
871
|
+
throw new Error(`stage ${stageId} evidencePolicies.${requirementKey}.acceptedStatuses must be ["replied"]`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
let degradation: PeerReplyDegradationPolicy | undefined;
|
|
875
|
+
if (policy.degradation !== undefined) {
|
|
876
|
+
const rawDegradation = object(
|
|
877
|
+
policy.degradation,
|
|
878
|
+
`stage ${stageId} evidencePolicies.${requirementKey}.degradation`,
|
|
879
|
+
);
|
|
880
|
+
const unsupportedDegradationFields = Object.keys(rawDegradation)
|
|
881
|
+
.filter((field) => field !== "minProducers");
|
|
882
|
+
if (unsupportedDegradationFields.length) {
|
|
883
|
+
throw new Error(
|
|
884
|
+
`stage ${stageId} evidencePolicies.${requirementKey}.degradation contains unsupported fields: ${unsupportedDegradationFields.join(", ")}`,
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
const degradedMin = rawDegradation.minProducers;
|
|
888
|
+
if (
|
|
889
|
+
!Number.isInteger(degradedMin)
|
|
890
|
+
|| (degradedMin as number) < 1
|
|
891
|
+
|| (degradedMin as number) >= (minProducers as number)
|
|
892
|
+
) {
|
|
893
|
+
throw new Error(
|
|
894
|
+
`stage ${stageId} evidencePolicies.${requirementKey}.degradation.minProducers must be at least 1 and lower than minProducers`,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
degradation = { minProducers: degradedMin as number };
|
|
898
|
+
if (degradation.minProducers < 2) {
|
|
899
|
+
// Warning, not a hard failure: a floor of 1 is legal for
|
|
900
|
+
// non-independent requirements, but operators should see that one
|
|
901
|
+
// producer can satisfy this degraded peer-reply quorum.
|
|
902
|
+
warn(
|
|
903
|
+
`workflow ${workflowId} stage ${stageId} evidence policy ${requirementKey}: degradation.minProducers is ${degradation.minProducers} (< 2); a single producer can satisfy the degraded peer-reply quorum`,
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
policies.set(requirementKey, {
|
|
908
|
+
kind: "peer-reply",
|
|
909
|
+
minProducers: minProducers as number,
|
|
910
|
+
eligibleAgents,
|
|
911
|
+
acceptedStatuses: ["replied"],
|
|
912
|
+
...(degradation ? { degradation } : {}),
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
return policies.size ? Object.fromEntries(policies) : undefined;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
export function parseWorkflowDefinitions(
|
|
919
|
+
raw: string | undefined,
|
|
920
|
+
environment: Record<string, string | undefined> = process.env,
|
|
921
|
+
onWarning?: (warning: string) => void,
|
|
922
|
+
): WebhookWorkflowDefinition[] {
|
|
923
|
+
if (!raw?.trim()) return [];
|
|
924
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
925
|
+
if (!Array.isArray(parsed)) throw new Error("KXM_WEBHOOK_WORKFLOWS must be a JSON array");
|
|
926
|
+
const warn = (message: string): void => { onWarning?.(message); };
|
|
927
|
+
const ids = new Set<string>();
|
|
928
|
+
return parsed.map((entry, definitionIndex) => {
|
|
929
|
+
const value = object(entry, `workflow ${definitionIndex}`);
|
|
930
|
+
const id = requireString(value.id, "workflow.id", { max: 64 });
|
|
931
|
+
if (ids.has(id)) throw new Error(`duplicate workflow id: ${id}`);
|
|
932
|
+
ids.add(id);
|
|
933
|
+
const source = value.source ?? "generic";
|
|
934
|
+
if (source !== "jira" && source !== "github" && source !== "generic") {
|
|
935
|
+
throw new Error(`workflow ${id} source must be jira, github, or generic`);
|
|
936
|
+
}
|
|
937
|
+
const delivery = value.delivery ?? "followUp";
|
|
938
|
+
if (delivery !== "steer" && delivery !== "followUp") {
|
|
939
|
+
throw new Error(`workflow ${id} delivery must be steer or followUp`);
|
|
940
|
+
}
|
|
941
|
+
const secretEnv = value.secretEnv === undefined
|
|
942
|
+
? undefined
|
|
943
|
+
: requireString(value.secretEnv, "workflow.secretEnv", { max: 128 });
|
|
944
|
+
if (value.secret !== undefined && secretEnv) {
|
|
945
|
+
throw new Error(`workflow ${id} must configure only one of secret or secretEnv`);
|
|
946
|
+
}
|
|
947
|
+
const secret = requireString(secretEnv ? environment[secretEnv] : value.secret, "workflow.secret", { max: 512 });
|
|
948
|
+
if (secret.length < 16) throw new Error(`workflow ${id} secret must contain at least 16 characters`);
|
|
949
|
+
const signalSecretEnv = value.signalSecretEnv === undefined
|
|
950
|
+
? undefined
|
|
951
|
+
: requireString(value.signalSecretEnv, "workflow.signalSecretEnv", { max: 128 });
|
|
952
|
+
if (value.signalSecret !== undefined && signalSecretEnv) {
|
|
953
|
+
throw new Error(`workflow ${id} must configure only one of signalSecret or signalSecretEnv`);
|
|
954
|
+
}
|
|
955
|
+
const signalSecret = signalSecretEnv
|
|
956
|
+
? requireString(environment[signalSecretEnv], "workflow.signalSecret", { max: 512 })
|
|
957
|
+
: value.signalSecret === undefined
|
|
958
|
+
? undefined
|
|
959
|
+
: requireString(value.signalSecret, "workflow.signalSecret", { max: 512 });
|
|
960
|
+
if (signalSecret && signalSecret.length < 16) {
|
|
961
|
+
throw new Error(`workflow ${id} signalSecret must contain at least 16 characters`);
|
|
962
|
+
}
|
|
963
|
+
const target = requireString(value.target, "workflow.target", { max: 80 });
|
|
964
|
+
const targetName = target.toLowerCase();
|
|
965
|
+
if (!Array.isArray(value.stages) || value.stages.length === 0 || value.stages.length > 32) {
|
|
966
|
+
throw new Error(`workflow ${id} must define between 1 and 32 stages`);
|
|
967
|
+
}
|
|
968
|
+
const stageIds = new Set<string>();
|
|
969
|
+
const stages = value.stages.map((stageEntry, stageIndex) => {
|
|
970
|
+
const stage = object(stageEntry, `workflow ${id} stage ${stageIndex}`);
|
|
971
|
+
const stageId = requireString(stage.id, "stage.id", { max: 64 });
|
|
972
|
+
if (stageIds.has(stageId)) throw new Error(`duplicate stage id ${stageId} in workflow ${id}`);
|
|
973
|
+
stageIds.add(stageId);
|
|
974
|
+
const maxAttempts = stage.maxAttempts ?? 3;
|
|
975
|
+
if (!Number.isInteger(maxAttempts) || (maxAttempts as number) < 1 || (maxAttempts as number) > 20) {
|
|
976
|
+
throw new Error(`stage ${stageId} maxAttempts must be an integer between 1 and 20`);
|
|
977
|
+
}
|
|
978
|
+
const area = stage.area
|
|
979
|
+
? requireString(stage.area, "stage.area", { max: 24 }) as ImprovementArea
|
|
980
|
+
: undefined;
|
|
981
|
+
if (area && !["harness", "gates", "implementation", "workflow", "documentation", "security", "other"].includes(area)) {
|
|
982
|
+
throw new Error(`stage ${stageId} area is invalid`);
|
|
983
|
+
}
|
|
984
|
+
const requiredEvidence = stringArray(stage.requiredEvidence ?? [], "stage.requiredEvidence")
|
|
985
|
+
.map((requirement, requirementIndex) => canonicalWorkflowEvidenceKey(
|
|
986
|
+
requireString(requirement, `stage.requiredEvidence[${requirementIndex}]`, { max: 128 }),
|
|
987
|
+
));
|
|
988
|
+
if (requiredEvidence.length > 32) throw new Error(`stage ${stageId} may require at most 32 evidence keys`);
|
|
989
|
+
if (new Set(requiredEvidence).size !== requiredEvidence.length) {
|
|
990
|
+
throw new Error(`stage ${stageId} requiredEvidence keys must be unique`);
|
|
991
|
+
}
|
|
992
|
+
const evidencePolicies = parseWorkflowEvidencePolicies(
|
|
993
|
+
stage.evidencePolicies,
|
|
994
|
+
stageId,
|
|
995
|
+
requiredEvidence,
|
|
996
|
+
id,
|
|
997
|
+
targetName,
|
|
998
|
+
warn,
|
|
999
|
+
);
|
|
1000
|
+
const on = parseOutcomeMap(stageId, stage.on);
|
|
1001
|
+
const stageMaxTransitions = stage.maxTransitions;
|
|
1002
|
+
if (stageMaxTransitions !== undefined
|
|
1003
|
+
&& (!Number.isInteger(stageMaxTransitions) || (stageMaxTransitions as number) < 1 || (stageMaxTransitions as number) > 100)) {
|
|
1004
|
+
throw new Error(`stage ${stageId} maxTransitions must be an integer between 1 and 100`);
|
|
1005
|
+
}
|
|
1006
|
+
return {
|
|
1007
|
+
id: stageId,
|
|
1008
|
+
label: requireString(stage.label ?? stageId, "stage.label", { max: 128 }),
|
|
1009
|
+
instructions: requireString(stage.instructions, "stage.instructions", { max: 4_000 }),
|
|
1010
|
+
requiredEvidence,
|
|
1011
|
+
maxAttempts: maxAttempts as number,
|
|
1012
|
+
...(area ? { area } : {}),
|
|
1013
|
+
...(evidencePolicies ? { evidencePolicies } : {}),
|
|
1014
|
+
...(on ? { on } : {}),
|
|
1015
|
+
...(stageMaxTransitions !== undefined ? { maxTransitions: stageMaxTransitions as number } : {}),
|
|
1016
|
+
};
|
|
1017
|
+
});
|
|
1018
|
+
const definitionMaxTransitions = value.maxTransitions as number | undefined;
|
|
1019
|
+
if (definitionMaxTransitions !== undefined) validateWorkflowTransitions({ id, stages: stages as WorkflowStageDefinition[], maxTransitions: definitionMaxTransitions });
|
|
1020
|
+
else validateWorkflowTransitions({ id, stages: stages as WorkflowStageDefinition[] });
|
|
1021
|
+
const stageIdSet = new Set(stages.map((stage) => stage.id));
|
|
1022
|
+
const parseOracleConfig = (raw: unknown, field: string): WorkflowOracleConfig | undefined => {
|
|
1023
|
+
if (raw === undefined) return undefined;
|
|
1024
|
+
const candidate = object(raw, `workflow ${id} ${field}`);
|
|
1025
|
+
const stageId = requireString(candidate.stageId, `workflow ${id} ${field}.stageId`, { max: 64 });
|
|
1026
|
+
if (!stageIdSet.has(stageId)) {
|
|
1027
|
+
throw new Error(`workflow ${id} ${field}.stageId references unknown stage ${stageId}`);
|
|
1028
|
+
}
|
|
1029
|
+
const evidenceKey = canonicalWorkflowEvidenceKey(requireString(candidate.evidenceKey, `workflow ${id} ${field}.evidenceKey`, { max: 128 }));
|
|
1030
|
+
return { stageId, evidenceKey };
|
|
1031
|
+
};
|
|
1032
|
+
const reproOracle = parseOracleConfig(value.reproOracle, "reproOracle");
|
|
1033
|
+
const planHash = parseOracleConfig(value.planHash, "planHash");
|
|
1034
|
+
let requirePlanHash: string[] | undefined;
|
|
1035
|
+
if (value.requirePlanHash !== undefined) {
|
|
1036
|
+
const required = stringArray(value.requirePlanHash, `workflow ${id} requirePlanHash`);
|
|
1037
|
+
for (const stageId of required) {
|
|
1038
|
+
if (!stageIdSet.has(stageId)) {
|
|
1039
|
+
throw new Error(`workflow ${id} requirePlanHash references unknown stage ${stageId}`);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
requirePlanHash = [...new Set(required)].sort();
|
|
1043
|
+
}
|
|
1044
|
+
let filter: WebhookWorkflowDefinition["filter"];
|
|
1045
|
+
if (value.filter !== undefined) {
|
|
1046
|
+
const candidate = object(value.filter, `workflow ${id} filter`);
|
|
1047
|
+
filter = {
|
|
1048
|
+
path: requireString(candidate.path, "filter.path", { max: 256 }),
|
|
1049
|
+
equals: requireString(candidate.equals, "filter.equals", { max: 512 }),
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
if (
|
|
1053
|
+
value.ttlMs !== undefined
|
|
1054
|
+
&& (!Number.isInteger(value.ttlMs)
|
|
1055
|
+
|| (value.ttlMs as number) < MIN_MESSAGE_TTL_MS
|
|
1056
|
+
|| (value.ttlMs as number) > MAX_MESSAGE_TTL_MS)
|
|
1057
|
+
) {
|
|
1058
|
+
throw new Error(`workflow ${id} ttlMs must be an integer between ${MIN_MESSAGE_TTL_MS} and ${MAX_MESSAGE_TTL_MS}`);
|
|
1059
|
+
}
|
|
1060
|
+
return {
|
|
1061
|
+
id,
|
|
1062
|
+
source,
|
|
1063
|
+
project: requireString(value.project, "workflow.project", { max: 128 }),
|
|
1064
|
+
target,
|
|
1065
|
+
secret,
|
|
1066
|
+
...(signalSecret ? { signalSecret } : {}),
|
|
1067
|
+
...(value.event ? { event: requireString(value.event, "workflow.event", { max: 128 }) } : {}),
|
|
1068
|
+
...(filter ? { filter } : {}),
|
|
1069
|
+
delivery,
|
|
1070
|
+
...(value.ttlMs !== undefined ? { ttlMs: value.ttlMs as number } : {}),
|
|
1071
|
+
...(value.maxTransitions !== undefined ? { maxTransitions: value.maxTransitions as number } : {}),
|
|
1072
|
+
...(reproOracle ? { reproOracle } : {}),
|
|
1073
|
+
...(planHash ? { planHash } : {}),
|
|
1074
|
+
...(requirePlanHash ? { requirePlanHash } : {}),
|
|
1075
|
+
promptTemplate: requireString(value.promptTemplate, "workflow.promptTemplate", { max: 20_000 }),
|
|
1076
|
+
stages,
|
|
1077
|
+
};
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** Validate typed transitions at definition load: targets must exist,
|
|
1082
|
+
* forward transitions may never skip an intermediate stage (approvals and
|
|
1083
|
+
* gates cannot be bypassed), and back-edges require global and bounded
|
|
1084
|
+
* budgets. */
|
|
1085
|
+
export function validateWorkflowTransitions(definition: Pick<WebhookWorkflowDefinition, "id" | "stages"> & { maxTransitions?: number }): void {
|
|
1086
|
+
const stageIndex = new Map(definition.stages.map((stage, index) => [stage.id, index]));
|
|
1087
|
+
let hasBackEdge = false;
|
|
1088
|
+
for (const stage of definition.stages) {
|
|
1089
|
+
if (!stage.on) continue;
|
|
1090
|
+
for (const [outcome, rawValue] of Object.entries(stage.on)) {
|
|
1091
|
+
if (!outcome.trim()) throw new Error(`stage ${stage.id} declares an empty outcome key`);
|
|
1092
|
+
const rule = normalizeOutcomeValue(rawValue, `stage ${stage.id} on.${outcome}`);
|
|
1093
|
+
if (rule.target === WORKFLOW_TERMINAL_TARGET) continue;
|
|
1094
|
+
const targetIndex = stageIndex.get(rule.target);
|
|
1095
|
+
if (targetIndex === undefined) {
|
|
1096
|
+
throw new Error(`stage ${stage.id} on.${outcome} targets unknown stage ${rule.target}`);
|
|
1097
|
+
}
|
|
1098
|
+
const sourceIndex = stageIndex.get(stage.id)!;
|
|
1099
|
+
if (targetIndex > sourceIndex + 1) {
|
|
1100
|
+
throw new Error(
|
|
1101
|
+
`stage ${stage.id} on.${outcome} skips intermediate stages by targeting ${rule.target}; forward transitions must target the next stage so approvals and gates cannot be bypassed`,
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
if (targetIndex <= sourceIndex) hasBackEdge = true;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
if (definition.maxTransitions !== undefined
|
|
1108
|
+
&& (!Number.isInteger(definition.maxTransitions) || (definition.maxTransitions as number) < 1 || (definition.maxTransitions as number) > 200)) {
|
|
1109
|
+
throw new Error(`workflow ${definition.id} maxTransitions must be an integer between 1 and 200`);
|
|
1110
|
+
}
|
|
1111
|
+
if (hasBackEdge && (definition.maxTransitions === undefined || definition.maxTransitions < 1)) {
|
|
1112
|
+
throw new Error(`workflow ${definition.id} declares a back-edge but no maxTransitions budget; cycles without budgets are rejected`);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function parseOutcomeMap(stageId: string, raw: unknown): WorkflowOutcomeMap | undefined {
|
|
1117
|
+
if (raw === undefined) return undefined;
|
|
1118
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1119
|
+
throw new Error(`stage ${stageId} on must be an object`);
|
|
1120
|
+
}
|
|
1121
|
+
const map: WorkflowOutcomeMap = {};
|
|
1122
|
+
for (const [outcome, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
1123
|
+
map[outcome] = normalizeOutcomeValue(value as WorkflowOutcomeValue, `stage ${stageId} on.${outcome}`);
|
|
1124
|
+
}
|
|
1125
|
+
return map;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
export function valueAtPath(payload: unknown, path: string): unknown {
|
|
1129
|
+
let current = payload;
|
|
1130
|
+
for (const part of path.split(".")) {
|
|
1131
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) return undefined;
|
|
1132
|
+
current = (current as Record<string, unknown>)[part];
|
|
1133
|
+
}
|
|
1134
|
+
return current;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
export function renderWorkflowPrompt(template: string, payload: unknown): string {
|
|
1138
|
+
return template.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_match, path: string) => {
|
|
1139
|
+
const value = valueAtPath(payload, path);
|
|
1140
|
+
if (value === undefined || value === null) return "";
|
|
1141
|
+
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function canonicalizeForHash(value: unknown): unknown {
|
|
1146
|
+
if (Array.isArray(value)) return value.map(canonicalizeForHash);
|
|
1147
|
+
if (value && typeof value === "object") {
|
|
1148
|
+
return Object.fromEntries(
|
|
1149
|
+
Object.entries(value as Record<string, unknown>)
|
|
1150
|
+
.filter(([, candidate]) => candidate !== undefined)
|
|
1151
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
1152
|
+
.map(([key, candidate]) => [key, canonicalizeForHash(candidate)]),
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
return value;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** Stable, secret-free JSON serialization of a workflow definition. Object
|
|
1159
|
+
* keys are sorted recursively and undefined fields are dropped, so hash
|
|
1160
|
+
* equality is semantic and credential rotation does not create false drift. */
|
|
1161
|
+
export function canonicalWorkflowDefinitionJson(definition: WebhookWorkflowDefinition): string {
|
|
1162
|
+
const { secret: _secret, signalSecret: _signalSecret, ...publicDefinition } = definition;
|
|
1163
|
+
return JSON.stringify(canonicalizeForHash(publicDefinition));
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** sha256 of the canonical workflow definition JSON. Stamped on runs at
|
|
1167
|
+
* creation so a later resume or audit can detect that the definition changed
|
|
1168
|
+
* underneath the run. */
|
|
1169
|
+
export function workflowDefinitionHash(definition: WebhookWorkflowDefinition): string {
|
|
1170
|
+
return createHash("sha256").update(canonicalWorkflowDefinitionJson(definition), "utf8").digest("hex");
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** Resolve a declared transition rule for a stage outcome, or undefined when
|
|
1174
|
+
* the outcome keeps the v0.4 default edges. */
|
|
1175
|
+
function resolveOutcomeRule(stage: WorkflowStageState, outcomeKey: string): WorkflowTransitionRule | undefined {
|
|
1176
|
+
const raw = stage.on?.[outcomeKey];
|
|
1177
|
+
return raw === undefined ? undefined : normalizeOutcomeValue(raw, `stage ${stage.id} on.${outcomeKey}`);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/** Transition budgets are derived from the durable journal: global count is
|
|
1181
|
+
* the record count, per-stage counts filter by source stage, per-edge counts
|
|
1182
|
+
* filter by source+outcome+target. Restart-safe with no extra state. */
|
|
1183
|
+
function transitionCounts(
|
|
1184
|
+
run: WorkflowRun,
|
|
1185
|
+
fromStage: string,
|
|
1186
|
+
outcome: string,
|
|
1187
|
+
target: string,
|
|
1188
|
+
): { total: number; fromStage: number; forEdge: number } {
|
|
1189
|
+
const records = run.transitions ?? [];
|
|
1190
|
+
return {
|
|
1191
|
+
total: records.length,
|
|
1192
|
+
fromStage: records.filter((record) => record.fromStage === fromStage).length,
|
|
1193
|
+
forEdge: records.filter((record) => record.fromStage === fromStage && record.outcome === outcome && record.toStage === target).length,
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function recordTransition(
|
|
1198
|
+
run: WorkflowRun,
|
|
1199
|
+
fromStage: string,
|
|
1200
|
+
rule: WorkflowTransitionRule,
|
|
1201
|
+
outcome: string,
|
|
1202
|
+
attempt: number,
|
|
1203
|
+
evidenceKeys: string[],
|
|
1204
|
+
timestamp: string,
|
|
1205
|
+
): WorkflowTransitionRecord {
|
|
1206
|
+
const record: WorkflowTransitionRecord = {
|
|
1207
|
+
id: newId("trans"),
|
|
1208
|
+
fromStage,
|
|
1209
|
+
toStage: rule.target,
|
|
1210
|
+
outcome,
|
|
1211
|
+
attempt,
|
|
1212
|
+
evidenceKeys,
|
|
1213
|
+
at: timestamp,
|
|
1214
|
+
};
|
|
1215
|
+
run.transitions = [...(run.transitions ?? []), record];
|
|
1216
|
+
return record;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** Enter (or re-enter) a stage with attempt-bound state: previous evidence
|
|
1220
|
+
* never satisfies a later attempt unless policy explicitly allows it. */
|
|
1221
|
+
function enterStage(run: WorkflowRun, stage: WorkflowStageState, timestamp: string): void {
|
|
1222
|
+
stage.status = "in_progress";
|
|
1223
|
+
stage.attempts = 0;
|
|
1224
|
+
stage.evidence = {};
|
|
1225
|
+
stage.verifiedEvidence = {};
|
|
1226
|
+
stage.startedAt = timestamp;
|
|
1227
|
+
stage.updatedAt = timestamp;
|
|
1228
|
+
delete stage.summary;
|
|
1229
|
+
run.currentStage = stage.id;
|
|
1230
|
+
run.updatedAt = timestamp;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** Execute a declared transition with global/per-stage/per-edge budgets.
|
|
1234
|
+
* Budget exhaustion fails safely: the run fails with actionable retrospective
|
|
1235
|
+
* evidence rather than looping unbounded. */
|
|
1236
|
+
function takeDeclaredTransition(
|
|
1237
|
+
run: WorkflowRun,
|
|
1238
|
+
stage: WorkflowStageState,
|
|
1239
|
+
rule: WorkflowTransitionRule,
|
|
1240
|
+
outcome: string,
|
|
1241
|
+
summary: string,
|
|
1242
|
+
attempt: number,
|
|
1243
|
+
timestamp: string,
|
|
1244
|
+
evidenceKeys: string[],
|
|
1245
|
+
): { retry: boolean; completed: boolean; run: WorkflowRun; transition?: WorkflowTransitionRecord; exhausted?: boolean } {
|
|
1246
|
+
const definitionBudget = run.maxTransitions;
|
|
1247
|
+
const counts = transitionCounts(run, stage.id, outcome, rule.target);
|
|
1248
|
+
const edgeExhausted = rule.maxTransitions !== undefined && counts.forEdge >= rule.maxTransitions;
|
|
1249
|
+
const stageExhausted = stage.maxTransitions !== undefined && counts.fromStage >= stage.maxTransitions;
|
|
1250
|
+
const globalExhausted = definitionBudget !== undefined && counts.total >= definitionBudget;
|
|
1251
|
+
if (edgeExhausted || stageExhausted || globalExhausted) {
|
|
1252
|
+
stage.completedAt = timestamp;
|
|
1253
|
+
run.status = "failed";
|
|
1254
|
+
delete run.currentStage;
|
|
1255
|
+
run.updatedAt = timestamp;
|
|
1256
|
+
return {
|
|
1257
|
+
retry: false,
|
|
1258
|
+
completed: false,
|
|
1259
|
+
run,
|
|
1260
|
+
exhausted: true,
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
const record = recordTransition(run, stage.id, rule, outcome, attempt, evidenceKeys.slice(0, 32), timestamp);
|
|
1264
|
+
if (rule.target === WORKFLOW_TERMINAL_TARGET) {
|
|
1265
|
+
stage.completedAt = timestamp;
|
|
1266
|
+
run.status = "completed";
|
|
1267
|
+
delete run.currentStage;
|
|
1268
|
+
run.completedAt = timestamp;
|
|
1269
|
+
run.updatedAt = timestamp;
|
|
1270
|
+
return { retry: false, completed: true, run, transition: record };
|
|
1271
|
+
}
|
|
1272
|
+
const target = run.stages.find((candidate) => candidate.id === rule.target);
|
|
1273
|
+
if (!target) {
|
|
1274
|
+
// Definition validation prevents this; fail closed anyway.
|
|
1275
|
+
run.status = "failed";
|
|
1276
|
+
delete run.currentStage;
|
|
1277
|
+
return { retry: false, completed: false, run, exhausted: true };
|
|
1278
|
+
}
|
|
1279
|
+
stage.summary = summary;
|
|
1280
|
+
enterStage(run, target, timestamp);
|
|
1281
|
+
return { retry: false, completed: false, run, transition: record };
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
/** sha256 over the canonical values of one evidence key. */
|
|
1286
|
+
export function evidenceValueSha256(evidence: WorkflowEvidence | WorkflowEvidenceInput, key: string): string | undefined {
|
|
1287
|
+
const values = evidence[canonicalWorkflowEvidenceKey(key)];
|
|
1288
|
+
if (!values || values.length === 0) return undefined;
|
|
1289
|
+
return createHash("sha256").update([...values].sort().join("\n"), "utf8").digest("hex");
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/** Enforce the immutable reproduction oracle and plan-hash prerequisites.
|
|
1293
|
+
* Called before a checkpoint mutates the run. */
|
|
1294
|
+
function enforceOracles(
|
|
1295
|
+
run: WorkflowRun,
|
|
1296
|
+
stageId: string,
|
|
1297
|
+
evidence: WorkflowEvidence,
|
|
1298
|
+
): void {
|
|
1299
|
+
if (run.oracle) {
|
|
1300
|
+
const presented = evidenceValueSha256(evidence, run.oracle.evidenceKey);
|
|
1301
|
+
if (presented !== undefined && presented !== run.oracle.sha256) {
|
|
1302
|
+
throw new ProtocolError(
|
|
1303
|
+
400,
|
|
1304
|
+
`evidence ${run.oracle.evidenceKey} does not match the immutable reproduction oracle captured at ${run.oracle.capturedAt}; the confirmed reproduction may not be weakened`,
|
|
1305
|
+
"weakened_reproduction",
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
if (run.requirePlanHash?.includes(stageId) && !run.planHash) {
|
|
1310
|
+
throw new ProtocolError(
|
|
1311
|
+
400,
|
|
1312
|
+
`stage ${stageId} requires an approved plan hash before it can checkpoint`,
|
|
1313
|
+
"plan_hash_required",
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** Capture oracle/plan-hash snapshots when their defining stage passes. */
|
|
1319
|
+
function captureOracles(run: WorkflowRun, stageId: string, evidence: WorkflowEvidence, timestamp: string): void {
|
|
1320
|
+
if (run.reproOracle?.stageId === stageId) {
|
|
1321
|
+
const sha256 = evidenceValueSha256(evidence, run.reproOracle.evidenceKey);
|
|
1322
|
+
if (sha256 !== undefined) {
|
|
1323
|
+
run.oracle = { evidenceKey: run.reproOracle.evidenceKey, sha256, capturedAt: timestamp };
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
if (run.planHashConfig?.stageId === stageId) {
|
|
1327
|
+
const sha256 = evidenceValueSha256(evidence, run.planHashConfig.evidenceKey);
|
|
1328
|
+
if (sha256 !== undefined) {
|
|
1329
|
+
run.planHash = { evidenceKey: run.planHashConfig.evidenceKey, sha256, capturedAt: timestamp };
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
export function checkpointRun(
|
|
1335
|
+
run: WorkflowRun,
|
|
1336
|
+
stageId: string,
|
|
1337
|
+
status: WorkflowCheckpointStatus,
|
|
1338
|
+
summary: string,
|
|
1339
|
+
evidence: WorkflowEvidenceInput,
|
|
1340
|
+
timestamp: string,
|
|
1341
|
+
verifiedEvidence: WorkflowVerifiedEvidence = {},
|
|
1342
|
+
outcome?: string,
|
|
1343
|
+
): { retry: boolean; completed: boolean; run: WorkflowRun; degraded?: boolean; transition?: WorkflowTransitionRecord; exhausted?: boolean } {
|
|
1344
|
+
if (run.status !== "running") throw new ProtocolError(409, `workflow is ${run.status}`, "workflow_terminal");
|
|
1345
|
+
const stage = run.stages.find((candidate) => candidate.id === stageId);
|
|
1346
|
+
if (!stage) throw new ProtocolError(404, `workflow stage not found: ${stageId}`, "workflow_stage_not_found");
|
|
1347
|
+
if (stage.id !== run.currentStage || stage.status !== "in_progress") {
|
|
1348
|
+
throw new ProtocolError(409, `stage ${stageId} is not currently active`, "workflow_stage_out_of_order");
|
|
1349
|
+
}
|
|
1350
|
+
const accumulatedEvidence = mergeWorkflowEvidence(stage.evidence, evidence);
|
|
1351
|
+
const accumulatedVerifiedEvidence = mergeVerifiedWorkflowEvidence(stage.verifiedEvidence, verifiedEvidence);
|
|
1352
|
+
enforceOracles(run, stageId, accumulatedEvidence);
|
|
1353
|
+
const peerStatuses = status === "passed"
|
|
1354
|
+
? requireCompleteEvidence(stage, accumulatedEvidence, accumulatedVerifiedEvidence, run.id)
|
|
1355
|
+
: [];
|
|
1356
|
+
const degradedRequirements = peerStatuses.filter((peerStatus) => peerStatus.degraded);
|
|
1357
|
+
stage.attempts += 1;
|
|
1358
|
+
stage.summary = summary;
|
|
1359
|
+
// Failed or warning evidence remains available in the journal, but it is
|
|
1360
|
+
// intentionally not trusted to satisfy a later passing attempt.
|
|
1361
|
+
if (status === "passed") {
|
|
1362
|
+
stage.evidence = accumulatedEvidence;
|
|
1363
|
+
if (Object.keys(accumulatedVerifiedEvidence).length) stage.verifiedEvidence = accumulatedVerifiedEvidence;
|
|
1364
|
+
captureOracles(run, stageId, accumulatedEvidence, timestamp);
|
|
1365
|
+
if (degradedRequirements.length) {
|
|
1366
|
+
stage.degraded = true;
|
|
1367
|
+
stage.degradedRequirements = degradedRequirements.map((peerStatus) => peerStatus.requirementKey);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
stage.updatedAt = timestamp;
|
|
1371
|
+
run.updatedAt = timestamp;
|
|
1372
|
+
if (status !== "passed") {
|
|
1373
|
+
stage.status = status;
|
|
1374
|
+
if (stage.attempts >= stage.maxAttempts) {
|
|
1375
|
+
stage.completedAt = timestamp;
|
|
1376
|
+
run.status = "failed";
|
|
1377
|
+
delete run.currentStage;
|
|
1378
|
+
return { retry: false, completed: false, run };
|
|
1379
|
+
}
|
|
1380
|
+
// Declared failure outcomes create typed transitions (bounded); the
|
|
1381
|
+
// outcome identity defaults to the checkpoint status.
|
|
1382
|
+
const outcomeKey = outcome ?? status;
|
|
1383
|
+
const rule = resolveOutcomeRule(stage, outcomeKey);
|
|
1384
|
+
if (rule && stage.attempts < stage.maxAttempts) {
|
|
1385
|
+
const result = takeDeclaredTransition(run, stage, rule, outcomeKey, summary, stage.attempts, timestamp, Object.keys(accumulatedEvidence));
|
|
1386
|
+
// Re-arm the source stage for future re-entry (the durable journal
|
|
1387
|
+
// already captured the failed attempt). Self-edges stay in_progress.
|
|
1388
|
+
if (result.transition !== undefined && !result.completed && rule.target !== stage.id) {
|
|
1389
|
+
stage.status = "pending";
|
|
1390
|
+
}
|
|
1391
|
+
return result;
|
|
1392
|
+
}
|
|
1393
|
+
stage.status = "in_progress";
|
|
1394
|
+
return { retry: true, completed: false, run };
|
|
1395
|
+
}
|
|
1396
|
+
stage.status = "passed";
|
|
1397
|
+
stage.completedAt = timestamp;
|
|
1398
|
+
const passedRule = resolveOutcomeRule(stage, outcome ?? "passed");
|
|
1399
|
+
if (passedRule) {
|
|
1400
|
+
return takeDeclaredTransition(run, stage, passedRule, outcome ?? "passed", summary, stage.attempts, timestamp, Object.keys(accumulatedEvidence));
|
|
1401
|
+
}
|
|
1402
|
+
const next = run.stages.find((candidate) => candidate.status === "pending");
|
|
1403
|
+
if (next) {
|
|
1404
|
+
next.status = "in_progress";
|
|
1405
|
+
next.startedAt = timestamp;
|
|
1406
|
+
next.updatedAt = timestamp;
|
|
1407
|
+
run.currentStage = next.id;
|
|
1408
|
+
return {
|
|
1409
|
+
retry: false,
|
|
1410
|
+
completed: false,
|
|
1411
|
+
run,
|
|
1412
|
+
...(degradedRequirements.length ? { degraded: true } : {}),
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
run.status = "completed";
|
|
1416
|
+
delete run.currentStage;
|
|
1417
|
+
run.completedAt = timestamp;
|
|
1418
|
+
return {
|
|
1419
|
+
retry: false,
|
|
1420
|
+
completed: true,
|
|
1421
|
+
run,
|
|
1422
|
+
...(degradedRequirements.length ? { degraded: true } : {}),
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
export function waitForWorkflowSignal(
|
|
1427
|
+
run: WorkflowRun,
|
|
1428
|
+
stageId: string,
|
|
1429
|
+
signalKey: string,
|
|
1430
|
+
summary: string,
|
|
1431
|
+
timestamp: string,
|
|
1432
|
+
expiresAt: string,
|
|
1433
|
+
evidence: WorkflowEvidenceInput = {},
|
|
1434
|
+
verifiedEvidence: WorkflowVerifiedEvidence = {},
|
|
1435
|
+
): WorkflowRun {
|
|
1436
|
+
if (run.status !== "running") throw new ProtocolError(409, `workflow is ${run.status}`, "workflow_not_running");
|
|
1437
|
+
const stage = run.stages.find((candidate) => candidate.id === stageId);
|
|
1438
|
+
if (!stage) throw new ProtocolError(404, `workflow stage not found: ${stageId}`, "workflow_stage_not_found");
|
|
1439
|
+
if (stage.id !== run.currentStage || stage.status !== "in_progress") {
|
|
1440
|
+
throw new ProtocolError(409, `stage ${stageId} is not currently active`, "workflow_stage_out_of_order");
|
|
1441
|
+
}
|
|
1442
|
+
if (Date.parse(expiresAt) <= Date.parse(timestamp)) {
|
|
1443
|
+
throw new ProtocolError(400, "workflow signal expiry must be in the future", "workflow_wait_invalid");
|
|
1444
|
+
}
|
|
1445
|
+
stage.evidence = mergeWorkflowEvidence(stage.evidence, evidence);
|
|
1446
|
+
const accumulatedVerifiedEvidence = mergeVerifiedWorkflowEvidence(stage.verifiedEvidence, verifiedEvidence);
|
|
1447
|
+
if (Object.keys(accumulatedVerifiedEvidence).length) stage.verifiedEvidence = accumulatedVerifiedEvidence;
|
|
1448
|
+
stage.status = "waiting";
|
|
1449
|
+
stage.updatedAt = timestamp;
|
|
1450
|
+
run.status = "waiting";
|
|
1451
|
+
run.waiting = { stageId, signalKey, summary, createdAt: timestamp, expiresAt };
|
|
1452
|
+
run.updatedAt = timestamp;
|
|
1453
|
+
return run;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
export function resumeWorkflowFromSignal(
|
|
1457
|
+
run: WorkflowRun,
|
|
1458
|
+
signalKey: string,
|
|
1459
|
+
status: WorkflowCheckpointStatus,
|
|
1460
|
+
summary: string,
|
|
1461
|
+
evidence: WorkflowEvidenceInput,
|
|
1462
|
+
timestamp: string,
|
|
1463
|
+
): { retry: boolean; completed: boolean; run: WorkflowRun; stageId: string; degraded?: boolean } {
|
|
1464
|
+
if (run.status !== "waiting" || !run.waiting) {
|
|
1465
|
+
throw new ProtocolError(409, `workflow is ${run.status}`, "workflow_not_waiting");
|
|
1466
|
+
}
|
|
1467
|
+
if (run.waiting.signalKey !== signalKey) {
|
|
1468
|
+
throw new ProtocolError(409, `workflow is waiting for ${run.waiting.signalKey}`, "workflow_signal_mismatch");
|
|
1469
|
+
}
|
|
1470
|
+
const stageId = run.waiting.stageId;
|
|
1471
|
+
const stage = run.stages.find((candidate) => candidate.id === stageId);
|
|
1472
|
+
if (!stage || stage.id !== run.currentStage || stage.status !== "waiting") {
|
|
1473
|
+
throw new ProtocolError(409, "workflow wait state is inconsistent", "workflow_wait_inconsistent");
|
|
1474
|
+
}
|
|
1475
|
+
const accumulatedEvidence = mergeWorkflowEvidence(stage.evidence, evidence);
|
|
1476
|
+
if (status === "passed") {
|
|
1477
|
+
requireCompleteEvidence(
|
|
1478
|
+
stage,
|
|
1479
|
+
accumulatedEvidence,
|
|
1480
|
+
normalizeVerifiedWorkflowEvidence(stage.verifiedEvidence),
|
|
1481
|
+
run.id,
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
run.status = "running";
|
|
1485
|
+
stage.status = "in_progress";
|
|
1486
|
+
delete run.waiting;
|
|
1487
|
+
const result = checkpointRun(run, stageId, status, summary, evidence, timestamp);
|
|
1488
|
+
return { ...result, stageId };
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
export function approveWorkflowDegradation(
|
|
1492
|
+
run: WorkflowRun,
|
|
1493
|
+
stageId: string,
|
|
1494
|
+
requirement: string,
|
|
1495
|
+
reason: string,
|
|
1496
|
+
approvalId: string,
|
|
1497
|
+
timestamp: string,
|
|
1498
|
+
): { run: WorkflowRun; approval: WorkflowDegradationApproval; created: boolean } {
|
|
1499
|
+
if (run.status !== "running" && run.status !== "waiting") {
|
|
1500
|
+
throw new ProtocolError(409, `workflow is ${run.status}`, "workflow_terminal");
|
|
1501
|
+
}
|
|
1502
|
+
const stage = run.stages.find((candidate) => candidate.id === stageId);
|
|
1503
|
+
if (!stage) throw new ProtocolError(404, `workflow stage not found: ${stageId}`, "workflow_stage_not_found");
|
|
1504
|
+
if (
|
|
1505
|
+
stage.id !== run.currentStage
|
|
1506
|
+
|| (stage.status !== "in_progress" && stage.status !== "waiting")
|
|
1507
|
+
) {
|
|
1508
|
+
throw new ProtocolError(409, `stage ${stageId} is not currently active`, "workflow_stage_out_of_order");
|
|
1509
|
+
}
|
|
1510
|
+
const requirementKey = canonicalWorkflowEvidenceKey(requirement);
|
|
1511
|
+
const policy = stage.resolvedEvidencePolicies?.[requirementKey];
|
|
1512
|
+
if (!policy?.degradation) {
|
|
1513
|
+
throw new ProtocolError(
|
|
1514
|
+
400,
|
|
1515
|
+
`requirement ${requirementKey} does not permit degraded quorum`,
|
|
1516
|
+
"workflow_degradation_forbidden",
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
const attempt = activeWorkflowAttempt(stage);
|
|
1520
|
+
const existing = stage.degradationApprovals?.find(
|
|
1521
|
+
(candidate) => candidate.requirementKey === requirementKey && candidate.attempt === attempt,
|
|
1522
|
+
);
|
|
1523
|
+
if (existing) {
|
|
1524
|
+
if (existing.reason !== reason.trim()) {
|
|
1525
|
+
throw new ProtocolError(
|
|
1526
|
+
409,
|
|
1527
|
+
`degradation was already approved for ${requirementKey} attempt ${attempt}`,
|
|
1528
|
+
"workflow_degradation_conflict",
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
return { run, approval: existing, created: false };
|
|
1532
|
+
}
|
|
1533
|
+
const approval: WorkflowDegradationApproval = {
|
|
1534
|
+
schema: "pi-mesh.workflow-degradation-approval.v1",
|
|
1535
|
+
id: approvalId,
|
|
1536
|
+
requirementKey,
|
|
1537
|
+
attempt,
|
|
1538
|
+
policyMinProducers: policy.minProducers,
|
|
1539
|
+
approvedMinProducers: policy.degradation.minProducers,
|
|
1540
|
+
approvedBy: "kxm-admin",
|
|
1541
|
+
reason: requireString(reason, "reason", { max: 1_000 }),
|
|
1542
|
+
approvedAt: timestamp,
|
|
1543
|
+
};
|
|
1544
|
+
(stage.degradationApprovals ??= []).push(approval);
|
|
1545
|
+
stage.updatedAt = timestamp;
|
|
1546
|
+
run.updatedAt = timestamp;
|
|
1547
|
+
return { run, approval, created: true };
|
|
1548
|
+
}
|