@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,702 @@
1
+ import { createResource, clone } from './resource-model.js';
2
+
3
+ export const AGENT_WORKSPACE_CONTROLLER_BOUNDARY = {
4
+ role: 'agent-workspace-controller',
5
+ scope: 'Volume-backed git workspace provisioning with PVC lifecycle, git ops, runner mount, reuse, codespace management, and workspace associations',
6
+ owns: ['workspace creation', 'PVC manifest generation', 'git command specs', 'mount specs', 'workspace reuse', 'codespace lifecycle', 'workspace associations', 'run history'],
7
+ delegatesTo: ['resource-model'],
8
+ mustNotOwn: ['git execution', 'Kubernetes API calls', 'secret values']
9
+ };
10
+
11
+ export function createAgentWorkspaceController() {
12
+ return {
13
+ role: 'agent-workspace-controller',
14
+
15
+ // --- Volume lifecycle ---
16
+
17
+ createWorkspace({ name, organizationRef, repository, volumeSpec = {}, branch, namespace = 'default' }) {
18
+ if (!organizationRef) {
19
+ return { error: true, reason: 'missing-org', message: 'organizationRef is required' };
20
+ }
21
+ if (!repository) {
22
+ return { error: true, reason: 'missing-repository', message: 'repository is required' };
23
+ }
24
+
25
+ const workspaceName = name || `ws-${repository.replace(/[^a-z0-9-]/gi, '-').toLowerCase()}-${Date.now()}`;
26
+ const pvcName = `krate-ws-${workspaceName}`;
27
+ const storageClassName = volumeSpec.storageClassName || 'standard';
28
+ const capacity = volumeSpec.capacity || '10Gi';
29
+ const accessModes = volumeSpec.accessModes || ['ReadWriteOnce'];
30
+
31
+ const workspace = createResource('KrateWorkspace', { name: workspaceName, namespace }, {
32
+ organizationRef,
33
+ repository,
34
+ volumeSpec: {
35
+ storageClassName,
36
+ capacity,
37
+ accessModes,
38
+ },
39
+ branch: branch || 'main',
40
+ pvcName,
41
+ });
42
+ workspace.status = {
43
+ phase: 'Pending',
44
+ volumeStatus: 'Pending',
45
+ createdAt: new Date().toISOString(),
46
+ };
47
+
48
+ const pvcManifest = {
49
+ apiVersion: 'v1',
50
+ kind: 'PersistentVolumeClaim',
51
+ metadata: {
52
+ name: pvcName,
53
+ namespace,
54
+ labels: {
55
+ 'krate.a5c.ai/workspace': workspaceName,
56
+ 'krate.a5c.ai/org': organizationRef,
57
+ },
58
+ },
59
+ spec: {
60
+ storageClassName,
61
+ accessModes,
62
+ resources: {
63
+ requests: { storage: capacity },
64
+ },
65
+ },
66
+ };
67
+
68
+ return { error: false, workspace, pvcManifest };
69
+ },
70
+
71
+ deleteWorkspace({ name, namespace = 'default', resources = {} }) {
72
+ if (!name) {
73
+ return { error: true, reason: 'missing-name', message: 'workspace name is required' };
74
+ }
75
+
76
+ const workspaces = resources.KrateWorkspace || [];
77
+ const workspace = workspaces.find((w) => w.metadata?.name === name);
78
+ if (!workspace) {
79
+ return { error: true, reason: 'not-found', message: `KrateWorkspace not found: ${name}` };
80
+ }
81
+
82
+ const pvcName = workspace.spec?.pvcName || `krate-ws-${name}`;
83
+ const updated = clone(workspace);
84
+ updated.status = {
85
+ ...updated.status,
86
+ phase: 'Terminating',
87
+ terminatingAt: new Date().toISOString(),
88
+ };
89
+
90
+ const pvcDeleteManifest = {
91
+ apiVersion: 'v1',
92
+ kind: 'PersistentVolumeClaim',
93
+ metadata: {
94
+ name: pvcName,
95
+ namespace: workspace.metadata?.namespace || namespace,
96
+ },
97
+ action: 'delete',
98
+ };
99
+
100
+ return { error: false, workspace: updated, pvcDeleteManifest };
101
+ },
102
+
103
+ getWorkspaceStatus({ name, resources = {} }) {
104
+ if (!name) {
105
+ return { error: true, reason: 'missing-name', message: 'workspace name is required' };
106
+ }
107
+
108
+ const workspaces = resources.KrateWorkspace || [];
109
+ const workspace = workspaces.find((w) => w.metadata?.name === name);
110
+ if (!workspace) {
111
+ return { error: true, reason: 'not-found', message: `KrateWorkspace not found: ${name}` };
112
+ }
113
+
114
+ return {
115
+ error: false,
116
+ name,
117
+ volumeStatus: workspace.status?.volumeStatus || 'Pending',
118
+ phase: workspace.status?.phase || 'Pending',
119
+ repository: workspace.spec?.repository,
120
+ branch: workspace.spec?.branch,
121
+ runRef: workspace.status?.runRef || null,
122
+ pvcName: workspace.spec?.pvcName,
123
+ capacity: workspace.spec?.volumeSpec?.capacity,
124
+ };
125
+ },
126
+
127
+ // --- Git operations (intent-based) ---
128
+
129
+ initializeWorkspace({ workspace, mountPath = '/workspace' }) {
130
+ if (!workspace) {
131
+ return { error: true, reason: 'missing-workspace', message: 'workspace resource is required' };
132
+ }
133
+
134
+ const repoUrl = workspace.spec?.repository || '';
135
+ const isSsh = repoUrl.startsWith('git@') || repoUrl.includes('ssh://');
136
+
137
+ const env = {};
138
+ if (isSsh) {
139
+ env.GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
140
+ }
141
+
142
+ return {
143
+ error: false,
144
+ commandSpec: {
145
+ command: 'git',
146
+ args: ['clone', repoUrl, mountPath],
147
+ env,
148
+ },
149
+ };
150
+ },
151
+
152
+ checkoutBranch({ workspace, branch }) {
153
+ if (!workspace) {
154
+ return { error: true, reason: 'missing-workspace', message: 'workspace resource is required' };
155
+ }
156
+ if (!branch) {
157
+ return { error: true, reason: 'missing-branch', message: 'branch is required' };
158
+ }
159
+
160
+ return {
161
+ error: false,
162
+ commandSpec: {
163
+ command: 'git',
164
+ args: ['checkout', branch],
165
+ cwd: '/workspace',
166
+ },
167
+ };
168
+ },
169
+
170
+ syncWorkspace({ workspace }) {
171
+ if (!workspace) {
172
+ return { error: true, reason: 'missing-workspace', message: 'workspace resource is required' };
173
+ }
174
+
175
+ const branch = workspace.spec?.branch || 'main';
176
+
177
+ return {
178
+ error: false,
179
+ commandSpecs: [
180
+ {
181
+ command: 'git',
182
+ args: ['fetch', 'origin'],
183
+ cwd: '/workspace',
184
+ },
185
+ {
186
+ command: 'git',
187
+ args: ['reset', '--hard', `origin/${branch}`],
188
+ cwd: '/workspace',
189
+ },
190
+ ],
191
+ };
192
+ },
193
+
194
+ // --- Runner mount spec ---
195
+
196
+ getMountSpec({ workspace }) {
197
+ if (!workspace) {
198
+ return { error: true, reason: 'missing-workspace', message: 'workspace resource is required' };
199
+ }
200
+
201
+ const pvcName = workspace.spec?.pvcName || `krate-ws-${workspace.metadata?.name}`;
202
+
203
+ return {
204
+ error: false,
205
+ volume: {
206
+ name: 'workspace',
207
+ persistentVolumeClaim: { claimName: pvcName },
208
+ },
209
+ volumeMount: {
210
+ name: 'workspace',
211
+ mountPath: '/workspace',
212
+ },
213
+ };
214
+ },
215
+
216
+ // --- Workspace reuse ---
217
+
218
+ findReusableWorkspace({ organizationRef, repository, branch, resources = {} }) {
219
+ const workspaces = resources.KrateWorkspace || [];
220
+ const match = workspaces.find((w) =>
221
+ w.spec?.organizationRef === organizationRef &&
222
+ w.spec?.repository === repository &&
223
+ (w.spec?.branch || 'main') === (branch || 'main') &&
224
+ w.status?.phase === 'Ready'
225
+ );
226
+
227
+ return match ? clone(match) : null;
228
+ },
229
+
230
+ claimWorkspace({ name, runRef, resources = {} }) {
231
+ if (!name) {
232
+ return { error: true, reason: 'missing-name', message: 'workspace name is required' };
233
+ }
234
+ if (!runRef) {
235
+ return { error: true, reason: 'missing-run-ref', message: 'runRef is required' };
236
+ }
237
+
238
+ const workspaces = resources.KrateWorkspace || [];
239
+ const workspace = workspaces.find((w) => w.metadata?.name === name);
240
+ if (!workspace) {
241
+ return { error: true, reason: 'not-found', message: `KrateWorkspace not found: ${name}` };
242
+ }
243
+
244
+ if (workspace.status?.phase === 'InUse') {
245
+ return { error: true, reason: 'already-in-use', message: `KrateWorkspace ${name} is already in use by ${workspace.status.runRef}` };
246
+ }
247
+
248
+ const updated = clone(workspace);
249
+ updated.status = {
250
+ ...updated.status,
251
+ phase: 'InUse',
252
+ runRef,
253
+ claimedAt: new Date().toISOString(),
254
+ };
255
+
256
+ return { error: false, workspace: updated };
257
+ },
258
+
259
+ releaseWorkspace({ name, resources = {} }) {
260
+ if (!name) {
261
+ return { error: true, reason: 'missing-name', message: 'workspace name is required' };
262
+ }
263
+
264
+ const workspaces = resources.KrateWorkspace || [];
265
+ const workspace = workspaces.find((w) => w.metadata?.name === name);
266
+ if (!workspace) {
267
+ return { error: true, reason: 'not-found', message: `KrateWorkspace not found: ${name}` };
268
+ }
269
+
270
+ if (workspace.status?.phase !== 'InUse') {
271
+ return { error: true, reason: 'not-in-use', message: `KrateWorkspace ${name} is not in use (current phase: ${workspace.status?.phase || 'Unknown'})` };
272
+ }
273
+
274
+ const updated = clone(workspace);
275
+ updated.status = {
276
+ ...updated.status,
277
+ phase: 'Ready',
278
+ runRef: undefined,
279
+ claimedAt: undefined,
280
+ releasedAt: new Date().toISOString(),
281
+ };
282
+
283
+ return { error: false, workspace: updated };
284
+ },
285
+
286
+ // --- Legacy compat helpers ---
287
+
288
+ provisionWorkspace({ repository, ref, branch, dispatchRun, policy, namespace = 'default', organizationRef = 'default' }) {
289
+ if (!repository) {
290
+ return { error: true, reason: 'missing-repository', message: 'repository is required' };
291
+ }
292
+ if (!dispatchRun) {
293
+ return { error: true, reason: 'missing-dispatch-run', message: 'dispatchRun is required' };
294
+ }
295
+
296
+ const result = this.createWorkspace({
297
+ organizationRef,
298
+ repository,
299
+ branch: branch || 'main',
300
+ namespace,
301
+ volumeSpec: {},
302
+ });
303
+
304
+ if (result.error) return result;
305
+
306
+ // Mark as InUse with the dispatch run
307
+ result.workspace.status.phase = 'InUse';
308
+ result.workspace.status.runRef = dispatchRun;
309
+ result.workspace.status.volumeStatus = 'Bound';
310
+
311
+ const runtimeName = `rt-${result.workspace.metadata.name}`;
312
+ const runtime = createResource('KrateWorkspaceRuntime', { name: runtimeName, namespace }, {
313
+ organizationRef,
314
+ workspaceRef: result.workspace.metadata.name,
315
+ status: 'provisioning'
316
+ });
317
+ runtime.status = { phase: 'Provisioning', createdAt: new Date().toISOString() };
318
+
319
+ return { error: false, workspace: result.workspace, runtime, pvcManifest: result.pvcManifest };
320
+ },
321
+
322
+ archiveWorkspace({ workspaceName, reason, resources = {} }) {
323
+ if (!workspaceName) {
324
+ return { error: true, reason: 'missing-workspace-name', message: 'workspaceName is required' };
325
+ }
326
+
327
+ const workspaces = resources.KrateWorkspace || [];
328
+ const workspace = workspaces.find((w) => w.metadata?.name === workspaceName);
329
+ if (!workspace) {
330
+ return { error: true, reason: 'not-found', message: `KrateWorkspace not found: ${workspaceName}` };
331
+ }
332
+
333
+ const now = new Date().toISOString();
334
+ const updated = clone(workspace);
335
+ updated.status = {
336
+ ...updated.status,
337
+ phase: 'Archived',
338
+ archivedAt: now,
339
+ archiveReason: reason || 'No reason provided'
340
+ };
341
+
342
+ return { error: false, workspace: updated };
343
+ },
344
+
345
+ recoverWorkspace({ workspaceName, resources = {} }) {
346
+ if (!workspaceName) {
347
+ return { error: true, reason: 'missing-workspace-name', message: 'workspaceName is required' };
348
+ }
349
+
350
+ const workspaces = resources.KrateWorkspace || [];
351
+ const workspace = workspaces.find((w) => w.metadata?.name === workspaceName);
352
+ if (!workspace) {
353
+ return { error: true, reason: 'not-found', message: `KrateWorkspace not found: ${workspaceName}` };
354
+ }
355
+
356
+ if (workspace.status?.phase !== 'Archived') {
357
+ return { error: true, reason: 'not-archived', message: `KrateWorkspace ${workspaceName} is not archived (current phase: ${workspace.status?.phase || 'Unknown'})` };
358
+ }
359
+
360
+ const updated = clone(workspace);
361
+ updated.status = {
362
+ ...updated.status,
363
+ phase: 'Active',
364
+ archivedAt: undefined,
365
+ archiveReason: undefined
366
+ };
367
+
368
+ return { error: false, workspace: updated };
369
+ },
370
+
371
+ bindSession({ workspaceName, sessionRef, agent, namespace = 'default', organizationRef = 'default', resources = {} }) {
372
+ if (!workspaceName) {
373
+ return { error: true, reason: 'missing-workspace-name', message: 'workspaceName is required' };
374
+ }
375
+ if (!sessionRef) {
376
+ return { error: true, reason: 'missing-session-ref', message: 'sessionRef is required' };
377
+ }
378
+
379
+ const workspaces = resources.KrateWorkspace || [];
380
+ const workspace = workspaces.find((w) => w.metadata?.name === workspaceName);
381
+ if (!workspace) {
382
+ return { error: true, reason: 'not-found', message: `KrateWorkspace not found: ${workspaceName}` };
383
+ }
384
+
385
+ const updated = clone(workspace);
386
+ if (!updated.status) updated.status = {};
387
+ if (!Array.isArray(updated.status.boundSessions)) updated.status.boundSessions = [];
388
+ updated.status.boundSessions.push({
389
+ sessionRef,
390
+ agent: agent || undefined,
391
+ boundAt: new Date().toISOString()
392
+ });
393
+
394
+ return { error: false, workspace: updated };
395
+ },
396
+
397
+ linkWorkItem({ workspaceName, workItemRef, workItemKind, namespace = 'default', organizationRef = 'default' }) {
398
+ if (!workspaceName) {
399
+ return { error: true, reason: 'missing-workspace-name', message: 'workspaceName is required' };
400
+ }
401
+ if (!workItemRef) {
402
+ return { error: true, reason: 'missing-work-item-ref', message: 'workItemRef is required' };
403
+ }
404
+
405
+ const linkName = `wiwl-${workspaceName}-${workItemRef}-${Date.now()}`;
406
+ const link = createResource('WorkItemWorkspaceLink', { name: linkName, namespace }, {
407
+ organizationRef,
408
+ workItemRef,
409
+ workItemKind: workItemKind || 'Issue',
410
+ workspace: workspaceName
411
+ });
412
+ link.status = { phase: 'Active', createdAt: new Date().toISOString() };
413
+
414
+ return { error: false, link };
415
+ },
416
+
417
+ linkWorkItemToSession({ workItemRef, workItemKind, sessionRef, namespace = 'default', organizationRef = 'default' }) {
418
+ if (!workItemRef) {
419
+ return { error: true, reason: 'missing-work-item-ref', message: 'workItemRef is required' };
420
+ }
421
+ if (!sessionRef) {
422
+ return { error: true, reason: 'missing-session-ref', message: 'sessionRef is required' };
423
+ }
424
+
425
+ const linkName = `wisl-${sessionRef}-${workItemRef}-${Date.now()}`;
426
+ const link = createResource('WorkItemSessionLink', { name: linkName, namespace }, {
427
+ organizationRef,
428
+ workItemRef,
429
+ workItemKind: workItemKind || 'Issue',
430
+ agentSession: sessionRef
431
+ });
432
+ link.status = { phase: 'Active', createdAt: new Date().toISOString() };
433
+
434
+ return { error: false, link };
435
+ },
436
+
437
+ listWorkspacesForRepo({ repository, resources = {} }) {
438
+ const workspaces = resources.KrateWorkspace || [];
439
+ return workspaces.filter((w) => w.spec?.repository === repository).map(clone);
440
+ },
441
+
442
+ listWorkspacesForRun({ dispatchRun, resources = {} }) {
443
+ const workspaces = resources.KrateWorkspace || [];
444
+ return workspaces.filter((w) => w.status?.runRef === dispatchRun).map(clone);
445
+ },
446
+
447
+ // --- Codespace lifecycle ---
448
+
449
+ launchCodespace(workspace, options = {}) {
450
+ if (!workspace) {
451
+ return { error: true, reason: 'missing-workspace', message: 'workspace is required' };
452
+ }
453
+
454
+ // Only one codespace per workspace
455
+ if (workspace.status?.codespace?.running) {
456
+ return { error: true, reason: 'codespace-already-running', message: `Codespace already running for workspace ${workspace.metadata?.name}` };
457
+ }
458
+
459
+ const wsName = workspace.metadata?.name || 'unknown';
460
+ const namespace = workspace.metadata?.namespace || 'default';
461
+ const orgRef = workspace.spec?.organizationRef || 'default';
462
+ const pvcName = workspace.spec?.pvcName || `krate-ws-${wsName}`;
463
+ const image = options.image || 'codercom/code-server:latest';
464
+ const cpuLimit = options.cpu || '1';
465
+ const memoryLimit = options.memory || '2Gi';
466
+ const port = options.port || 8080;
467
+ const passwordSecretRef = options.passwordSecretRef || null;
468
+
469
+ const podName = `codespace-${wsName}`;
470
+ const serviceName = `codespace-svc-${wsName}`;
471
+
472
+ const podSpec = {
473
+ apiVersion: 'v1',
474
+ kind: 'Pod',
475
+ metadata: {
476
+ name: podName,
477
+ namespace,
478
+ labels: {
479
+ 'krate.a5c.ai/workspace': wsName,
480
+ 'krate.a5c.ai/org': orgRef,
481
+ 'krate.a5c.ai/component': 'codespace',
482
+ },
483
+ },
484
+ spec: {
485
+ containers: [
486
+ {
487
+ name: 'code-server',
488
+ image,
489
+ ports: [{ containerPort: port, name: 'http' }],
490
+ resources: {
491
+ limits: { cpu: cpuLimit, memory: memoryLimit },
492
+ requests: { cpu: '250m', memory: '512Mi' },
493
+ },
494
+ env: [
495
+ { name: 'KRATE_WORKSPACE', value: wsName },
496
+ { name: 'KRATE_ORG', value: orgRef },
497
+ { name: 'GIT_AUTHOR_NAME', value: options.gitAuthorName || 'krate-agent' },
498
+ { name: 'GIT_AUTHOR_EMAIL', value: options.gitAuthorEmail || `agent@${orgRef}.krate.local` },
499
+ ],
500
+ volumeMounts: [
501
+ { name: 'workspace', mountPath: '/workspace' },
502
+ ],
503
+ },
504
+ ],
505
+ volumes: [
506
+ {
507
+ name: 'workspace',
508
+ persistentVolumeClaim: { claimName: pvcName },
509
+ },
510
+ ],
511
+ restartPolicy: 'Always',
512
+ },
513
+ };
514
+
515
+ if (passwordSecretRef) {
516
+ podSpec.spec.containers[0].env.push({
517
+ name: 'PASSWORD',
518
+ valueFrom: { secretKeyRef: passwordSecretRef },
519
+ });
520
+ }
521
+
522
+ const serviceSpec = {
523
+ apiVersion: 'v1',
524
+ kind: 'Service',
525
+ metadata: {
526
+ name: serviceName,
527
+ namespace,
528
+ labels: {
529
+ 'krate.a5c.ai/workspace': wsName,
530
+ 'krate.a5c.ai/org': orgRef,
531
+ 'krate.a5c.ai/component': 'codespace',
532
+ },
533
+ },
534
+ spec: {
535
+ selector: {
536
+ 'krate.a5c.ai/workspace': wsName,
537
+ 'krate.a5c.ai/component': 'codespace',
538
+ },
539
+ ports: [
540
+ { port, targetPort: port, protocol: 'TCP', name: 'http' },
541
+ ],
542
+ type: 'ClusterIP',
543
+ },
544
+ };
545
+
546
+ const codespaceUrl = `http://${serviceName}.${namespace}.svc.cluster.local:${port}`;
547
+
548
+ return { error: false, podSpec, serviceSpec, codespaceUrl };
549
+ },
550
+
551
+ stopCodespace(workspace) {
552
+ if (!workspace) {
553
+ return { error: true, reason: 'missing-workspace', message: 'workspace is required' };
554
+ }
555
+
556
+ const wsName = workspace.metadata?.name || 'unknown';
557
+ const namespace = workspace.metadata?.namespace || 'default';
558
+
559
+ const podDeleteManifest = {
560
+ apiVersion: 'v1',
561
+ kind: 'Pod',
562
+ metadata: { name: `codespace-${wsName}`, namespace },
563
+ action: 'delete',
564
+ };
565
+
566
+ const serviceDeleteManifest = {
567
+ apiVersion: 'v1',
568
+ kind: 'Service',
569
+ metadata: { name: `codespace-svc-${wsName}`, namespace },
570
+ action: 'delete',
571
+ };
572
+
573
+ return { error: false, podDeleteManifest, serviceDeleteManifest };
574
+ },
575
+
576
+ getCodespaceStatus(workspace, podStatus = null) {
577
+ if (!workspace) {
578
+ return { error: true, reason: 'missing-workspace', message: 'workspace is required' };
579
+ }
580
+
581
+ const wsName = workspace.metadata?.name || 'unknown';
582
+ const namespace = workspace.metadata?.namespace || 'default';
583
+ const running = podStatus?.phase === 'Running';
584
+ const port = 8080;
585
+ const serviceName = `codespace-svc-${wsName}`;
586
+ const url = running ? `http://${serviceName}.${namespace}.svc.cluster.local:${port}` : null;
587
+
588
+ return {
589
+ error: false,
590
+ running,
591
+ url,
592
+ port,
593
+ uptime: podStatus?.startTime ? new Date().toISOString() : null,
594
+ startTime: podStatus?.startTime || null,
595
+ connectedUsers: podStatus?.connectedUsers || 0,
596
+ phase: podStatus?.phase || 'Unknown',
597
+ };
598
+ },
599
+
600
+ // --- Workspace associations ---
601
+
602
+ addAssociation(workspace, ref) {
603
+ if (!workspace) {
604
+ return { error: true, reason: 'missing-workspace', message: 'workspace is required' };
605
+ }
606
+ if (!ref || !ref.kind || !ref.name) {
607
+ return { error: true, reason: 'invalid-ref', message: 'ref must have kind and name' };
608
+ }
609
+ const validKinds = ['AgentDispatchRun', 'User', 'AgentSession'];
610
+ if (!validKinds.includes(ref.kind)) {
611
+ return { error: true, reason: 'invalid-ref-kind', message: `ref.kind must be one of: ${validKinds.join(', ')}` };
612
+ }
613
+
614
+ const updated = clone(workspace);
615
+ if (!updated.spec) updated.spec = {};
616
+ if (!Array.isArray(updated.spec.associations)) updated.spec.associations = [];
617
+
618
+ // Prevent duplicates
619
+ const exists = updated.spec.associations.some(
620
+ (a) => a.kind === ref.kind && a.name === ref.name
621
+ );
622
+ if (exists) {
623
+ return { error: true, reason: 'duplicate-association', message: `Association ${ref.kind}/${ref.name} already exists` };
624
+ }
625
+
626
+ updated.spec.associations.push({
627
+ kind: ref.kind,
628
+ name: ref.name,
629
+ addedAt: new Date().toISOString(),
630
+ });
631
+
632
+ return { error: false, workspace: updated };
633
+ },
634
+
635
+ removeAssociation(workspace, ref) {
636
+ if (!workspace) {
637
+ return { error: true, reason: 'missing-workspace', message: 'workspace is required' };
638
+ }
639
+ if (!ref || !ref.kind || !ref.name) {
640
+ return { error: true, reason: 'invalid-ref', message: 'ref must have kind and name' };
641
+ }
642
+
643
+ const updated = clone(workspace);
644
+ if (!updated.spec) updated.spec = {};
645
+ if (!Array.isArray(updated.spec.associations)) updated.spec.associations = [];
646
+
647
+ const before = updated.spec.associations.length;
648
+ updated.spec.associations = updated.spec.associations.filter(
649
+ (a) => !(a.kind === ref.kind && a.name === ref.name)
650
+ );
651
+
652
+ if (updated.spec.associations.length === before) {
653
+ return { error: true, reason: 'not-found', message: `Association ${ref.kind}/${ref.name} not found` };
654
+ }
655
+
656
+ return { error: false, workspace: updated };
657
+ },
658
+
659
+ listAssociations(workspace) {
660
+ if (!workspace) {
661
+ return { error: true, reason: 'missing-workspace', message: 'workspace is required' };
662
+ }
663
+
664
+ const associations = workspace.spec?.associations || [];
665
+ return { error: false, associations: clone(associations) };
666
+ },
667
+
668
+ // --- Run history ---
669
+
670
+ getWorkspaceRuns(workspace, allRuns = []) {
671
+ if (!workspace) {
672
+ return { error: true, reason: 'missing-workspace', message: 'workspace is required' };
673
+ }
674
+
675
+ const wsName = workspace.metadata?.name;
676
+ const active = [];
677
+ const history = [];
678
+
679
+ for (const run of allRuns) {
680
+ const refersToWs =
681
+ run.status?.workspaceRef === wsName ||
682
+ run.spec?.workspaceRef === wsName ||
683
+ (workspace.spec?.associations || []).some(
684
+ (a) => a.kind === 'AgentDispatchRun' && a.name === run.metadata?.name
685
+ );
686
+
687
+ if (!refersToWs) continue;
688
+
689
+ const phase = run.status?.phase || 'Unknown';
690
+ const isActive = phase === 'Running' || phase === 'Queued' || phase === 'Pending' || phase === 'Dispatched';
691
+
692
+ if (isActive) {
693
+ active.push(clone(run));
694
+ } else {
695
+ history.push(clone(run));
696
+ }
697
+ }
698
+
699
+ return { error: false, active, history };
700
+ }
701
+ };
702
+ }