@a5c-ai/krate 5.0.1-staging.660d2b90f → 5.0.1-staging.69cb593ea

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 (118) hide show
  1. package/Dockerfile +31 -29
  2. package/bin/krate-demo.mjs +0 -0
  3. package/bin/krate-server.mjs +0 -0
  4. package/dist/krate-controller-ui.json +808 -10
  5. package/dist/krate-lifecycle.json +1 -1
  6. package/dist/krate-runtime-snapshot.json +223 -53
  7. package/dist/krate-summary.json +40 -3
  8. package/docs/agents/gaps-agent-mux-to-krate-crds.md +298 -0
  9. package/docs/architecture-v2.md +431 -0
  10. package/docs/openapi.yaml +1275 -0
  11. package/docs/requirements-v2.md +238 -0
  12. package/docs/sdk-api-reference.md +782 -0
  13. package/docs/system-spec-v2.md +352 -0
  14. package/docs/todos.md +4 -0
  15. package/docs/web-console-spec.md +433 -0
  16. package/package.json +1 -1
  17. package/scripts/validate-ui.mjs +305 -207
  18. package/src/agent-adapter-controller.js +169 -0
  19. package/src/agent-approval-controller.js +47 -0
  20. package/src/agent-dispatch-controller.js +130 -7
  21. package/src/agent-gateway-config-controller.js +147 -0
  22. package/src/agent-memory-controller.js +357 -0
  23. package/src/agent-memory-import.js +327 -0
  24. package/src/agent-memory-query.js +292 -0
  25. package/src/agent-memory-repository-source-controller.js +255 -0
  26. package/src/agent-mux-client.js +1 -1
  27. package/src/agent-permission-review.js +102 -14
  28. package/src/agent-project-controller.js +117 -0
  29. package/src/agent-provider-config-controller.js +150 -0
  30. package/src/agent-secret-config-grant-controller.js +282 -0
  31. package/src/agent-session-transcript-controller.js +189 -0
  32. package/src/agent-stack-controller.js +52 -1
  33. package/src/agent-subagent-controller.js +160 -0
  34. package/src/agent-transport-binding-controller.js +121 -0
  35. package/src/agent-trigger-controller.js +273 -0
  36. package/src/agent-workspace-controller.js +702 -0
  37. package/src/agent-writeback-controller.js +302 -0
  38. package/src/api-controller.js +338 -3
  39. package/src/async-controller.js +207 -0
  40. package/src/audit-controller.js +191 -0
  41. package/src/auth.js +48 -6
  42. package/src/controller-client.js +112 -38
  43. package/src/controller-ui.js +96 -16
  44. package/src/data-plane.js +3 -2
  45. package/src/event-bus.js +61 -0
  46. package/src/external/conflict-controller.js +225 -0
  47. package/src/external/github/auth.js +96 -0
  48. package/src/external/github/cicd.js +180 -0
  49. package/src/external/github/git-forge.js +240 -0
  50. package/src/external/github/index.js +144 -0
  51. package/src/external/github/issue-tracking.js +163 -0
  52. package/src/external/provider-adapter.js +161 -0
  53. package/src/external/provider-resource-factory.js +161 -0
  54. package/src/external/sync-controller.js +235 -0
  55. package/src/external/webhook-controller.js +144 -0
  56. package/src/external/write-controller.js +283 -0
  57. package/src/gitea-backend.js +36 -0
  58. package/src/gitea-service.js +173 -0
  59. package/src/http-server.js +226 -0
  60. package/src/index.js +27 -0
  61. package/src/kubernetes-controller-async.js +531 -0
  62. package/src/kubernetes-controller.js +156 -84
  63. package/src/notification-controller.js +178 -0
  64. package/src/org-scoping.js +5 -0
  65. package/src/resource-model.js +26 -8
  66. package/src/runner-controller.js +272 -0
  67. package/src/snapshot-cache.js +157 -0
  68. package/tests/agent-adapter-controller.test.js +361 -0
  69. package/tests/agent-dispatch-controller.test.js +139 -0
  70. package/tests/agent-gateway-config-controller.test.js +386 -0
  71. package/tests/agent-memory-controller.test.js +308 -0
  72. package/tests/agent-memory-import-snapshot.test.js +477 -0
  73. package/tests/agent-memory-query.test.js +404 -0
  74. package/tests/agent-memory-repository-source.test.js +514 -0
  75. package/tests/agent-permission-review-v2.test.js +317 -0
  76. package/tests/agent-project-controller.test.js +302 -0
  77. package/tests/agent-provider-config-controller.test.js +376 -0
  78. package/tests/agent-resources.test.js +35 -19
  79. package/tests/agent-secret-config-grant.test.js +231 -0
  80. package/tests/agent-session-transcript-controller.test.js +499 -0
  81. package/tests/agent-subagent-controller.test.js +201 -0
  82. package/tests/agent-transport-binding-controller.test.js +294 -0
  83. package/tests/agent-trigger-routes.test.js +190 -0
  84. package/tests/agent-trigger-sources.test.js +245 -0
  85. package/tests/agent-workspace-controller.test.js +181 -0
  86. package/tests/agent-writeback.test.js +292 -0
  87. package/tests/approval-persistence.test.js +171 -0
  88. package/tests/async-controller.test.js +252 -0
  89. package/tests/audit-controller.test.js +227 -0
  90. package/tests/codespace-controller.test.js +318 -0
  91. package/tests/controller-client.test.js +133 -0
  92. package/tests/deployment.test.js +43 -29
  93. package/tests/e2e/lifecycle.test.js +5 -2
  94. package/tests/event-bus-integration.test.js +190 -0
  95. package/tests/external-github-forge.test.js +560 -0
  96. package/tests/external-github-issues-cicd.test.js +520 -0
  97. package/tests/external-integration.test.js +470 -0
  98. package/tests/external-persistence.test.js +340 -0
  99. package/tests/external-provider-adapter.test.js +365 -0
  100. package/tests/external-resource-model.test.js +215 -0
  101. package/tests/external-webhook-sync.test.js +287 -0
  102. package/tests/external-write-conflict.test.js +353 -0
  103. package/tests/gitea-service.test.js +253 -0
  104. package/tests/health-check-real.test.js +165 -0
  105. package/tests/integration/full-flow.test.js +266 -0
  106. package/tests/krate.test.js +58 -6
  107. package/tests/memory-search-wiring.test.js +270 -0
  108. package/tests/notification-controller.test.js +196 -0
  109. package/tests/notification-integration.test.js +179 -0
  110. package/tests/org-scoping.test.js +687 -0
  111. package/tests/runner-controller.test.js +327 -0
  112. package/tests/runner-integration.test.js +231 -0
  113. package/tests/session-cookie-hmac.test.js +151 -0
  114. package/tests/snapshot-performance.test.js +315 -0
  115. package/tests/sse-events.test.js +107 -0
  116. package/tests/webhook-trigger.test.js +198 -0
  117. package/tests/workspace-volumes.test.js +312 -0
  118. package/tests/writeback-persistence.test.js +207 -0
