@a5c-ai/krate 5.0.1-staging.00fa5317c

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.
Files changed (256) hide show
  1. package/Dockerfile +31 -0
  2. package/README.md +183 -0
  3. package/bin/krate-demo.mjs +23 -0
  4. package/bin/krate-server.mjs +14 -0
  5. package/dist/krate-controller-ui.json +3205 -0
  6. package/dist/krate-lifecycle.json +201 -0
  7. package/dist/krate-runtime-snapshot.json +3125 -0
  8. package/dist/krate-summary.json +724 -0
  9. package/docs/README.md +61 -0
  10. package/docs/agents/README.md +83 -0
  11. package/docs/agents/acceptance-test-matrix.md +193 -0
  12. package/docs/agents/agent-mux-adapter-contract.md +167 -0
  13. package/docs/agents/agent-mux-source-map.md +310 -0
  14. package/docs/agents/agent-run-memory-import-spec.md +256 -0
  15. package/docs/agents/agent-stack-management-spec.md +421 -0
  16. package/docs/agents/api-contract-spec.md +309 -0
  17. package/docs/agents/artifacts-writeback-spec.md +145 -0
  18. package/docs/agents/chart-packaging-spec.md +128 -0
  19. package/docs/agents/ci-orchestration-spec.md +140 -0
  20. package/docs/agents/context-assembly-spec.md +219 -0
  21. package/docs/agents/controller-reconciliation-spec.md +255 -0
  22. package/docs/agents/crd-schema-spec.md +315 -0
  23. package/docs/agents/decision-log-open-questions.md +169 -0
  24. package/docs/agents/developer-implementation-checklist.md +329 -0
  25. package/docs/agents/dispatching-design.md +262 -0
  26. package/docs/agents/gaps-agent-mux-to-krate-crds.md +298 -0
  27. package/docs/agents/glossary.md +66 -0
  28. package/docs/agents/implementation-blueprint.md +324 -0
  29. package/docs/agents/implementation-rollout-slices.md +251 -0
  30. package/docs/agents/memory-context-integration-spec.md +194 -0
  31. package/docs/agents/memory-ontology-schema-spec.md +253 -0
  32. package/docs/agents/memory-operations-runbook.md +121 -0
  33. package/docs/agents/mvp-vertical-slice-spec.md +146 -0
  34. package/docs/agents/observability-audit-spec.md +265 -0
  35. package/docs/agents/operator-runbook.md +174 -0
  36. package/docs/agents/org-memory-api-payload-examples.md +333 -0
  37. package/docs/agents/org-memory-controller-sequence-spec.md +181 -0
  38. package/docs/agents/org-memory-e2e-fixture-plan.md +161 -0
  39. package/docs/agents/org-memory-ui-implementation-map.md +114 -0
  40. package/docs/agents/org-memory-vertical-slice-spec.md +168 -0
  41. package/docs/agents/org-resource-model-delta-spec.md +111 -0
  42. package/docs/agents/org-route-resource-model-spec.md +183 -0
  43. package/docs/agents/org-scoping-namespace-spec.md +114 -0
  44. package/docs/agents/rbac-secrets-management-spec.md +406 -0
  45. package/docs/agents/repository-page-integration-spec.md +255 -0
  46. package/docs/agents/resource-contract-examples.md +808 -0
  47. package/docs/agents/resource-relationship-map.md +190 -0
  48. package/docs/agents/security-threat-model.md +188 -0
  49. package/docs/agents/shared-memory-company-brain-spec.md +358 -0
  50. package/docs/agents/storage-migration-spec.md +168 -0
  51. package/docs/agents/subagent-orchestration-spec.md +152 -0
  52. package/docs/agents/system-overview.md +88 -0
  53. package/docs/agents/tools-mcp-skills-spec.md +189 -0
  54. package/docs/agents/traceability-matrix.md +79 -0
  55. package/docs/agents/ui-flow-spec.md +211 -0
  56. package/docs/agents/ui-ux-system-spec.md +426 -0
  57. package/docs/agents/workspace-lifecycle-spec.md +166 -0
  58. package/docs/architecture-spec.md +78 -0
  59. package/docs/components/control-plane.md +78 -0
  60. package/docs/components/data-plane.md +69 -0
  61. package/docs/components/hooks-events.md +67 -0
  62. package/docs/components/identity-rbac-policy.md +73 -0
  63. package/docs/components/kubevela-oam.md +70 -0
  64. package/docs/components/operations-publishing.md +81 -0
  65. package/docs/components/runners-ci.md +66 -0
  66. package/docs/components/web-ui.md +94 -0
  67. package/docs/external/README.md +47 -0
  68. package/docs/external/bidirectional-sync-design.md +134 -0
  69. package/docs/external/cicd-interface.md +64 -0
  70. package/docs/external/external-backend-controllers.md +170 -0
  71. package/docs/external/external-backend-crds.md +234 -0
  72. package/docs/external/external-backend-ui-spec.md +151 -0
  73. package/docs/external/external-backend-ux-flows.md +115 -0
  74. package/docs/external/external-object-mapping.md +125 -0
  75. package/docs/external/git-forge-interface.md +68 -0
  76. package/docs/external/github-integration-design.md +151 -0
  77. package/docs/external/issue-tracking-interface.md +66 -0
  78. package/docs/external/provider-capability-manifests.md +204 -0
  79. package/docs/external/provider-catalog.md +139 -0
  80. package/docs/external/provider-rollout-testing.md +78 -0
  81. package/docs/external/research-results.md +48 -0
  82. package/docs/external/security-auth-permissions.md +81 -0
  83. package/docs/external/sync-state-machines.md +108 -0
  84. package/docs/external/unified-external-backend-model.md +107 -0
  85. package/docs/external/user-facing-changes.md +67 -0
  86. package/docs/gaps.md +161 -0
  87. package/docs/install.md +94 -0
  88. package/docs/krate-design.md +334 -0
  89. package/docs/local-minikube.md +55 -0
  90. package/docs/ontology/README.md +32 -0
  91. package/docs/ontology/bounded-contexts.md +29 -0
  92. package/docs/ontology/events-and-hooks.md +32 -0
  93. package/docs/ontology/oam-kubevela.md +32 -0
  94. package/docs/ontology/operations-and-release.md +25 -0
  95. package/docs/ontology/personas-and-actors.md +32 -0
  96. package/docs/ontology/policies-and-invariants.md +33 -0
  97. package/docs/ontology/problem-space.md +30 -0
  98. package/docs/ontology/resource-contracts.md +40 -0
  99. package/docs/ontology/resource-taxonomy.md +42 -0
  100. package/docs/ontology/runners-and-ci.md +29 -0
  101. package/docs/ontology/solution-space.md +24 -0
  102. package/docs/ontology/storage-and-data-boundaries.md +29 -0
  103. package/docs/ontology/validation-matrix.md +24 -0
  104. package/docs/ontology/web-ui-excellent-flows.md +32 -0
  105. package/docs/ontology/workflows.md +39 -0
  106. package/docs/ontology/world.md +35 -0
  107. package/docs/openapi.yaml +1275 -0
  108. package/docs/product-requirements.md +62 -0
  109. package/docs/roadmap-mvp.md +87 -0
  110. package/docs/system-requirements.md +90 -0
  111. package/docs/tests/README.md +53 -0
  112. package/docs/tests/agent-qa-plan.md +63 -0
  113. package/docs/tests/browser-ui-tests.md +62 -0
  114. package/docs/tests/ci-quality-gates.md +48 -0
  115. package/docs/tests/coverage-model.md +64 -0
  116. package/docs/tests/e2e-scenario-tests.md +53 -0
  117. package/docs/tests/fixtures-test-data.md +63 -0
  118. package/docs/tests/observability-reliability-tests.md +54 -0
  119. package/docs/tests/product-test-matrix.md +145 -0
  120. package/docs/tests/qa-adoption-roadmap.md +130 -0
  121. package/docs/tests/qa-automation-plan.md +101 -0
  122. package/docs/tests/security-compliance-tests.md +57 -0
  123. package/docs/tests/test-framework-tools.md +88 -0
  124. package/docs/tests/test-suite-layout.md +121 -0
  125. package/docs/tests/unit-integration-tests.md +48 -0
  126. package/docs/todo-kyverno +714 -0
  127. package/docs/todos.md +4 -0
  128. package/docs/user-stories.md +78 -0
  129. package/examples/minikube-demo.yaml +190 -0
  130. package/examples/oam-application.yaml +23 -0
  131. package/examples/policy-kyverno-pr-title.yaml +18 -0
  132. package/package.json +63 -0
  133. package/scripts/build.mjs +29 -0
  134. package/scripts/setup-minikube.mjs +65 -0
  135. package/scripts/smoke.mjs +37 -0
  136. package/scripts/validate-doc-coverage.mjs +152 -0
  137. package/scripts/validate-package.mjs +93 -0
  138. package/scripts/validate-ui.mjs +278 -0
  139. package/src/agent-adapter-controller.js +169 -0
  140. package/src/agent-approval-controller.js +170 -0
  141. package/src/agent-context-bundles.js +242 -0
  142. package/src/agent-dispatch-controller.js +209 -0
  143. package/src/agent-gateway-config-controller.js +147 -0
  144. package/src/agent-memory-controller.js +357 -0
  145. package/src/agent-memory-import.js +327 -0
  146. package/src/agent-memory-query.js +292 -0
  147. package/src/agent-memory-repository-source-controller.js +255 -0
  148. package/src/agent-mux-client.js +280 -0
  149. package/src/agent-permission-review.js +250 -0
  150. package/src/agent-project-controller.js +117 -0
  151. package/src/agent-provider-config-controller.js +150 -0
  152. package/src/agent-secret-config-grant-controller.js +282 -0
  153. package/src/agent-session-transcript-controller.js +189 -0
  154. package/src/agent-stack-controller.js +347 -0
  155. package/src/agent-subagent-controller.js +160 -0
  156. package/src/agent-transport-binding-controller.js +121 -0
  157. package/src/agent-trigger-controller.js +381 -0
  158. package/src/agent-workspace-controller.js +702 -0
  159. package/src/agent-writeback-controller.js +302 -0
  160. package/src/api-controller.js +541 -0
  161. package/src/argocd-gitops.js +43 -0
  162. package/src/async-controller.js +207 -0
  163. package/src/audit-controller.js +191 -0
  164. package/src/auth.js +307 -0
  165. package/src/component-catalog.js +41 -0
  166. package/src/control-plane.js +136 -0
  167. package/src/controller-client.js +72 -0
  168. package/src/controller-ui.js +617 -0
  169. package/src/data-plane.js +179 -0
  170. package/src/event-bus.js +61 -0
  171. package/src/external/conflict-controller.js +225 -0
  172. package/src/external/github/auth.js +96 -0
  173. package/src/external/github/cicd.js +180 -0
  174. package/src/external/github/git-forge.js +240 -0
  175. package/src/external/github/index.js +144 -0
  176. package/src/external/github/issue-tracking.js +163 -0
  177. package/src/external/provider-adapter.js +161 -0
  178. package/src/external/provider-resource-factory.js +161 -0
  179. package/src/external/sync-controller.js +235 -0
  180. package/src/external/webhook-controller.js +144 -0
  181. package/src/external/write-controller.js +283 -0
  182. package/src/gitea-backend.js +131 -0
  183. package/src/gitea-service.js +173 -0
  184. package/src/handoff.js +98 -0
  185. package/src/hooks-events.js +63 -0
  186. package/src/http-server.js +377 -0
  187. package/src/identity-policy.js +86 -0
  188. package/src/index.js +57 -0
  189. package/src/kubernetes-controller-async.js +511 -0
  190. package/src/kubernetes-controller.js +878 -0
  191. package/src/kubernetes-resource-gateway.js +48 -0
  192. package/src/notification-controller.js +178 -0
  193. package/src/operations.js +112 -0
  194. package/src/org-scoping.js +5 -0
  195. package/src/resource-model.js +221 -0
  196. package/src/runner-controller.js +272 -0
  197. package/src/runners-ci.js +48 -0
  198. package/src/runtime.js +196 -0
  199. package/src/snapshot-cache.js +157 -0
  200. package/src/web-ui.js +40 -0
  201. package/tests/agent-adapter-controller.test.js +361 -0
  202. package/tests/agent-approval-controller.test.js +173 -0
  203. package/tests/agent-context-bundles.test.js +278 -0
  204. package/tests/agent-dispatch-controller.test.js +315 -0
  205. package/tests/agent-gateway-config-controller.test.js +386 -0
  206. package/tests/agent-memory-controller.test.js +308 -0
  207. package/tests/agent-memory-import-snapshot.test.js +477 -0
  208. package/tests/agent-memory-query.test.js +404 -0
  209. package/tests/agent-memory-repository-source.test.js +514 -0
  210. package/tests/agent-mux-client.test.js +204 -0
  211. package/tests/agent-permission-review-v2.test.js +317 -0
  212. package/tests/agent-permission-review.test.js +209 -0
  213. package/tests/agent-project-controller.test.js +302 -0
  214. package/tests/agent-provider-config-controller.test.js +376 -0
  215. package/tests/agent-resources.test.js +228 -0
  216. package/tests/agent-secret-config-grant.test.js +231 -0
  217. package/tests/agent-session-transcript-controller.test.js +499 -0
  218. package/tests/agent-stack-controller.test.js +221 -0
  219. package/tests/agent-subagent-controller.test.js +201 -0
  220. package/tests/agent-transport-binding-controller.test.js +294 -0
  221. package/tests/agent-trigger-controller.test.js +211 -0
  222. package/tests/agent-trigger-routes.test.js +190 -0
  223. package/tests/agent-trigger-sources.test.js +245 -0
  224. package/tests/agent-workspace-controller.test.js +181 -0
  225. package/tests/agent-writeback.test.js +292 -0
  226. package/tests/approval-persistence.test.js +171 -0
  227. package/tests/async-controller.test.js +252 -0
  228. package/tests/audit-controller.test.js +227 -0
  229. package/tests/codespace-controller.test.js +318 -0
  230. package/tests/deployment.test.js +407 -0
  231. package/tests/e2e/lifecycle.test.js +117 -0
  232. package/tests/event-bus-integration.test.js +190 -0
  233. package/tests/external-github-forge.test.js +560 -0
  234. package/tests/external-github-issues-cicd.test.js +520 -0
  235. package/tests/external-integration.test.js +470 -0
  236. package/tests/external-persistence.test.js +340 -0
  237. package/tests/external-provider-adapter.test.js +365 -0
  238. package/tests/external-resource-model.test.js +215 -0
  239. package/tests/external-webhook-sync.test.js +287 -0
  240. package/tests/external-write-conflict.test.js +353 -0
  241. package/tests/gitea-service.test.js +253 -0
  242. package/tests/health-check-real.test.js +165 -0
  243. package/tests/integration/full-flow.test.js +266 -0
  244. package/tests/krate.test.js +756 -0
  245. package/tests/memory-search-wiring.test.js +270 -0
  246. package/tests/notification-controller.test.js +196 -0
  247. package/tests/notification-integration.test.js +179 -0
  248. package/tests/org-scoping.test.js +687 -0
  249. package/tests/runner-controller.test.js +327 -0
  250. package/tests/runner-integration.test.js +231 -0
  251. package/tests/session-cookie-hmac.test.js +151 -0
  252. package/tests/snapshot-performance.test.js +247 -0
  253. package/tests/sse-events.test.js +107 -0
  254. package/tests/webhook-trigger.test.js +198 -0
  255. package/tests/workspace-volumes.test.js +312 -0
  256. package/tests/writeback-persistence.test.js +207 -0
