@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,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KXM External Side-Effect Idempotency (Slice C)
|
|
3
|
+
* Ensures deterministic branching, preflight CAS checks, and immutable receipts for external mutations.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { mkdirSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
|
|
11
|
+
export const EXTERNAL_EFFECT_SCHEMA = "kxm.external-effect-receipt.v1" as const;
|
|
12
|
+
|
|
13
|
+
export type ExternalActionKind =
|
|
14
|
+
| "git-branch"
|
|
15
|
+
| "git-commit"
|
|
16
|
+
| "git-push"
|
|
17
|
+
| "pr-create"
|
|
18
|
+
| "tracker-issue"
|
|
19
|
+
| "webhook";
|
|
20
|
+
|
|
21
|
+
export type ExternalEffectStatus = "in-flight" | "committed" | "failed" | "aborted";
|
|
22
|
+
|
|
23
|
+
export interface ExternalEffectReceipt {
|
|
24
|
+
schema: typeof EXTERNAL_EFFECT_SCHEMA;
|
|
25
|
+
effectKey: string;
|
|
26
|
+
runId: string;
|
|
27
|
+
stepId: string;
|
|
28
|
+
attemptId: string;
|
|
29
|
+
actionKind: ExternalActionKind;
|
|
30
|
+
targetRef: string;
|
|
31
|
+
status: ExternalEffectStatus;
|
|
32
|
+
payloadHash: string;
|
|
33
|
+
receiptPayload: Record<string, unknown>;
|
|
34
|
+
executedAt: string;
|
|
35
|
+
completedAt?: string | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RunBranchOptions {
|
|
39
|
+
description?: string | undefined;
|
|
40
|
+
workflowId?: string | undefined;
|
|
41
|
+
issueKey?: string | undefined;
|
|
42
|
+
format?: "prefix-run" | "run-suffix" | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function slugifyBranchPart(text: string, maxLength: number = 40): string {
|
|
46
|
+
const cleaned = text
|
|
47
|
+
.toLowerCase()
|
|
48
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
49
|
+
.replace(/^-+|-+$/g, "");
|
|
50
|
+
if (cleaned.length <= maxLength) return cleaned;
|
|
51
|
+
return cleaned.slice(0, maxLength).replace(/-+$/, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function deterministicRunBranch(
|
|
55
|
+
runId: string,
|
|
56
|
+
descriptionOrOptions?: string | RunBranchOptions,
|
|
57
|
+
): string {
|
|
58
|
+
const cleanId = runId.replace(/^run_/, "");
|
|
59
|
+
if (!descriptionOrOptions) {
|
|
60
|
+
return `kxm/run-${cleanId}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const options: RunBranchOptions =
|
|
64
|
+
typeof descriptionOrOptions === "string"
|
|
65
|
+
? { description: descriptionOrOptions }
|
|
66
|
+
: descriptionOrOptions;
|
|
67
|
+
|
|
68
|
+
const parts: string[] = [];
|
|
69
|
+
if (options.workflowId) {
|
|
70
|
+
const wf = slugifyBranchPart(options.workflowId, 25);
|
|
71
|
+
if (wf) parts.push(wf);
|
|
72
|
+
}
|
|
73
|
+
if (options.issueKey) {
|
|
74
|
+
const issue = slugifyBranchPart(options.issueKey, 20);
|
|
75
|
+
if (issue && !parts.some((p) => p.includes(issue))) parts.push(issue);
|
|
76
|
+
}
|
|
77
|
+
if (options.description) {
|
|
78
|
+
const desc = slugifyBranchPart(options.description, 35);
|
|
79
|
+
if (desc && !parts.some((p) => p.includes(desc))) parts.push(desc);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const slug = parts.join("-").slice(0, 50).replace(/-+$/, "");
|
|
83
|
+
if (!slug) {
|
|
84
|
+
return `kxm/run-${cleanId}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (options.format === "prefix-run") {
|
|
88
|
+
return `kxm/${slug}-run-${cleanId}`;
|
|
89
|
+
}
|
|
90
|
+
return `kxm/run-${cleanId}-${slug}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function computeEffectKey(
|
|
94
|
+
runId: string,
|
|
95
|
+
stepId: string,
|
|
96
|
+
actionKind: ExternalActionKind,
|
|
97
|
+
targetRef: string,
|
|
98
|
+
): string {
|
|
99
|
+
const raw = `${runId}:${stepId}:${actionKind}:${targetRef}`;
|
|
100
|
+
return `eff_${createHash("sha256").update(raw).digest("hex").slice(0, 16)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class ExternalEffectsLedger {
|
|
104
|
+
private db: DatabaseSync;
|
|
105
|
+
|
|
106
|
+
constructor(dbPath: string = ":memory:") {
|
|
107
|
+
if (dbPath !== ":memory:") {
|
|
108
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
109
|
+
}
|
|
110
|
+
this.db = new DatabaseSync(dbPath);
|
|
111
|
+
this.initSchema();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private initSchema(): void {
|
|
115
|
+
this.db.exec(`
|
|
116
|
+
CREATE TABLE IF NOT EXISTS external_effects (
|
|
117
|
+
effect_key TEXT PRIMARY KEY,
|
|
118
|
+
run_id TEXT NOT NULL,
|
|
119
|
+
step_id TEXT NOT NULL,
|
|
120
|
+
attempt_id TEXT NOT NULL,
|
|
121
|
+
action_kind TEXT NOT NULL,
|
|
122
|
+
target_ref TEXT NOT NULL,
|
|
123
|
+
status TEXT NOT NULL,
|
|
124
|
+
payload_hash TEXT NOT NULL,
|
|
125
|
+
receipt_payload TEXT NOT NULL,
|
|
126
|
+
executed_at TEXT NOT NULL,
|
|
127
|
+
completed_at TEXT
|
|
128
|
+
);
|
|
129
|
+
CREATE INDEX IF NOT EXISTS idx_ext_effects_run ON external_effects(run_id);
|
|
130
|
+
`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Preflight Check-And-Set (CAS):
|
|
135
|
+
* Guarantees that an external mutation is only initiated if not already committed or currently in-flight.
|
|
136
|
+
*/
|
|
137
|
+
claimEffect(input: {
|
|
138
|
+
runId: string;
|
|
139
|
+
stepId: string;
|
|
140
|
+
attemptId: string;
|
|
141
|
+
actionKind: ExternalActionKind;
|
|
142
|
+
targetRef: string;
|
|
143
|
+
payload?: Record<string, unknown> | undefined;
|
|
144
|
+
timeoutMs?: number | undefined;
|
|
145
|
+
}): { ok: true; effectKey: string } | { ok: false; error: string; existing?: ExternalEffectReceipt } {
|
|
146
|
+
const effectKey = computeEffectKey(input.runId, input.stepId, input.actionKind, input.targetRef);
|
|
147
|
+
const now = new Date().toISOString();
|
|
148
|
+
const payloadStr = JSON.stringify(input.payload ?? {});
|
|
149
|
+
const payloadHash = createHash("sha256").update(payloadStr).digest("hex");
|
|
150
|
+
|
|
151
|
+
const existing = this.getReceipt(effectKey);
|
|
152
|
+
if (existing) {
|
|
153
|
+
if (existing.status === "committed") {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: `effect_already_committed: ${input.actionKind} on ${input.targetRef} was already committed`,
|
|
157
|
+
existing,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (existing.status === "in-flight") {
|
|
161
|
+
const timeout = input.timeoutMs ?? 60000;
|
|
162
|
+
const elapsed = Date.now() - Date.parse(existing.executedAt);
|
|
163
|
+
if (elapsed < timeout) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
error: `effect_in_flight: ${input.actionKind} on ${input.targetRef} is currently executing`,
|
|
167
|
+
existing,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// If timed out, allow reclaim by updating status
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const stmt = this.db.prepare(`
|
|
175
|
+
INSERT INTO external_effects (
|
|
176
|
+
effect_key, run_id, step_id, attempt_id, action_kind, target_ref,
|
|
177
|
+
status, payload_hash, receipt_payload, executed_at
|
|
178
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
179
|
+
ON CONFLICT(effect_key) DO UPDATE SET
|
|
180
|
+
attempt_id = excluded.attempt_id,
|
|
181
|
+
status = 'in-flight',
|
|
182
|
+
executed_at = excluded.executed_at,
|
|
183
|
+
payload_hash = excluded.payload_hash,
|
|
184
|
+
receipt_payload = excluded.receipt_payload
|
|
185
|
+
`);
|
|
186
|
+
|
|
187
|
+
stmt.run(
|
|
188
|
+
effectKey,
|
|
189
|
+
input.runId,
|
|
190
|
+
input.stepId,
|
|
191
|
+
input.attemptId,
|
|
192
|
+
input.actionKind,
|
|
193
|
+
input.targetRef,
|
|
194
|
+
"in-flight",
|
|
195
|
+
payloadHash,
|
|
196
|
+
payloadStr,
|
|
197
|
+
now,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
return { ok: true, effectKey };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
commitEffect(
|
|
204
|
+
effectKey: string,
|
|
205
|
+
receiptPayload: Record<string, unknown>,
|
|
206
|
+
): void {
|
|
207
|
+
const now = new Date().toISOString();
|
|
208
|
+
const stmt = this.db.prepare(`
|
|
209
|
+
UPDATE external_effects
|
|
210
|
+
SET status = 'committed', receipt_payload = ?, completed_at = ?
|
|
211
|
+
WHERE effect_key = ?
|
|
212
|
+
`);
|
|
213
|
+
stmt.run(JSON.stringify(receiptPayload), now, effectKey);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
abortEffect(effectKey: string, reason: string): void {
|
|
217
|
+
const now = new Date().toISOString();
|
|
218
|
+
const stmt = this.db.prepare(`
|
|
219
|
+
UPDATE external_effects
|
|
220
|
+
SET status = 'aborted', receipt_payload = ?, completed_at = ?
|
|
221
|
+
WHERE effect_key = ?
|
|
222
|
+
`);
|
|
223
|
+
stmt.run(JSON.stringify({ error: reason }), now, effectKey);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
getReceipt(effectKey: string): ExternalEffectReceipt | undefined {
|
|
227
|
+
const stmt = this.db.prepare(`
|
|
228
|
+
SELECT * FROM external_effects WHERE effect_key = ?
|
|
229
|
+
`);
|
|
230
|
+
const row = stmt.get(effectKey) as {
|
|
231
|
+
effect_key: string;
|
|
232
|
+
run_id: string;
|
|
233
|
+
step_id: string;
|
|
234
|
+
attempt_id: string;
|
|
235
|
+
action_kind: ExternalActionKind;
|
|
236
|
+
target_ref: string;
|
|
237
|
+
status: ExternalEffectStatus;
|
|
238
|
+
payload_hash: string;
|
|
239
|
+
receipt_payload: string;
|
|
240
|
+
executed_at: string;
|
|
241
|
+
completed_at: string | null;
|
|
242
|
+
} | undefined;
|
|
243
|
+
|
|
244
|
+
if (!row) return undefined;
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
schema: EXTERNAL_EFFECT_SCHEMA,
|
|
248
|
+
effectKey: row.effect_key,
|
|
249
|
+
runId: row.run_id,
|
|
250
|
+
stepId: row.step_id,
|
|
251
|
+
attemptId: row.attempt_id,
|
|
252
|
+
actionKind: row.action_kind,
|
|
253
|
+
targetRef: row.target_ref,
|
|
254
|
+
status: row.status,
|
|
255
|
+
payloadHash: row.payload_hash,
|
|
256
|
+
receiptPayload: JSON.parse(row.receipt_payload) as Record<string, unknown>,
|
|
257
|
+
executedAt: row.executed_at,
|
|
258
|
+
completedAt: row.completed_at ?? undefined,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
listRunEffects(runId: string): ExternalEffectReceipt[] {
|
|
263
|
+
const stmt = this.db.prepare(`
|
|
264
|
+
SELECT * FROM external_effects WHERE run_id = ? ORDER BY executed_at ASC
|
|
265
|
+
`);
|
|
266
|
+
const rows = stmt.all(runId) as Array<{
|
|
267
|
+
effect_key: string;
|
|
268
|
+
run_id: string;
|
|
269
|
+
step_id: string;
|
|
270
|
+
attempt_id: string;
|
|
271
|
+
action_kind: ExternalActionKind;
|
|
272
|
+
target_ref: string;
|
|
273
|
+
status: ExternalEffectStatus;
|
|
274
|
+
payload_hash: string;
|
|
275
|
+
receipt_payload: string;
|
|
276
|
+
executed_at: string;
|
|
277
|
+
completed_at: string | null;
|
|
278
|
+
}>;
|
|
279
|
+
|
|
280
|
+
return rows.map((row) => ({
|
|
281
|
+
schema: EXTERNAL_EFFECT_SCHEMA,
|
|
282
|
+
effectKey: row.effect_key,
|
|
283
|
+
runId: row.run_id,
|
|
284
|
+
stepId: row.step_id,
|
|
285
|
+
attemptId: row.attempt_id,
|
|
286
|
+
actionKind: row.action_kind,
|
|
287
|
+
targetRef: row.target_ref,
|
|
288
|
+
status: row.status,
|
|
289
|
+
payloadHash: row.payload_hash,
|
|
290
|
+
receiptPayload: JSON.parse(row.receipt_payload) as Record<string, unknown>,
|
|
291
|
+
executedAt: row.executed_at,
|
|
292
|
+
completedAt: row.completed_at ?? undefined,
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
close(): void {
|
|
297
|
+
this.db.close();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
2
|
+
import { redactSecrets } from "./redact.ts";
|
|
3
|
+
import type { WorkflowEvidenceInput } from "./workflow.ts";
|
|
4
|
+
|
|
5
|
+
export type WatchStatus = "passed" | "failed" | "warning";
|
|
6
|
+
|
|
7
|
+
export interface GithubCheckRun {
|
|
8
|
+
name: string;
|
|
9
|
+
status: string;
|
|
10
|
+
conclusion?: string | null;
|
|
11
|
+
html_url?: string;
|
|
12
|
+
completed_at?: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GithubWatchInput {
|
|
16
|
+
serverUrl: string;
|
|
17
|
+
definitionId: string;
|
|
18
|
+
signalSecret: string;
|
|
19
|
+
runId: string;
|
|
20
|
+
stageId: string;
|
|
21
|
+
signalKey: string;
|
|
22
|
+
repo: string;
|
|
23
|
+
pr: number;
|
|
24
|
+
required?: string[];
|
|
25
|
+
timeoutMs: number;
|
|
26
|
+
intervalMs: number;
|
|
27
|
+
token?: string;
|
|
28
|
+
deliveryId?: string;
|
|
29
|
+
dryRun?: boolean;
|
|
30
|
+
now?: () => number;
|
|
31
|
+
sleep?: (ms: number) => Promise<void>;
|
|
32
|
+
fetchImpl?: typeof fetch;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GithubWatchResult {
|
|
36
|
+
exitCode: number;
|
|
37
|
+
posted: boolean;
|
|
38
|
+
duplicate?: boolean;
|
|
39
|
+
status?: WatchStatus;
|
|
40
|
+
summary: string;
|
|
41
|
+
evidence: WorkflowEvidenceInput;
|
|
42
|
+
deliveryId?: string;
|
|
43
|
+
skipped?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function fetchWithTimeout(fetchImpl: typeof fetch, input: string, init: RequestInit, timeoutMs: number): Promise<Response> {
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
|
|
49
|
+
try {
|
|
50
|
+
return await fetchImpl(input, { ...init, signal: controller.signal });
|
|
51
|
+
} finally {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function requiredToken(token: string | undefined): string | undefined {
|
|
57
|
+
const value = token?.trim();
|
|
58
|
+
return value || undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const FAILED_CONCLUSIONS = new Set([
|
|
62
|
+
"failure",
|
|
63
|
+
"cancelled",
|
|
64
|
+
"timed_out",
|
|
65
|
+
"action_required",
|
|
66
|
+
"stale",
|
|
67
|
+
"startup_failure",
|
|
68
|
+
]);
|
|
69
|
+
const SUCCESS_CONCLUSIONS = new Set(["success"]);
|
|
70
|
+
const CHECK_RUNS_PER_PAGE = 100;
|
|
71
|
+
const MAX_CHECK_RUN_PAGES = 100;
|
|
72
|
+
|
|
73
|
+
export function mapCheckConclusion(
|
|
74
|
+
runs: GithubCheckRun[],
|
|
75
|
+
required: string[] = [],
|
|
76
|
+
): { status: WatchStatus | "pending"; evidence: WorkflowEvidenceInput } {
|
|
77
|
+
const names = required.length > 0 ? required : [...new Set(runs.map((run) => run.name))];
|
|
78
|
+
const interesting = names.map((name) => runs.find((run) => run.name === name));
|
|
79
|
+
const evidence = Object.fromEntries(interesting.slice(0, 32).map((run, index) => {
|
|
80
|
+
const name = names[index] ?? "unknown";
|
|
81
|
+
const conclusion = run?.conclusion ?? run?.status ?? "missing";
|
|
82
|
+
const url = run?.html_url ? ` url:${run.html_url}` : "";
|
|
83
|
+
const completed = run?.completed_at ? ` at:${run.completed_at}` : "";
|
|
84
|
+
return [`github.check:${name}`, redactSecrets(`conclusion:${conclusion}${url}${completed}`).slice(0, 500)];
|
|
85
|
+
}));
|
|
86
|
+
if (names.length === 0 || interesting.some((run) => !run || run.status !== "completed")) {
|
|
87
|
+
return { status: "pending", evidence };
|
|
88
|
+
}
|
|
89
|
+
if (interesting.some((run) => FAILED_CONCLUSIONS.has(run?.conclusion ?? ""))) {
|
|
90
|
+
return { status: "failed", evidence };
|
|
91
|
+
}
|
|
92
|
+
if (interesting.every((run) => SUCCESS_CONCLUSIONS.has(run?.conclusion ?? ""))) {
|
|
93
|
+
return { status: "passed", evidence };
|
|
94
|
+
}
|
|
95
|
+
return { status: "pending", evidence };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function postWorkflowSignal(input: {
|
|
99
|
+
serverUrl: string;
|
|
100
|
+
definitionId: string;
|
|
101
|
+
signalSecret: string;
|
|
102
|
+
runId: string;
|
|
103
|
+
signalKey: string;
|
|
104
|
+
status: WatchStatus;
|
|
105
|
+
summary: string;
|
|
106
|
+
evidence: WorkflowEvidenceInput;
|
|
107
|
+
deliveryId: string;
|
|
108
|
+
timeoutMs?: number;
|
|
109
|
+
fetchImpl?: typeof fetch;
|
|
110
|
+
}): Promise<{ httpStatus: number; duplicate: boolean }> {
|
|
111
|
+
const body = JSON.stringify({ status: input.status, summary: input.summary, evidence: input.evidence });
|
|
112
|
+
const signature = `sha256=${createHmac("sha256", input.signalSecret).update(body).digest("hex")}`;
|
|
113
|
+
const endpoint = [
|
|
114
|
+
input.serverUrl.replace(/\/$/, ""),
|
|
115
|
+
"v1/webhooks",
|
|
116
|
+
encodeURIComponent(input.definitionId),
|
|
117
|
+
"runs",
|
|
118
|
+
encodeURIComponent(input.runId),
|
|
119
|
+
"signals",
|
|
120
|
+
encodeURIComponent(input.signalKey),
|
|
121
|
+
].join("/");
|
|
122
|
+
const response = await fetchWithTimeout(input.fetchImpl ?? fetch, endpoint, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: {
|
|
125
|
+
"content-type": "application/json",
|
|
126
|
+
"x-hub-signature-256": signature,
|
|
127
|
+
"x-kxm-delivery-id": input.deliveryId,
|
|
128
|
+
},
|
|
129
|
+
body,
|
|
130
|
+
}, input.timeoutMs ?? 15_000);
|
|
131
|
+
const text = await response.text();
|
|
132
|
+
let duplicate = false;
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(text) as { duplicate?: boolean };
|
|
135
|
+
duplicate = parsed.duplicate === true;
|
|
136
|
+
} catch {
|
|
137
|
+
// Non-JSON responses are treated as adapter failures below.
|
|
138
|
+
}
|
|
139
|
+
if (!response.ok && response.status !== 200) {
|
|
140
|
+
throw new Error(`signal_http_${response.status}`);
|
|
141
|
+
}
|
|
142
|
+
return { httpStatus: response.status, duplicate };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function watchGithubChecks(input: GithubWatchInput): Promise<GithubWatchResult> {
|
|
146
|
+
const token = requiredToken(input.token);
|
|
147
|
+
if (!token) {
|
|
148
|
+
return { exitCode: 1, posted: false, skipped: true, summary: "github_auth_unavailable", evidence: {} };
|
|
149
|
+
}
|
|
150
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
151
|
+
const now = input.now ?? Date.now;
|
|
152
|
+
const sleep = input.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
153
|
+
const deadline = now() + input.timeoutMs;
|
|
154
|
+
const headers = {
|
|
155
|
+
authorization: `Bearer ${token}`,
|
|
156
|
+
accept: "application/vnd.github+json",
|
|
157
|
+
"user-agent": "kxm-github-watch",
|
|
158
|
+
};
|
|
159
|
+
const contextEvidence: WorkflowEvidenceInput = {
|
|
160
|
+
"workflow.run": input.runId,
|
|
161
|
+
"workflow.stage": input.stageId,
|
|
162
|
+
"workflow.signal": input.signalKey,
|
|
163
|
+
};
|
|
164
|
+
const explicitDeliveryId = input.deliveryId?.trim();
|
|
165
|
+
const deliveryGeneration = randomUUID();
|
|
166
|
+
let deliveryId = explicitDeliveryId || `github-watch:${deliveryGeneration}:pr-${input.pr}`;
|
|
167
|
+
const deliver = async (
|
|
168
|
+
status: WatchStatus,
|
|
169
|
+
summary: string,
|
|
170
|
+
evidence: WorkflowEvidenceInput,
|
|
171
|
+
): Promise<GithubWatchResult> => {
|
|
172
|
+
const boundedEvidence = Object.fromEntries(Object.entries({ ...contextEvidence, ...evidence }).slice(0, 64));
|
|
173
|
+
if (input.dryRun) return { exitCode: status === "failed" && summary === "github_watch_timeout" ? 4 : 0, posted: false, status, summary, evidence: boundedEvidence, deliveryId };
|
|
174
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
175
|
+
try {
|
|
176
|
+
const posted = await postWorkflowSignal({ serverUrl: input.serverUrl, definitionId: input.definitionId, signalSecret: input.signalSecret, runId: input.runId, signalKey: input.signalKey, status, summary, evidence: boundedEvidence, deliveryId, timeoutMs: Math.min(15_000, Math.max(1_000, input.intervalMs)), fetchImpl });
|
|
177
|
+
return { exitCode: status === "failed" && summary === "github_watch_timeout" ? 4 : 0, posted: true, duplicate: posted.duplicate, status, summary, evidence: boundedEvidence, deliveryId };
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const message = error instanceof Error ? error.message : "signal_failed";
|
|
180
|
+
if (/signal_http_(404|409)\b/.test(message)) return { exitCode: 1, posted: false, summary: "workflow_not_waiting", evidence: boundedEvidence, deliveryId };
|
|
181
|
+
const transient = /signal_http_(429|5\d\d)\b/.test(message) || message === "signal_failed" || /abort|timeout|fetch/i.test(message);
|
|
182
|
+
if (!transient || attempt === 3) return { exitCode: 1, posted: false, summary: "signal_failed", evidence: boundedEvidence, deliveryId };
|
|
183
|
+
await sleep(Math.min(250 * (2 ** (attempt - 1)), 1_000));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { exitCode: 1, posted: false, summary: "signal_failed", evidence: boundedEvidence, deliveryId };
|
|
187
|
+
};
|
|
188
|
+
const githubGet = async (url: string): Promise<Response | undefined> => {
|
|
189
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
190
|
+
const remaining = deadline - now();
|
|
191
|
+
if (remaining <= 0) return undefined;
|
|
192
|
+
try {
|
|
193
|
+
const response = await fetchWithTimeout(fetchImpl, url, { headers }, Math.min(15_000, remaining));
|
|
194
|
+
if (response.status !== 429 && response.status < 500) return response;
|
|
195
|
+
if (attempt === 3) return response;
|
|
196
|
+
} catch {
|
|
197
|
+
if (attempt === 3 || now() >= deadline) return undefined;
|
|
198
|
+
}
|
|
199
|
+
await sleep(Math.min(250 * (2 ** (attempt - 1)), Math.max(1, deadline - now())));
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
};
|
|
203
|
+
const prResponse = await githubGet(`https://api.github.com/repos/${input.repo}/pulls/${input.pr}`);
|
|
204
|
+
if (!prResponse) return deliver("failed", "github_watch_timeout", {});
|
|
205
|
+
if (!prResponse.ok) {
|
|
206
|
+
return { exitCode: 1, posted: false, summary: "github_pr_unavailable", evidence: { "github.http": String(prResponse.status) } };
|
|
207
|
+
}
|
|
208
|
+
const pull = await prResponse.json() as { head?: { sha?: string } };
|
|
209
|
+
const headSha = pull.head?.sha;
|
|
210
|
+
if (!headSha) {
|
|
211
|
+
return { exitCode: 1, posted: false, summary: "github_head_unavailable", evidence: {} };
|
|
212
|
+
}
|
|
213
|
+
if (!explicitDeliveryId) deliveryId = `github-watch:${deliveryGeneration}:${headSha.slice(0, 40)}`;
|
|
214
|
+
let lastEvidence: WorkflowEvidenceInput = {};
|
|
215
|
+
while (now() <= deadline) {
|
|
216
|
+
const checkRuns: GithubCheckRun[] = [];
|
|
217
|
+
for (let page = 1; page <= MAX_CHECK_RUN_PAGES; page += 1) {
|
|
218
|
+
const checksUrl = new URL(`https://api.github.com/repos/${input.repo}/commits/${headSha}/check-runs`);
|
|
219
|
+
checksUrl.searchParams.set("per_page", String(CHECK_RUNS_PER_PAGE));
|
|
220
|
+
checksUrl.searchParams.set("page", String(page));
|
|
221
|
+
const checksResponse = await githubGet(checksUrl.toString());
|
|
222
|
+
if (!checksResponse) return deliver("failed", "github_watch_timeout", lastEvidence);
|
|
223
|
+
if (!checksResponse.ok) {
|
|
224
|
+
return { exitCode: 1, posted: false, summary: "github_checks_unavailable", evidence: { "github.http": String(checksResponse.status) } };
|
|
225
|
+
}
|
|
226
|
+
const payload = await checksResponse.json() as { total_count?: number; check_runs?: GithubCheckRun[] };
|
|
227
|
+
const pageRuns = Array.isArray(payload.check_runs) ? payload.check_runs : [];
|
|
228
|
+
checkRuns.push(...pageRuns);
|
|
229
|
+
const totalCount = Number.isInteger(payload.total_count) && payload.total_count! >= 0
|
|
230
|
+
? payload.total_count!
|
|
231
|
+
: undefined;
|
|
232
|
+
const complete = totalCount === undefined
|
|
233
|
+
? pageRuns.length < CHECK_RUNS_PER_PAGE
|
|
234
|
+
: checkRuns.length >= totalCount;
|
|
235
|
+
if (complete) break;
|
|
236
|
+
if (pageRuns.length === 0 || page === MAX_CHECK_RUN_PAGES) {
|
|
237
|
+
return {
|
|
238
|
+
exitCode: 1,
|
|
239
|
+
posted: false,
|
|
240
|
+
summary: "github_checks_unavailable",
|
|
241
|
+
evidence: { "github.pagination": pageRuns.length === 0 ? "incomplete" : "limit_exceeded" },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const mapped = mapCheckConclusion(checkRuns, input.required ?? []);
|
|
246
|
+
lastEvidence = mapped.evidence;
|
|
247
|
+
if (mapped.status !== "pending") {
|
|
248
|
+
const summary = mapped.status === "passed" ? "required GitHub checks passed" : "required GitHub checks failed";
|
|
249
|
+
return deliver(mapped.status, summary, mapped.evidence);
|
|
250
|
+
}
|
|
251
|
+
if (now() + input.intervalMs > deadline) break;
|
|
252
|
+
await sleep(input.intervalMs);
|
|
253
|
+
}
|
|
254
|
+
return deliver("failed", "github_watch_timeout", lastEvidence);
|
|
255
|
+
}
|