@@ -0,0 +1,302 @@
1
+ import { createResource, clone } from './resource-model.js';
2
+
3
+ export const AGENT_WRITEBACK_CONTROLLER_BOUNDARY = {
4
+ role: 'agent-writeback-controller',
5
+ scope: 'KrateArtifact write pipeline, branch push approval flow, and PR merge with status check enforcement',
6
+ owns: ['artifact creation', 'artifact listing', 'branch push requests', 'push approval/denial', 'PR merge requests', 'write intent validation', 'write intent status'],
7
+ delegatesTo: ['resource-model'],
8
+ mustNotOwn: ['git operations', 'secret values', 'agent execution', 'CI orchestration']
9
+ };
10
+
11
+ const VALID_WRITE_TYPES = new Set(['artifact', 'branch-push', 'pr-merge']);
12
+
13
+ /**
14
+ * In-memory store of AgentWriteIntent records (plain objects, not K8s CRDs,
15
+ * because AgentWriteIntent is not in the canonical resource taxonomy).
16
+ * Controllers operate statelessly against caller-supplied resources maps;
17
+ * the write intent shape mirrors what would live in an aggregated store.
18
+ */
19
+
20
+ export function createAgentWritebackController() {
21
+ return {
22
+ role: 'agent-writeback-controller',
23
+
24
+ // -----------------------------------------------------------------------
25
+ // Artifacts
26
+ // -----------------------------------------------------------------------
27
+
28
+ createArtifact({ name, runRef, contentRef, kind = 'output', namespace = 'default', organizationRef = 'default' }) {
29
+ if (!runRef) {
30
+ return { error: true, reason: 'missing-run-ref', message: 'runRef is required to link an artifact to a dispatch run' };
31
+ }
32
+ if (!contentRef) {
33
+ return { error: true, reason: 'missing-content-ref', message: 'contentRef is required to identify the artifact content' };
34
+ }
35
+
36
+ const now = new Date().toISOString();
37
+ const artifactName = name || `artifact-${runRef}-${Date.now()}`;
38
+
39
+ const artifact = createResource('KrateArtifact', { name: artifactName, namespace }, {
40
+ organizationRef,
41
+ dispatchRun: runRef,
42
+ kind,
43
+ digest: contentRef
44
+ });
45
+ artifact.status = { phase: 'Available', createdAt: now };
46
+
47
+ return { error: false, artifact };
48
+ },
49
+
50
+ validateArtifact({ artifact }) {
51
+ if (!artifact || typeof artifact !== 'object') {
52
+ return { valid: false, error: true, reason: 'invalid-artifact', message: 'artifact must be an object' };
53
+ }
54
+ if (artifact.kind !== 'KrateArtifact') {
55
+ return { valid: false, error: true, reason: 'wrong-kind', message: `Expected KrateArtifact, got: ${artifact.kind}` };
56
+ }
57
+ if (!artifact.spec?.dispatchRun) {
58
+ return { valid: false, error: true, reason: 'missing-run-ref', message: 'artifact spec.dispatchRun is required' };
59
+ }
60
+ if (!artifact.spec?.digest) {
61
+ return { valid: false, error: true, reason: 'missing-digest', message: 'artifact spec.digest is required' };
62
+ }
63
+ return { valid: true, error: false };
64
+ },
65
+
66
+ listArtifactsForRun({ runRef, resources = {} }) {
67
+ const artifacts = resources.KrateArtifact || [];
68
+ return artifacts.filter((a) => a.spec?.dispatchRun === runRef).map(clone);
69
+ },
70
+
71
+ // -----------------------------------------------------------------------
72
+ // Branch push approval flow
73
+ // -----------------------------------------------------------------------
74
+
75
+ requestBranchPush({ runRef, branch, targetRepo, requestedBy, namespace = 'default', organizationRef = 'default' }) {
76
+ if (!runRef) {
77
+ return { error: true, reason: 'missing-run-ref', message: 'runRef is required' };
78
+ }
79
+ if (!branch) {
80
+ return { error: true, reason: 'missing-branch', message: 'branch is required' };
81
+ }
82
+ if (!targetRepo) {
83
+ return { error: true, reason: 'missing-target-repo', message: 'targetRepo is required' };
84
+ }
85
+
86
+ const now = new Date().toISOString();
87
+ const intentName = `push-${runRef}-${Date.now()}`;
88
+
89
+ const pushRequest = {
90
+ kind: 'AgentWriteIntent',
91
+ metadata: { name: intentName, namespace, labels: {}, annotations: {} },
92
+ spec: { organizationRef, runRef, branch, targetRepo, writeType: 'branch-push', requestedBy: requestedBy || 'unknown', requestedAt: now },
93
+ status: { approvalStatus: 'pending', phase: 'Pending', createdAt: now }
94
+ };
95
+
96
+ return { error: false, pushRequest };
97
+ },
98
+
99
+ approveBranchPush({ intentName, approvedBy, reason, resources = {} }) {
100
+ if (!intentName) {
101
+ return { error: true, reason: 'missing-intent-name', message: 'intentName is required' };
102
+ }
103
+ if (!approvedBy) {
104
+ return { error: true, reason: 'missing-approved-by', message: 'approvedBy is required' };
105
+ }
106
+
107
+ const intents = resources.AgentWriteIntent || [];
108
+ const intent = intents.find((i) => i.metadata?.name === intentName);
109
+ if (!intent) {
110
+ return { error: true, reason: 'not-found', message: `AgentWriteIntent not found: ${intentName}` };
111
+ }
112
+ if (intent.status?.approvalStatus !== 'pending') {
113
+ return { error: true, reason: 'already-decided', message: `AgentWriteIntent ${intentName} has already been decided: ${intent.status?.approvalStatus}` };
114
+ }
115
+
116
+ const now = new Date().toISOString();
117
+ const pushRequest = clone(intent);
118
+ pushRequest.status = {
119
+ ...pushRequest.status,
120
+ approvalStatus: 'approved',
121
+ phase: 'Approved',
122
+ approvedBy,
123
+ approvedAt: now,
124
+ reason: reason || undefined
125
+ };
126
+
127
+ return { error: false, pushRequest };
128
+ },
129
+
130
+ denyBranchPush({ intentName, deniedBy, reason, resources = {} }) {
131
+ if (!intentName) {
132
+ return { error: true, reason: 'missing-intent-name', message: 'intentName is required' };
133
+ }
134
+ if (!deniedBy) {
135
+ return { error: true, reason: 'missing-denied-by', message: 'deniedBy is required' };
136
+ }
137
+
138
+ const intents = resources.AgentWriteIntent || [];
139
+ const intent = intents.find((i) => i.metadata?.name === intentName);
140
+ if (!intent) {
141
+ return { error: true, reason: 'not-found', message: `AgentWriteIntent not found: ${intentName}` };
142
+ }
143
+ if (intent.status?.approvalStatus !== 'pending') {
144
+ return { error: true, reason: 'already-decided', message: `AgentWriteIntent ${intentName} has already been decided: ${intent.status?.approvalStatus}` };
145
+ }
146
+
147
+ const now = new Date().toISOString();
148
+ const pushRequest = clone(intent);
149
+ pushRequest.status = {
150
+ ...pushRequest.status,
151
+ approvalStatus: 'denied',
152
+ phase: 'Denied',
153
+ deniedBy,
154
+ deniedAt: now,
155
+ reason: reason || undefined
156
+ };
157
+
158
+ return { error: false, pushRequest };
159
+ },
160
+
161
+ // -----------------------------------------------------------------------
162
+ // PR merge with status check enforcement
163
+ // -----------------------------------------------------------------------
164
+
165
+ requestPrMerge({ runRef, prRef, statusChecks = [], requestedBy, namespace = 'default', organizationRef = 'default' }) {
166
+ if (!runRef) {
167
+ return { error: true, reason: 'missing-run-ref', message: 'runRef is required' };
168
+ }
169
+ if (!prRef) {
170
+ return { error: true, reason: 'missing-pr-ref', message: 'prRef is required' };
171
+ }
172
+
173
+ // Enforce status check gate — reject if any check is not 'success'
174
+ const failing = statusChecks.filter((c) => c.state !== 'success');
175
+ if (failing.length > 0) {
176
+ const names = failing.map((c) => c.name).join(', ');
177
+ return { error: true, reason: 'status-checks-failing', message: `The following status checks are not passing: ${names}` };
178
+ }
179
+
180
+ const now = new Date().toISOString();
181
+ const intentName = `merge-${runRef}-${Date.now()}`;
182
+
183
+ const mergeRequest = {
184
+ kind: 'AgentWriteIntent',
185
+ metadata: { name: intentName, namespace, labels: {}, annotations: {} },
186
+ spec: { organizationRef, runRef, prRef, writeType: 'pr-merge', statusChecks: clone(statusChecks), requestedBy: requestedBy || 'unknown', requestedAt: now },
187
+ status: { approvalStatus: 'pending', phase: 'Pending', createdAt: now }
188
+ };
189
+
190
+ return { error: false, mergeRequest };
191
+ },
192
+
193
+ // -----------------------------------------------------------------------
194
+ // Write intent validation & status
195
+ // -----------------------------------------------------------------------
196
+
197
+ validateWriteIntent({ intent }) {
198
+ if (!intent || typeof intent !== 'object') {
199
+ return { valid: false, error: true, reason: 'invalid-intent', message: 'intent must be an object' };
200
+ }
201
+ const writeType = intent.spec?.writeType;
202
+ if (!writeType || !VALID_WRITE_TYPES.has(writeType)) {
203
+ return { valid: false, error: true, reason: 'invalid-write-type', message: `Unknown write type: ${writeType}. Must be one of: ${[...VALID_WRITE_TYPES].join(', ')}` };
204
+ }
205
+ if (!intent.spec?.runRef) {
206
+ return { valid: false, error: true, reason: 'missing-run-ref', message: 'intent spec.runRef is required' };
207
+ }
208
+ return { valid: true, error: false };
209
+ },
210
+
211
+ getWriteIntentStatus({ intentName, resources = {} }) {
212
+ if (!intentName) {
213
+ return { error: true, reason: 'missing-intent-name', message: 'intentName is required' };
214
+ }
215
+
216
+ const intents = resources.AgentWriteIntent || [];
217
+ const intent = intents.find((i) => i.metadata?.name === intentName);
218
+ if (!intent) {
219
+ return { error: true, reason: 'not-found', message: `AgentWriteIntent not found: ${intentName}` };
220
+ }
221
+
222
+ return {
223
+ error: false,
224
+ intent: clone(intent),
225
+ approvalStatus: intent.status?.approvalStatus || 'pending',
226
+ phase: intent.status?.phase || 'Pending',
227
+ approvedBy: intent.status?.approvedBy || null,
228
+ deniedBy: intent.status?.deniedBy || null,
229
+ approvedAt: intent.status?.approvedAt || null,
230
+ deniedAt: intent.status?.deniedAt || null,
231
+ reason: intent.status?.reason || null
232
+ };
233
+ },
234
+
235
+ // -----------------------------------------------------------------------
236
+ // B2: Persistence
237
+ // -----------------------------------------------------------------------
238
+
239
+ async persistWriteIntent({ intent, applyResource }) {
240
+ if (!intent) {
241
+ return { error: true, reason: 'missing-intent', message: 'intent is required' };
242
+ }
243
+ if (typeof applyResource !== 'function') {
244
+ return { error: true, reason: 'missing-apply-resource', message: 'applyResource function is required' };
245
+ }
246
+ try {
247
+ const applyResult = await applyResource(intent);
248
+ return { error: false, intent, applyResult };
249
+ } catch (err) {
250
+ return { error: true, reason: 'persist-failed', message: err?.message || 'applyResource failed' };
251
+ }
252
+ },
253
+
254
+ // -----------------------------------------------------------------------
255
+ // B2: Execution pipeline
256
+ // -----------------------------------------------------------------------
257
+
258
+ async executeWriteIntent({ intent, gateway }) {
259
+ if (!intent) {
260
+ return { error: true, reason: 'missing-intent', message: 'intent is required' };
261
+ }
262
+
263
+ const approvalStatus = intent.status?.approvalStatus;
264
+ if (approvalStatus !== 'approved') {
265
+ return { error: true, reason: 'not-approved', message: `WriteIntent is not approved (current status: ${approvalStatus})` };
266
+ }
267
+
268
+ const writeType = intent.spec?.writeType;
269
+
270
+ try {
271
+ if (writeType === 'branch-push') {
272
+ const executionResult = await gateway.pushBranch({
273
+ branch: intent.spec.branch,
274
+ targetRepo: intent.spec.targetRepo,
275
+ runRef: intent.spec.runRef
276
+ });
277
+ return { error: false, executionResult };
278
+ }
279
+
280
+ if (writeType === 'pr-merge') {
281
+ // Validate status checks before merging
282
+ const statusChecks = intent.spec?.statusChecks || [];
283
+ const failing = statusChecks.filter((c) => c.state !== 'success');
284
+ if (failing.length > 0) {
285
+ const names = failing.map((c) => c.name).join(', ');
286
+ return { error: true, reason: 'status-checks-failing', message: `The following status checks are not passing: ${names}` };
287
+ }
288
+
289
+ const executionResult = await gateway.mergePr({
290
+ prRef: intent.spec.prRef,
291
+ runRef: intent.spec.runRef
292
+ });
293
+ return { error: false, executionResult };
294
+ }
295
+
296
+ return { error: true, reason: 'unsupported-write-type', message: `Execution not supported for writeType: ${writeType}` };
297
+ } catch (err) {
298
+ return { error: true, reason: 'execution-failed', message: err?.message || 'Gateway execution failed' };
299
+ }
300
+ }
301
+ };
302
+ }
@@ -1,8 +1,17 @@
1
1
  import { createKubernetesResourceGateway } from './kubernetes-resource-gateway.js';