@@ -0,0 +1,287 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createHmac } from 'node:crypto';
4
+ import { createWebhookController } from '../src/external/webhook-controller.js';
5
+ import { createSyncController } from '../src/external/sync-controller.js';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Acceptance criteria: Slice 3.4 — Webhook & Sync Controllers
9
+ // ---------------------------------------------------------------------------
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const SECRET = 'test-secret-key-12345';
16
+
17
+ function makeRawPayload() {
18
+ return JSON.stringify({ action: 'opened', repository: { id: 99, full_name: 'org/repo' } });
19
+ }
20
+
21
+ function makeHmacSignature(body, secret = SECRET) {
22
+ return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
23
+ }
24
+
25
+ function makeWebhookRequest(overrides = {}) {
26
+ const body = makeRawPayload();
27
+ return {
28
+ headers: {
29
+ 'x-hub-signature-256': makeHmacSignature(body),
30
+ 'x-github-delivery': 'delivery-abc-123',
31
+ 'x-github-event': 'pull_request',
32
+ ...((overrides.headers) || {})
33
+ },
34
+ body,
35
+ ...overrides
36
+ };
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // WebhookController tests
41
+ // ---------------------------------------------------------------------------
42
+
43
+ test('verifyHmacSignature accepts valid HMAC-SHA256 signature', () => {
44
+ const controller = createWebhookController({ secret: SECRET });
45
+ const body = makeRawPayload();
46
+ const sig = makeHmacSignature(body);
47
+ const result = controller.verifyHmacSignature(body, sig);
48
+ assert.equal(result.valid, true, 'valid HMAC signature must be accepted');
49
+ assert.equal(result.reason, null, 'reason must be null when valid');
50
+ });
51
+
52
+ test('verifyHmacSignature rejects invalid signature', () => {
53
+ const controller = createWebhookController({ secret: SECRET });
54
+ const body = makeRawPayload();
55
+ const badSig = 'sha256=' + 'deadbeef'.repeat(8);
56
+ const result = controller.verifyHmacSignature(body, badSig);
57
+ assert.equal(result.valid, false, 'invalid HMAC signature must be rejected');
58
+ assert.ok(result.reason, 'reason must be present when rejected');
59
+ assert.match(result.reason, /signature|mismatch|invalid/i, 'reason must explain the rejection');
60
+ });
61
+
62
+ test('verifyHmacSignature rejects missing signature', () => {
63
+ const controller = createWebhookController({ secret: SECRET });
64
+ const body = makeRawPayload();
65
+ const result = controller.verifyHmacSignature(body, null);
66
+ assert.equal(result.valid, false, 'missing signature must be rejected');
67
+ assert.ok(result.reason, 'reason must be present when rejected');
68
+ assert.match(result.reason, /missing|signature/i, 'reason must mention missing signature');
69
+ });
70
+
71
+ test('createDeliveryRecord creates record with deliveryId, eventType, timestamp', () => {
72
+ const controller = createWebhookController({ secret: SECRET });
73
+ const req = makeWebhookRequest();
74
+ const record = controller.createDeliveryRecord({
75
+ deliveryId: 'delivery-abc-123',
76
+ eventType: 'pull_request',
77
+ payload: JSON.parse(req.body),
78
+ rawBody: req.body
79
+ });
80
+ assert.ok(record, 'delivery record must be created');
81
+ assert.equal(record.deliveryId, 'delivery-abc-123', 'deliveryId must match input');
82
+ assert.equal(record.eventType, 'pull_request', 'eventType must match input');
83
+ assert.ok(record.timestamp, 'timestamp must be present');
84
+ assert.ok(typeof record.timestamp === 'string', 'timestamp must be a string (ISO 8601)');
85
+ assert.ok(record.payload, 'payload must be present');
86
+ });
87
+
88
+ test('isDuplicate returns true for already-seen deliveryId', () => {
89
+ const controller = createWebhookController({ secret: SECRET });
90
+ const record = controller.createDeliveryRecord({
91
+ deliveryId: 'dup-delivery-001',
92
+ eventType: 'push',
93
+ payload: {},
94
+ rawBody: '{}'
95
+ });
96
+ controller.recordDelivery(record);
97
+ const result = controller.isDuplicate('dup-delivery-001');
98
+ assert.equal(result, true, 'isDuplicate must return true for seen deliveryId');
99
+ });
100
+
101
+ test('isDuplicate returns false for new deliveryId', () => {
102
+ const controller = createWebhookController({ secret: SECRET });
103
+ const result = controller.isDuplicate('brand-new-delivery-999');
104
+ assert.equal(result, false, 'isDuplicate must return false for unseen deliveryId');
105
+ });
106
+
107
+ test('processDelivery queues normalized events', () => {
108
+ const controller = createWebhookController({ secret: SECRET });
109
+ const req = makeWebhookRequest();
110
+ const queued = [];
111
+ controller.onEvent((event) => queued.push(event));
112
+
113
+ const result = controller.processDelivery({
114
+ deliveryId: 'delivery-queue-001',
115
+ eventType: 'pull_request',
116
+ payload: JSON.parse(req.body),
117
+ rawBody: req.body
118
+ });
119
+
120
+ assert.ok(result, 'processDelivery must return a result');
121
+ assert.ok(result.queued !== undefined, 'result must indicate queuing status');
122
+ assert.ok(Array.isArray(queued) || result.queued >= 0, 'events must be queued');
123
+ });
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // SyncController tests
127
+ // ---------------------------------------------------------------------------
128
+
129
+ test('normalizeEvent converts raw provider event to canonical format', () => {
130
+ const controller = createSyncController();
131
+ const rawEvent = {
132
+ eventType: 'pull_request',
133
+ action: 'opened',
134
+ nativeId: 'pr-42',
135
+ providerRef: 'github-provider',
136
+ resourceKind: 'PullRequest',
137
+ data: {
138
+ title: 'Fix bug',
139
+ body: 'Closes #10',
140
+ state: 'open'
141
+ },
142
+ receivedAt: new Date().toISOString()
143
+ };
144
+ const normalized = controller.normalizeEvent(rawEvent);
145
+ assert.ok(normalized, 'normalizeEvent must return a value');
146
+ assert.ok(normalized.eventType, 'normalized event must have eventType');
147
+ assert.ok(normalized.nativeId, 'normalized event must have nativeId');
148
+ assert.ok(normalized.providerRef, 'normalized event must have providerRef');
149
+ assert.ok(normalized.resourceKind, 'normalized event must have resourceKind');
150
+ assert.ok(normalized.canonicalAt, 'normalized event must have canonicalAt timestamp');
151
+ assert.ok(normalized.data, 'normalized event must have data');
152
+ });
153
+
154
+ test('upsertResource creates resource with external envelope (nativeId, url, etag)', () => {
155
+ const controller = createSyncController();
156
+ const resource = controller.upsertResource({
157
+ kind: 'Repository',
158
+ localName: 'org-repo',
159
+ namespace: 'default',
160
+ spec: { organizationRef: 'default', providerRef: 'github-provider' },
161
+ externalEnvelope: {
162
+ nativeId: 'github-repo-12345',
163
+ url: 'https://github.com/org/repo',
164
+ etag: '"abc123"',
165
+ providerRef: 'github-provider'
166
+ }
167
+ });
168
+ assert.ok(resource, 'upsertResource must return a resource');
169
+ assert.ok(resource.metadata, 'resource must have metadata');
170
+ assert.ok(resource.spec, 'resource must have spec');
171
+ assert.ok(resource.status, 'resource must have status');
172
+ assert.ok(resource.status.external, 'resource status must have external envelope');
173
+ assert.equal(resource.status.external.nativeId, 'github-repo-12345', 'nativeId must match');
174
+ assert.equal(resource.status.external.url, 'https://github.com/org/repo', 'url must match');
175
+ assert.equal(resource.status.external.etag, '"abc123"', 'etag must match');
176
+ });
177
+
178
+ test('upsertResource updates existing resource preserving external identity', () => {
179
+ const controller = createSyncController();
180
+ const initial = controller.upsertResource({
181
+ kind: 'Repository',
182
+ localName: 'org-repo',
183
+ namespace: 'default',
184
+ spec: { organizationRef: 'default', providerRef: 'github-provider', description: 'original' },
185
+ externalEnvelope: {
186
+ nativeId: 'github-repo-12345',
187
+ url: 'https://github.com/org/repo',
188
+ etag: '"abc123"',
189
+ providerRef: 'github-provider'
190
+ }
191
+ });
192
+
193
+ const updated = controller.upsertResource({
194
+ kind: 'Repository',
195
+ localName: 'org-repo',
196
+ namespace: 'default',
197
+ spec: { organizationRef: 'default', providerRef: 'github-provider', description: 'updated description' },
198
+ externalEnvelope: {
199
+ nativeId: 'github-repo-12345',
200
+ url: 'https://github.com/org/repo',
201
+ etag: '"def456"',
202
+ providerRef: 'github-provider'
203
+ }
204
+ });
205
+
206
+ assert.equal(updated.status.external.nativeId, 'github-repo-12345', 'nativeId must be preserved on update');
207
+ assert.equal(updated.status.external.etag, '"def456"', 'etag must be updated');
208
+ assert.equal(updated.spec.description, 'updated description', 'spec must be updated');
209
+ assert.ok(updated.status.external.lastSyncedAt, 'lastSyncedAt must be set after update');
210
+ });
211
+
212
+ test('updateWatermark advances the high-watermark timestamp', () => {
213
+ const controller = createSyncController();
214
+ const bindingRef = 'github-binding-001';
215
+ const ts1 = '2024-01-01T10:00:00.000Z';
216
+ const ts2 = '2024-01-01T12:00:00.000Z';
217
+
218
+ controller.updateWatermark(bindingRef, ts1);
219
+ controller.updateWatermark(bindingRef, ts2);
220
+
221
+ const current = controller.getWatermark(bindingRef);
222
+ assert.equal(current, ts2, 'watermark must advance to later timestamp');
223
+ });
224
+
225
+ test('getWatermark returns current watermark for a binding', () => {
226
+ const controller = createSyncController();
227
+ const bindingRef = 'github-binding-002';
228
+
229
+ // No watermark yet
230
+ const initial = controller.getWatermark(bindingRef);
231
+ assert.equal(initial, null, 'watermark must be null before any update');
232
+
233
+ const ts = '2024-06-15T09:30:00.000Z';
234
+ controller.updateWatermark(bindingRef, ts);
235
+ const after = controller.getWatermark(bindingRef);
236
+ assert.equal(after, ts, 'getWatermark must return the set watermark');
237
+ });
238
+
239
+ test('applyOwnershipMode allows write in bidirectional mode', () => {
240
+ const controller = createSyncController();
241
+ const result = controller.applyOwnershipMode({
242
+ ownershipMode: 'bidirectional',
243
+ operation: 'write',
244
+ origin: 'krate'
245
+ });
246
+ assert.equal(result.allowed, true, 'bidirectional mode must allow krate writes');
247
+ assert.ok(!result.reason || result.reason === null, 'no blocking reason expected');
248
+ });
249
+
250
+ test('applyOwnershipMode blocks write in external-owned mode', () => {
251
+ const controller = createSyncController();
252
+ const result = controller.applyOwnershipMode({
253
+ ownershipMode: 'external-owned',
254
+ operation: 'write',
255
+ origin: 'krate'
256
+ });
257
+ assert.equal(result.allowed, false, 'external-owned mode must block krate writes');
258
+ assert.ok(result.reason, 'reason must explain the block');
259
+ assert.match(result.reason, /external-owned|read.?only|blocked/i, 'reason must mention external-owned or read-only');
260
+ });
261
+
262
+ test('applyOwnershipMode allows write in krate-owned mode', () => {
263
+ const controller = createSyncController();
264
+ const result = controller.applyOwnershipMode({
265
+ ownershipMode: 'krate-owned',
266
+ operation: 'write',
267
+ origin: 'krate'
268
+ });
269
+ assert.equal(result.allowed, true, 'krate-owned mode must allow krate writes');
270
+ });
271
+
272
+ test('createTombstone marks deleted external resources', () => {
273
+ const controller = createSyncController();
274
+ const tombstone = controller.createTombstone({
275
+ nativeId: 'github-repo-99999',
276
+ providerRef: 'github-provider',
277
+ resourceKind: 'Repository',
278
+ localRef: 'deleted-repo',
279
+ deletedAt: '2024-07-01T00:00:00.000Z'
280
+ });
281
+ assert.ok(tombstone, 'createTombstone must return a record');
282
+ assert.equal(tombstone.nativeId, 'github-repo-99999', 'nativeId must match');
283
+ assert.equal(tombstone.providerRef, 'github-provider', 'providerRef must match');
284
+ assert.equal(tombstone.resourceKind, 'Repository', 'resourceKind must match');
285
+ assert.equal(tombstone.tombstoned, true, 'tombstoned flag must be true');
286
+ assert.ok(tombstone.deletedAt, 'deletedAt must be present');
287
+ });
@@ -0,0 +1,353 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
4
+ createWriteController,
5
+ getIdempotencyKey,
6
+ validateWriteIntent
7
+ } from '../src/external/write-controller.js';
8
+ import {
9
+ createConflictController,
10
+ validateConflict
11
+ } from '../src/external/conflict-controller.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Acceptance criteria: Slice 3.5 — Write & Conflict Controllers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ function makeWriteController() {
22
+ return createWriteController();
23
+ }
24
+
25
+ function makeConflictController() {
26
+ return createConflictController();
27
+ }
28
+
29
+ function makeWriteIntentInput(overrides = {}) {
30
+ return {
31
+ interfaceKey: 'issueTracking',
32
+ operation: 'createIssue',
33
+ payload: { title: 'Test Issue', body: 'desc' },
34
+ resourceRef: 'org/repo#issue-1',
35
+ requiresApproval: true,
36
+ maxRetries: 3,
37
+ ...overrides
38
+ };
39
+ }
40
+
41
+ function makeConflictInput(overrides = {}) {
42
+ return {
43
+ resourceRef: 'org/repo#issue-1',
44
+ fieldPath: 'spec.title',
45
+ localValue: 'Local Title',
46
+ externalValue: 'External Title',
47
+ ...overrides
48
+ };
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // WRITE CONTROLLER TESTS
53
+ // ---------------------------------------------------------------------------
54
+
55
+ // Test 1
56
+ test('createWriteIntent creates intent with PendingApproval status when approval is required', () => {
57
+ const controller = makeWriteController();
58
+ const result = controller.createWriteIntent(makeWriteIntentInput({ requiresApproval: true }));
59
+
60
+ assert.ok(result.intent, 'must return an intent object');
61
+ assert.equal(result.intent.status.phase, 'PendingApproval', 'phase must be PendingApproval when approval required');
62
+ assert.ok(result.intent.metadata?.name, 'intent must have a name');
63
+ assert.equal(result.intent.spec.interfaceKey, 'issueTracking', 'spec must contain interfaceKey');
64
+ assert.equal(result.intent.spec.operation, 'createIssue', 'spec must contain operation');
65
+ });
66
+
67
+ // Test 2
68
+ test('createWriteIntent with no approval required starts as ReadyToSend', () => {
69
+ const controller = makeWriteController();
70
+ const result = controller.createWriteIntent(makeWriteIntentInput({ requiresApproval: false }));
71
+
72
+ assert.ok(result.intent, 'must return an intent object');
73
+ assert.equal(result.intent.status.phase, 'ReadyToSend', 'phase must be ReadyToSend when no approval required');
74
+ });
75
+
76
+ // Test 3
77
+ test('approveWriteIntent transitions PendingApproval intent to ReadyToSend', () => {
78
+ const controller = makeWriteController();
79
+ const { intent } = controller.createWriteIntent(makeWriteIntentInput({ requiresApproval: true }));
80
+ assert.equal(intent.status.phase, 'PendingApproval');
81
+
82
+ const result = controller.approveWriteIntent({ intentName: intent.metadata.name, approvedBy: 'user-1', resources: { ExternalWriteIntent: [intent] } });
83
+
84
+ assert.ok(!result.error, `must not error: ${result.message}`);
85
+ assert.equal(result.intent.status.phase, 'ReadyToSend', 'approved intent must be ReadyToSend');
86
+ assert.equal(result.intent.status.approvedBy, 'user-1', 'must record approver');
87
+ });
88
+
89
+ // Test 4
90
+ test('rejectWriteIntent transitions PendingApproval intent to Rejected', () => {
91
+ const controller = makeWriteController();
92
+ const { intent } = controller.createWriteIntent(makeWriteIntentInput({ requiresApproval: true }));
93
+
94
+ const result = controller.rejectWriteIntent({ intentName: intent.metadata.name, rejectedBy: 'user-2', reason: 'not allowed', resources: { ExternalWriteIntent: [intent] } });
95
+
96
+ assert.ok(!result.error, `must not error: ${result.message}`);
97
+ assert.equal(result.intent.status.phase, 'Rejected', 'rejected intent must be Rejected');
98
+ assert.equal(result.intent.status.rejectedBy, 'user-2', 'must record rejecter');
99
+ assert.equal(result.intent.status.rejectionReason, 'not allowed', 'must record rejection reason');
100
+ });
101
+
102
+ // Test 5
103
+ test('executeWriteIntent transitions to Sending then Succeeded on success', async () => {
104
+ const controller = makeWriteController();
105
+ const { intent } = controller.createWriteIntent(makeWriteIntentInput({ requiresApproval: false }));
106
+ assert.equal(intent.status.phase, 'ReadyToSend');
107
+
108
+ const phases = [];
109
+ const result = await controller.executeWriteIntent({
110
+ intentName: intent.metadata.name,
111
+ resources: { ExternalWriteIntent: [intent] },
112
+ executor: async () => ({ id: 'issue-123', url: 'https://example.com/issue/123' }),
113
+ onPhaseChange: (phase) => phases.push(phase)
114
+ });
115
+
116
+ assert.ok(!result.error, `must not error: ${result.message}`);
117
+ assert.equal(result.intent.status.phase, 'Succeeded', 'succeeded intent must be Succeeded');
118
+ assert.ok(phases.includes('Sending'), 'must pass through Sending phase');
119
+ assert.ok(result.intent.status.externalResult, 'must record external result');
120
+ });
121
+
122
+ // Test 6
123
+ test('executeWriteIntent with failure transitions to Retrying', async () => {
124
+ const controller = makeWriteController();
125
+ const { intent } = controller.createWriteIntent(makeWriteIntentInput({ requiresApproval: false, maxRetries: 3 }));
126
+
127
+ let callCount = 0;
128
+ const result = await controller.executeWriteIntent({
129
+ intentName: intent.metadata.name,
130
+ resources: { ExternalWriteIntent: [intent] },
131
+ executor: async () => {
132
+ callCount++;
133
+ if (callCount === 1) throw new Error('transient failure');
134
+ return { id: 'issue-456' };
135
+ }
136
+ });
137
+
138
+ assert.ok(!result.error, `must not error: ${result.message}`);
139
+ assert.equal(result.intent.status.phase, 'Succeeded', 'must eventually succeed after retry');
140
+ assert.ok(result.intent.status.retryCount >= 1, 'must record retry count');
141
+ });
142
+
143
+ // Test 7
144
+ test('executeWriteIntent respects maxRetries and transitions to Failed when exhausted', async () => {
145
+ const controller = makeWriteController();
146
+ const { intent } = controller.createWriteIntent(makeWriteIntentInput({ requiresApproval: false, maxRetries: 2 }));
147
+
148
+ const result = await controller.executeWriteIntent({
149
+ intentName: intent.metadata.name,
150
+ resources: { ExternalWriteIntent: [intent] },
151
+ executor: async () => { throw new Error('permanent failure'); }
152
+ });
153
+
154
+ assert.ok(!result.error || result.intent, 'must return intent even on failure');
155
+ assert.equal(result.intent.status.phase, 'Failed', 'must be Failed after exhausting retries');
156
+ assert.ok(result.intent.status.retryCount >= 2, 'must record exhausted retries');
157
+ assert.ok(result.intent.status.lastError, 'must record last error');
158
+ });
159
+
160
+ // Test 8
161
+ test('getIdempotencyKey generates consistent key for same operation', () => {
162
+ const input = { interfaceKey: 'issueTracking', operation: 'createIssue', resourceRef: 'org/repo#issue-1', payload: { title: 'Test' } };
163
+ const key1 = getIdempotencyKey(input);
164
+ const key2 = getIdempotencyKey(input);
165
+
166
+ assert.equal(typeof key1, 'string', 'key must be a string');
167
+ assert.ok(key1.length > 0, 'key must be non-empty');
168
+ assert.equal(key1, key2, 'same input must produce same key');
169
+ });
170
+
171
+ // Test 9 (bonus: different inputs → different keys)
172
+ test('getIdempotencyKey generates different keys for different operations', () => {
173
+ const input1 = { interfaceKey: 'issueTracking', operation: 'createIssue', resourceRef: 'org/repo#issue-1', payload: { title: 'A' } };
174
+ const input2 = { interfaceKey: 'issueTracking', operation: 'createIssue', resourceRef: 'org/repo#issue-2', payload: { title: 'B' } };
175
+ const key1 = getIdempotencyKey(input1);
176
+ const key2 = getIdempotencyKey(input2);
177
+
178
+ assert.notEqual(key1, key2, 'different resourceRefs must produce different keys');
179
+ });
180
+
181
+ // Test 10
182
+ test('validateWriteIntent rejects missing interfaceKey', () => {
183
+ const result = validateWriteIntent({ operation: 'createIssue', payload: {}, resourceRef: 'org/repo#1' });
184
+
185
+ assert.equal(result.valid, false, 'must be invalid');
186
+ assert.ok(result.errors.some((e) => /interface/i.test(e)), 'error must mention interfaceKey');
187
+ });
188
+
189
+ // Test 11
190
+ test('validateWriteIntent rejects missing operation', () => {
191
+ const result = validateWriteIntent({ interfaceKey: 'issueTracking', payload: {}, resourceRef: 'org/repo#1' });
192
+
193
+ assert.equal(result.valid, false, 'must be invalid');
194
+ assert.ok(result.errors.some((e) => /operation/i.test(e)), 'error must mention operation');
195
+ });
196
+
197
+ // Test 12 (bonus)
198
+ test('validateWriteIntent accepts a complete valid intent input', () => {
199
+ const result = validateWriteIntent(makeWriteIntentInput());
200
+
201
+ assert.equal(result.valid, true, 'valid input must pass validation');
202
+ assert.equal(result.errors.length, 0, 'no errors for valid input');
203
+ });
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // CONFLICT CONTROLLER TESTS
207
+ // ---------------------------------------------------------------------------
208
+
209
+ // Test 13
210
+ test('detectConflict creates conflict when local and external values differ', () => {
211
+ const controller = makeConflictController();
212
+ const result = controller.detectConflict(makeConflictInput({ localValue: 'A', externalValue: 'B' }));
213
+
214
+ assert.ok(result.conflict, 'must return a conflict');
215
+ assert.equal(result.conflict.status.phase, 'Open', 'new conflict must be Open');
216
+ assert.equal(result.conflict.spec.localValue, 'A', 'must record local value');
217
+ assert.equal(result.conflict.spec.externalValue, 'B', 'must record external value');
218
+ assert.equal(result.conflict.spec.fieldPath, 'spec.title', 'must record fieldPath');
219
+ assert.equal(result.conflict.spec.resourceRef, 'org/repo#issue-1', 'must record resourceRef');
220
+ });
221
+
222
+ // Test 14
223
+ test('detectConflict returns null when local and external values match', () => {
224
+ const controller = makeConflictController();
225
+ const result = controller.detectConflict(makeConflictInput({ localValue: 'Same', externalValue: 'Same' }));
226
+
227
+ assert.equal(result.conflict, null, 'no conflict when values match');
228
+ });
229
+
230
+ // Test 15
231
+ test('resolveConflict with prefer-external strategy updates local value', () => {
232
+ const controller = makeConflictController();
233
+ const { conflict } = controller.detectConflict(makeConflictInput({ localValue: 'Local', externalValue: 'External' }));
234
+
235
+ const result = controller.resolveConflict({
236
+ conflictName: conflict.metadata.name,
237
+ strategy: 'prefer-external',
238
+ resources: { ExternalSyncConflict: [conflict] }
239
+ });
240
+
241
+ assert.ok(!result.error, `must not error: ${result.message}`);
242
+ assert.equal(result.conflict.status.phase, 'Resolved', 'conflict must be Resolved');
243
+ assert.equal(result.resolution.chosenValue, 'External', 'chosen value must be external value');
244
+ assert.equal(result.resolution.strategy, 'prefer-external', 'must record strategy');
245
+ });
246
+
247
+ // Test 16
248
+ test('resolveConflict with prefer-krate strategy keeps local value', () => {
249
+ const controller = makeConflictController();
250
+ const { conflict } = controller.detectConflict(makeConflictInput({ localValue: 'Local', externalValue: 'External' }));
251
+
252
+ const result = controller.resolveConflict({
253
+ conflictName: conflict.metadata.name,
254
+ strategy: 'prefer-krate',
255
+ resources: { ExternalSyncConflict: [conflict] }
256
+ });
257
+
258
+ assert.ok(!result.error, `must not error: ${result.message}`);
259
+ assert.equal(result.conflict.status.phase, 'Resolved', 'conflict must be Resolved');
260
+ assert.equal(result.resolution.chosenValue, 'Local', 'chosen value must be local value');
261
+ });
262
+
263
+ // Test 17
264
+ test('resolveConflict with manual strategy requires explicit resolution value', () => {
265
+ const controller = makeConflictController();
266
+ const { conflict } = controller.detectConflict(makeConflictInput({ localValue: 'Local', externalValue: 'External' }));
267
+
268
+ const result = controller.resolveConflict({
269
+ conflictName: conflict.metadata.name,
270
+ strategy: 'manual',
271
+ resolvedValue: 'ManualChoice',
272
+ resources: { ExternalSyncConflict: [conflict] }
273
+ });
274
+
275
+ assert.ok(!result.error, `must not error: ${result.message}`);
276
+ assert.equal(result.conflict.status.phase, 'Resolved', 'conflict must be Resolved');
277
+ assert.equal(result.resolution.chosenValue, 'ManualChoice', 'chosen value must be manually provided value');
278
+ });
279
+
280
+ // Test 18
281
+ test('resolveConflict with ignore strategy marks conflict as Ignored', () => {
282
+ const controller = makeConflictController();
283
+ const { conflict } = controller.detectConflict(makeConflictInput());
284
+
285
+ const result = controller.resolveConflict({
286
+ conflictName: conflict.metadata.name,
287
+ strategy: 'ignore',
288
+ resources: { ExternalSyncConflict: [conflict] }
289
+ });
290
+
291
+ assert.ok(!result.error, `must not error: ${result.message}`);
292
+ assert.equal(result.conflict.status.phase, 'Ignored', 'conflict must be Ignored');
293
+ });
294
+
295
+ // Test 19
296
+ test('supersededCheck marks old conflicts as Superseded when new sync arrives for same resource+field', () => {
297
+ const controller = makeConflictController();
298
+ const { conflict: old1 } = controller.detectConflict(makeConflictInput({ localValue: 'v1', externalValue: 'v2' }));
299
+ const { conflict: old2 } = controller.detectConflict(makeConflictInput({ localValue: 'v2', externalValue: 'v3' }));
300
+
301
+ const result = controller.supersededCheck({
302
+ resourceRef: 'org/repo#issue-1',
303
+ fieldPath: 'spec.title',
304
+ resources: { ExternalSyncConflict: [old1, old2] }
305
+ });
306
+
307
+ assert.ok(Array.isArray(result.superseded), 'must return array of superseded conflicts');
308
+ assert.ok(result.superseded.length >= 1, 'must mark at least one conflict as Superseded');
309
+ assert.ok(result.superseded.every((c) => c.status.phase === 'Superseded'), 'all returned items must be Superseded');
310
+ });
311
+
312
+ // Test 20
313
+ test('getOpenConflicts returns only Open (non-resolved) conflicts', () => {
314
+ const controller = makeConflictController();
315
+ const { conflict: c1 } = controller.detectConflict(makeConflictInput({ localValue: 'A', externalValue: 'B', fieldPath: 'spec.title' }));
316
+ const { conflict: c2 } = controller.detectConflict(makeConflictInput({ localValue: 'X', externalValue: 'Y', fieldPath: 'spec.body' }));
317
+
318
+ // Resolve c2
319
+ const resolved = controller.resolveConflict({
320
+ conflictName: c2.metadata.name,
321
+ strategy: 'prefer-external',
322
+ resources: { ExternalSyncConflict: [c1, c2] }
323
+ });
324
+
325
+ const openResult = controller.getOpenConflicts({ resources: { ExternalSyncConflict: [c1, resolved.conflict] } });
326
+ assert.ok(Array.isArray(openResult.conflicts), 'must return an array');
327
+ assert.equal(openResult.conflicts.length, 1, 'must return exactly one open conflict');
328
+ assert.equal(openResult.conflicts[0].metadata.name, c1.metadata.name, 'must return the open conflict');
329
+ });
330
+
331
+ // Test 21
332
+ test('validateConflict rejects missing resourceRef', () => {
333
+ const result = validateConflict({ fieldPath: 'spec.title', localValue: 'A', externalValue: 'B' });
334
+
335
+ assert.equal(result.valid, false, 'must be invalid');
336
+ assert.ok(result.errors.some((e) => /resourceRef/i.test(e)), 'error must mention resourceRef');
337
+ });
338
+
339
+ // Test 22 (bonus)
340
+ test('validateConflict rejects missing fieldPath', () => {
341
+ const result = validateConflict({ resourceRef: 'org/repo#1', localValue: 'A', externalValue: 'B' });
342
+
343
+ assert.equal(result.valid, false, 'must be invalid');
344
+ assert.ok(result.errors.some((e) => /fieldPath/i.test(e)), 'error must mention fieldPath');
345
+ });
346
+
347
+ // Test 23 (bonus)
348
+ test('validateConflict accepts valid conflict input', () => {
349
+ const result = validateConflict(makeConflictInput());
350
+
351
+ assert.equal(result.valid, true, 'valid conflict input must pass');
352
+ assert.equal(result.errors.length, 0, 'no errors for valid input');
353
+ });