2
+ import { clearSnapshotCache } from './snapshot-cache.js';
2
3
  import { createPermissionReviewer } from './agent-permission-review.js';
3
4
  import { createAgentDispatchController } from './agent-dispatch-controller.js';
4
5
  import { createAgentApprovalController } from './agent-approval-controller.js';
5
6
  import { createAgentTriggerController } from './agent-trigger-controller.js';
7
+ import { createAgentWorkspaceController } from './agent-workspace-controller.js';
8
+ import { createAgentMemoryController } from './agent-memory-controller.js';
9
+ import { orgNamespaceName, normalizeOrgSlug } from './org-scoping.js';
10
+ import { globalEventBus } from './event-bus.js';
11
+ import { createSyncController } from './external/sync-controller.js';
12
+ import { createWebhookController } from './external/webhook-controller.js';
13
+ import { createWriteController } from './external/write-controller.js';
14
+ import { createConflictController } from './external/conflict-controller.js';
6
15
 
7
16
  export const KRATE_API_CONTROLLER_BOUNDARY = {
8
17
  role: 'krate-api-controller',
@@ -15,6 +24,24 @@ export const KRATE_API_CONTROLLER_BOUNDARY = {
15
24
  export function createKrateApiController(options = {}) {
16
25
  const resourceGateway = options.resourceGateway || createKubernetesResourceGateway(options);
17
26
  const namespace = options.namespace || resourceGateway.namespace || process.env.KRATE_NAMESPACE || 'krate-system';
27
+ const onAuditEvent = typeof options.onAuditEvent === 'function' ? options.onAuditEvent : null;
28
+
29
+ function emitAuditEvent(resource, operation) {
30
+ if (!onAuditEvent) return;
31
+ try {
32
+ const org = resource.spec?.organizationRef || resource.metadata?.labels?.['krate.a5c.ai/org'] || '';
33
+ onAuditEvent({
34
+ operation,
35
+ org,
36
+ namespace: org ? orgNamespaceName(org) : (resource.metadata?.namespace || namespace),
37
+ kind: resource.kind,
38
+ name: resource.metadata?.name,
39
+ timestamp: new Date().toISOString()
40
+ });
41
+ } catch {
42
+ // Audit failures must not crash apply operations
43
+ }
44
+ }
18
45
 
19
46
  return {
20
47
  role: 'krate-api-controller',
@@ -36,18 +63,133 @@ export function createKrateApiController(options = {}) {
36
63
  async listResource(kindOrPlural) {
37
64
  return resourceGateway.list(kindOrPlural);
38
65
  },
66
+ async listResourceForOrg(org, kindOrPlural) {
67
+ const orgNs = orgNamespaceName(normalizeOrgSlug(org));
68
+ // Client-side filtering is used because the resource gateway's list()
69
+ // method does not currently support namespace-scoped listing. The
70
+ // gateway aggregates resources across namespaces at snapshot time, so
71
+ // filtering here is both correct and consistent with the gateway API.
72
+ const result = await resourceGateway.list(kindOrPlural);
73
+ const items = normalizeResourceList(result).filter(
74
+ (item) => item.metadata?.namespace === orgNs
75
+ );
76
+ return { ...result, items };
77
+ },
39
78
  async getResource(kindOrPlural, name) {
40
79
  return resourceGateway.get(kindOrPlural, name);
41
80
  },
42
81
  async applyResource(resource) {
43
- return resourceGateway.apply(resource);
82
+ // Cross-org admission check: if the resource has an organizationRef,
83
+ // ensure the namespace matches the org's derived namespace.
84
+ const resourceOrg = resource.spec?.organizationRef;
85
+ const resourceNs = resource.metadata?.namespace;
86
+ if (resourceOrg) {
87
+ const expectedNs = orgNamespaceName(resourceOrg);
88
+ if (resourceNs && resourceNs !== expectedNs) {
89
+ // Explicit namespace does not match the org — reject
90
+ throw new Error(
91
+ `Cross-org namespace mismatch: resource organizationRef "${resourceOrg}" expects namespace "${expectedNs}" but got "${resourceNs}"`
92
+ );
93
+ }
94
+ if (!resourceNs) {
95
+ // organizationRef present but no namespace — auto-assign
96
+ resource = {
97
+ ...resource,
98
+ metadata: { ...resource.metadata, namespace: expectedNs }
99
+ };
100
+ }
101
+ }
102
+ const result = await resourceGateway.apply(resource);
103
+ clearSnapshotCache();
104
+ const appliedResource = result.resource || resource;
105
+ emitAuditEvent(appliedResource, result.operation || 'apply');
106
+ globalEventBus.emitResourceChange(
107
+ appliedResource.kind || resource.kind || 'Unknown',
108
+ appliedResource.metadata?.name || resource.metadata?.name || 'unknown',
109
+ result.operation || 'apply'
110
+ );
111
+ return result;
112
+ },
113
+ async applyResourceForOrg(orgSlug, resource) {
114
+ const slug = normalizeOrgSlug(orgSlug);
115
+ const orgNs = orgNamespaceName(slug);
116
+ const resourceOrg = resource.spec?.organizationRef;
117
+ if (resourceOrg && normalizeOrgSlug(resourceOrg) !== slug) {
118
+ throw new Error(
119
+ `Org mismatch: resource organizationRef "${resourceOrg}" does not match target org "${slug}"`
120
+ );
121
+ }
122
+ const scopedResource = {
123
+ ...resource,
124
+ metadata: { ...resource.metadata, namespace: orgNs },
125
+ spec: { ...resource.spec, organizationRef: slug }
126
+ };
127
+ const result = await resourceGateway.apply(scopedResource);
128
+ clearSnapshotCache();
129
+ const appliedResource = result.resource || scopedResource;
130
+ emitAuditEvent(appliedResource, result.operation || 'apply');
131
+ globalEventBus.emitResourceChange(
132
+ appliedResource.kind || scopedResource.kind || 'Unknown',
133
+ appliedResource.metadata?.name || scopedResource.metadata?.name || 'unknown',
134
+ result.operation || 'apply'
135
+ );
136
+ return { ...result, resource: appliedResource };
44
137
  },
45
138
  async deleteResource(kindOrPlural, name) {
46
- return resourceGateway.delete(kindOrPlural, name);
139
+ const result = await resourceGateway.delete(kindOrPlural, name);
140
+ clearSnapshotCache();
141
+ emitAuditEvent(
142
+ { kind: kindOrPlural, metadata: { name, namespace }, spec: {} },
143
+ 'delete'
144
+ );
145
+ globalEventBus.emitResourceChange(kindOrPlural, name, 'delete');
146
+ return result;
147
+ },
148
+ async deleteResourceForOrg(orgSlug, kindOrPlural, name) {
149
+ const slug = normalizeOrgSlug(orgSlug);
150
+ const orgNs = orgNamespaceName(slug);
151
+ // Verify the resource exists and belongs to the org before deleting
152
+ const existing = await resourceGateway.get(kindOrPlural, name);
153
+ const resource = existing?.resource || existing;
154
+ if (resource) {
155
+ const resourceNs = resource.metadata?.namespace;
156
+ if (!resourceNs || resourceNs !== orgNs) {
157
+ throw new Error(
158
+ `Cross-org denial: resource "${name}" is in namespace "${resourceNs || '(none)'}" which does not match org "${slug}" namespace "${orgNs}"`
159
+ );
160
+ }
161
+ }
162
+ const result = await resourceGateway.delete(kindOrPlural, name);
163
+ clearSnapshotCache();
164
+ emitAuditEvent(
165
+ { kind: kindOrPlural, metadata: { name, namespace: orgNs }, spec: { organizationRef: slug } },
166
+ 'delete'
167
+ );
168
+ globalEventBus.emitResourceChange(kindOrPlural, name, 'delete');
169
+ return result;
170
+ },
171
+ async getResourceForOrg(orgSlug, kindOrPlural, name) {
172
+ const slug = normalizeOrgSlug(orgSlug);
173
+ const orgNs = orgNamespaceName(slug);
174
+ const existing = await resourceGateway.get(kindOrPlural, name);
175
+ const resource = existing?.resource || existing;
176
+ if (resource) {
177
+ const resourceNs = resource.metadata?.namespace;
178
+ if (!resourceNs || resourceNs !== orgNs) {
179
+ throw new Error(
180
+ `Cross-org denial: resource "${name}" is in namespace "${resourceNs || '(none)'}" which does not match org "${slug}" namespace "${orgNs}"`
181
+ );
182
+ }
183
+ }
184
+ return existing;
47
185
  },
48
186
  async createRepository(input) {
49
187
  const created = await resourceGateway.createRepository(input);
50
188
  const repository = created?.resource || created;
189
+ emitAuditEvent(
190
+ repository?.kind ? repository : { kind: 'Repository', metadata: repository?.metadata || { name: input.name || input.metadata?.name }, spec: repository?.spec || input.spec || input },
191
+ 'create-repository'
192
+ );
51
193
  return {
52
194
  operation: created?.operation || 'create-repository',
53
195
  command: created?.command || 'kubectl apply -f -',
@@ -56,7 +198,13 @@ export function createKrateApiController(options = {}) {
56
198
  };
57
199
  },
58
200
  async createOrganization(input) {
59
- return resourceGateway.createOrganization(input);
201
+ const result = await resourceGateway.createOrganization(input);
202
+ const orgResource = result?.organization || result?.resource || result;
203
+ emitAuditEvent(
204
+ orgResource?.kind ? orgResource : { kind: 'Organization', metadata: orgResource?.metadata || { name: input.slug || input.name || input.metadata?.name }, spec: orgResource?.spec || input.spec || input },
205
+ 'create-organization'
206
+ );
207
+ return result;
60
208
  },
61
209
  watchResource(resourcePath, handlers = {}) {
62
210
  return resourceGateway.watch(resourcePath, handlers);
@@ -105,6 +253,193 @@ export function createKrateApiController(options = {}) {
105
253
  namespace: input.namespace || namespace,
106
254
  organizationRef: input.organizationRef || 'default',
107
255
  });
256
+ },
257
+ async provisionAgentWorkspace(input) {
258
+ const workspaceController = createAgentWorkspaceController();
259
+ return workspaceController.provisionWorkspace({
260
+ ...input,
261
+ namespace: input.namespace || namespace,
262
+ organizationRef: input.organizationRef || 'default'
263
+ });
264
+ },
265
+ async archiveAgentWorkspace(input) {
266
+ const snapshot = await this.snapshot();
267
+ const workspaceController = createAgentWorkspaceController();
268
+ return workspaceController.archiveWorkspace({
269
+ ...input,
270
+ resources: snapshot.resources
271
+ });
272
+ },
273
+ async linkWorkItem(input) {
274
+ const workspaceController = createAgentWorkspaceController();
275
+ return workspaceController.linkWorkItem({
276
+ ...input,
277
+ namespace: input.namespace || namespace,
278
+ organizationRef: input.organizationRef || 'default'
279
+ });
280
+ },
281
+ async queryAgentMemory(input) {
282
+ const memoryController = createAgentMemoryController();
283
+ return memoryController.queryMemory({
284
+ ...input,
285
+ namespace: input.namespace || namespace,
286
+ organizationRef: input.organizationRef || 'default'
287
+ });
288
+ },
289
+ async createMemoryImport(input) {
290
+ const memoryController = createAgentMemoryController();
291
+ return memoryController.createImport({
292
+ ...input,
293
+ namespace: input.namespace || namespace,
294
+ organizationRef: input.organizationRef || 'default'
295
+ });
296
+ },
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // External controller integration
300
+ // ---------------------------------------------------------------------------
301
+
302
+ /**
303
+ * Sync an external resource into the Krate resource store.
304
+ * Creates a SyncController with a persistFn that calls applyResource, then
305
+ * upserts the resource and optionally advances the watermark.
306
+ *
307
+ * @param {string} bindingName
308
+ * @param {{ kind, localName, namespace?, spec, externalEnvelope, watermark? }} options
309
+ */
310
+ async syncExternalBinding(bindingName, options = {}) {
311
+ const self = this;
312
+ const syncController = createSyncController({
313
+ persistFn: async (resource) => {
314
+ await resourceGateway.apply(resource);
315
+ }
316
+ });
317
+
318
+ const {
319
+ kind,
320
+ localName,
321
+ namespace: resourceNamespace = 'default',
322
+ spec = {},
323
+ externalEnvelope,
324
+ watermark
325
+ } = options;
326
+
327
+ const resource = syncController.upsertResource({
328
+ kind,
329
+ localName,
330
+ namespace: resourceNamespace,
331
+ spec,
332
+ externalEnvelope
333
+ });
334
+
335
+ if (watermark) {
336
+ syncController.updateWatermark(bindingName, watermark);
337
+ }
338
+
339
+ // Keep a reference to the sync controller so getExternalSyncStatus can read it
340
+ if (!self._syncControllers) self._syncControllers = new Map();
341
+ self._syncControllers.set(bindingName, syncController);
342
+
343
+ return { resource, bindingName };
344
+ },
345
+
346
+ /**
347
+ * Create a write intent for an external operation.
348
+ *
349
+ * @param {{ interfaceKey, operation, payload?, resourceRef, requiresApproval?,
350
+ * maxRetries?, namespace?, organizationRef? }} input
351
+ */
352
+ async createExternalWriteIntent(input) {
353
+ const writeController = createWriteController({
354
+ persistFn: async (resource) => {
355
+ await resourceGateway.apply(resource);
356
+ }
357
+ });
358
+ return writeController.createWriteIntent(input);
359
+ },
360
+
361
+ /**
362
+ * Approve a PendingApproval ExternalWriteIntent.
363
+ *
364
+ * @param {{ intentName, approvedBy, resources? }} opts
365
+ */
366
+ async approveExternalWriteIntent(opts) {
367
+ const writeController = createWriteController({
368
+ persistFn: async (resource) => {
369
+ await resourceGateway.apply(resource);
370
+ }
371
+ });
372
+ return writeController.approveWriteIntent(opts);
373
+ },
374
+
375
+ /**
376
+ * Cancel (reject) an ExternalWriteIntent.
377
+ *
378
+ * @param {{ intentName, cancelledBy, resources? }} opts
379
+ */
380
+ async cancelExternalWriteIntent({ intentName, cancelledBy, resources } = {}) {
381
+ const writeController = createWriteController({
382
+ persistFn: async (resource) => {
383
+ await resourceGateway.apply(resource);
384
+ }
385
+ });
386
+ return writeController.rejectWriteIntent({
387
+ intentName,
388
+ rejectedBy: cancelledBy,
389
+ reason: 'cancelled',
390
+ resources
391
+ });
392
+ },
393
+
394
+ /**
395
+ * Detect a conflict between local and external field values.
396
+ *
397
+ * @param {{ resourceRef, fieldPath, localValue, externalValue, namespace?, organizationRef? }} input
398
+ */
399
+ async detectExternalConflict(input) {
400
+ const conflictController = createConflictController({
401
+ persistFn: async (resource) => {
402
+ await resourceGateway.apply(resource);
403
+ }
404
+ });
405
+ return conflictController.detectConflict(input);
406
+ },
407
+
408
+ /**
409
+ * Resolve an Open ExternalSyncConflict using the specified strategy.
410
+ *
411
+ * @param {{ conflictName, strategy, resolvedValue?, resources? }} opts
412
+ */
413
+ async resolveExternalConflict(opts) {
414
+ const conflictController = createConflictController({
415
+ persistFn: async (resource) => {
416
+ await resourceGateway.apply(resource);
417
+ }
418
+ });
419
+ return conflictController.resolveConflict(opts);
420
+ },
421
+
422
+ /**
423
+ * Return the current sync state for a binding (watermark, etc.).
424
+ *
425
+ * @param {string} bindingName
426
+ * @returns {{ bindingName: string, watermark: string|null }}
427
+ */
428
+ async getExternalSyncStatus(bindingName) {
429
+ const syncController = this._syncControllers?.get(bindingName);
430
+ const watermark = syncController ? syncController.getWatermark(bindingName) : null;
431
+ return { bindingName, watermark };
432
+ },
433
+
434
+ /**
435
+ * Process an inbound external webhook payload.
436
+ * Creates a WebhookController, processes the delivery, and emits events.
437
+ *
438
+ * @param {{ deliveryId, eventType, payload, rawBody, providerType?, secret? }} params
439
+ */
440
+ async processExternalWebhook({ deliveryId, eventType, payload, rawBody, providerType, secret } = {}) {
441
+ const webhookController = createWebhookController({ secret: secret || '' });
442
+ return webhookController.processDelivery({ deliveryId, eventType, payload, rawBody });
108
443
  }
109
444
  };
110
445
  }