@a5c-ai/kradle 5.0.1-staging.3abdf9534c25

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 (295) hide show
  1. package/Dockerfile +31 -0
  2. package/README.md +187 -0
  3. package/bin/kradle-demo.mjs +23 -0
  4. package/bin/kradle-server.mjs +14 -0
  5. package/dist/kradle-controller-ui.json +3482 -0
  6. package/dist/kradle-lifecycle.json +201 -0
  7. package/dist/kradle-runtime-snapshot.json +3125 -0
  8. package/dist/kradle-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-kradle-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/architecture-v2.md +2759 -0
  60. package/docs/components/control-plane.md +78 -0
  61. package/docs/components/data-plane.md +69 -0
  62. package/docs/components/hooks-events.md +67 -0
  63. package/docs/components/identity-rbac-policy.md +73 -0
  64. package/docs/components/kubevela-oam.md +70 -0
  65. package/docs/components/operations-publishing.md +81 -0
  66. package/docs/components/runners-ci.md +66 -0
  67. package/docs/components/web-ui.md +94 -0
  68. package/docs/crd-behaviors-and-relationships.md +3926 -0
  69. package/docs/external/README.md +47 -0
  70. package/docs/external/bidirectional-sync-design.md +134 -0
  71. package/docs/external/cicd-interface.md +64 -0
  72. package/docs/external/external-backend-controllers.md +170 -0
  73. package/docs/external/external-backend-crds.md +234 -0
  74. package/docs/external/external-backend-ui-spec.md +151 -0
  75. package/docs/external/external-backend-ux-flows.md +115 -0
  76. package/docs/external/external-object-mapping.md +125 -0
  77. package/docs/external/git-forge-interface.md +68 -0
  78. package/docs/external/github-integration-design.md +151 -0
  79. package/docs/external/issue-tracking-interface.md +66 -0
  80. package/docs/external/provider-capability-manifests.md +204 -0
  81. package/docs/external/provider-catalog.md +139 -0
  82. package/docs/external/provider-rollout-testing.md +78 -0
  83. package/docs/external/research-results.md +48 -0
  84. package/docs/external/security-auth-permissions.md +81 -0
  85. package/docs/external/sync-state-machines.md +108 -0
  86. package/docs/external/unified-external-backend-model.md +107 -0
  87. package/docs/external/user-facing-changes.md +67 -0
  88. package/docs/gaps.md +161 -0
  89. package/docs/install.md +94 -0
  90. package/docs/integration-and-design-decisions.md +1530 -0
  91. package/docs/kradle-design.md +334 -0
  92. package/docs/local-minikube.md +55 -0
  93. package/docs/ontology/README.md +32 -0
  94. package/docs/ontology/bounded-contexts.md +29 -0
  95. package/docs/ontology/events-and-hooks.md +32 -0
  96. package/docs/ontology/oam-kubevela.md +32 -0
  97. package/docs/ontology/operations-and-release.md +25 -0
  98. package/docs/ontology/personas-and-actors.md +32 -0
  99. package/docs/ontology/policies-and-invariants.md +33 -0
  100. package/docs/ontology/problem-space.md +30 -0
  101. package/docs/ontology/resource-contracts.md +40 -0
  102. package/docs/ontology/resource-taxonomy.md +42 -0
  103. package/docs/ontology/runners-and-ci.md +29 -0
  104. package/docs/ontology/solution-space.md +24 -0
  105. package/docs/ontology/storage-and-data-boundaries.md +29 -0
  106. package/docs/ontology/validation-matrix.md +24 -0
  107. package/docs/ontology/web-ui-excellent-flows.md +32 -0
  108. package/docs/ontology/workflows.md +39 -0
  109. package/docs/ontology/world.md +35 -0
  110. package/docs/openapi.yaml +1291 -0
  111. package/docs/product-requirements.md +62 -0
  112. package/docs/requirements-v2.md +235 -0
  113. package/docs/roadmap-mvp.md +87 -0
  114. package/docs/sdk-api-reference.md +1108 -0
  115. package/docs/system-requirements.md +90 -0
  116. package/docs/system-spec-v2.md +1230 -0
  117. package/docs/tests/README.md +53 -0
  118. package/docs/tests/agent-qa-plan.md +63 -0
  119. package/docs/tests/browser-ui-tests.md +62 -0
  120. package/docs/tests/ci-quality-gates.md +48 -0
  121. package/docs/tests/coverage-model.md +64 -0
  122. package/docs/tests/e2e-scenario-tests.md +53 -0
  123. package/docs/tests/fixtures-test-data.md +63 -0
  124. package/docs/tests/observability-reliability-tests.md +54 -0
  125. package/docs/tests/product-test-matrix.md +145 -0
  126. package/docs/tests/qa-adoption-roadmap.md +130 -0
  127. package/docs/tests/qa-automation-plan.md +101 -0
  128. package/docs/tests/security-compliance-tests.md +57 -0
  129. package/docs/tests/test-framework-tools.md +88 -0
  130. package/docs/tests/test-suite-layout.md +121 -0
  131. package/docs/tests/unit-integration-tests.md +48 -0
  132. package/docs/todo-kyverno +714 -0
  133. package/docs/todos.md +4 -0
  134. package/docs/user-stories.md +78 -0
  135. package/docs/web-console-spec.md +533 -0
  136. package/examples/minikube-demo.yaml +190 -0
  137. package/examples/oam-application.yaml +23 -0
  138. package/examples/policy-kyverno-pr-title.yaml +18 -0
  139. package/package.json +66 -0
  140. package/scripts/build.mjs +29 -0
  141. package/scripts/setup-minikube.mjs +65 -0
  142. package/scripts/smoke.mjs +37 -0
  143. package/scripts/validate-doc-coverage.mjs +152 -0
  144. package/scripts/validate-package.mjs +95 -0
  145. package/scripts/validate-ui.mjs +305 -0
  146. package/src/agent-adapter-controller.js +169 -0
  147. package/src/agent-approval-controller.js +170 -0
  148. package/src/agent-context-bundles.js +242 -0
  149. package/src/agent-dispatch-controller.js +549 -0
  150. package/src/agent-gateway-config-controller.js +147 -0
  151. package/src/agent-identity-migration.js +115 -0
  152. package/src/agent-memory-controller.js +357 -0
  153. package/src/agent-memory-import.js +327 -0
  154. package/src/agent-memory-query.js +292 -0
  155. package/src/agent-memory-repository-source-controller.js +255 -0
  156. package/src/agent-mux-client.js +589 -0
  157. package/src/agent-permission-review.js +250 -0
  158. package/src/agent-persona-controller.js +135 -0
  159. package/src/agent-project-controller.js +117 -0
  160. package/src/agent-prompt-composition.js +55 -0
  161. package/src/agent-provider-config-controller.js +151 -0
  162. package/src/agent-secret-config-grant-controller.js +282 -0
  163. package/src/agent-session-transcript-controller.js +189 -0
  164. package/src/agent-stack-controller.js +421 -0
  165. package/src/agent-subagent-controller.js +160 -0
  166. package/src/agent-transport-binding-controller.js +121 -0
  167. package/src/agent-trigger-controller.js +387 -0
  168. package/src/agent-workspace-controller.js +702 -0
  169. package/src/agent-writeback-controller.js +302 -0
  170. package/src/api-controller.js +621 -0
  171. package/src/argocd-gitops.js +43 -0
  172. package/src/artifact-registry-controller.js +542 -0
  173. package/src/assistant-runtime.js +284 -0
  174. package/src/async-controller.js +207 -0
  175. package/src/audit-controller.js +191 -0
  176. package/src/auth.js +310 -0
  177. package/src/component-catalog.js +41 -0
  178. package/src/control-plane.js +136 -0
  179. package/src/controller-client.js +112 -0
  180. package/src/controller-ui.js +620 -0
  181. package/src/data-plane.js +179 -0
  182. package/src/event-bus.js +397 -0
  183. package/src/external/conflict-controller.js +225 -0
  184. package/src/external/github/auth.js +96 -0
  185. package/src/external/github/cicd.js +180 -0
  186. package/src/external/github/git-forge.js +240 -0
  187. package/src/external/github/index.js +144 -0
  188. package/src/external/github/issue-tracking.js +163 -0
  189. package/src/external/provider-adapter.js +161 -0
  190. package/src/external/provider-resource-factory.js +221 -0
  191. package/src/external/sync-controller.js +235 -0
  192. package/src/external/webhook-controller.js +144 -0
  193. package/src/external/write-controller.js +283 -0
  194. package/src/gitea-backend.js +131 -0
  195. package/src/gitea-service.js +173 -0
  196. package/src/handoff.js +98 -0
  197. package/src/health-probes.js +134 -0
  198. package/src/hooks-events.js +63 -0
  199. package/src/hooks-lifecycle.js +117 -0
  200. package/src/http-server.js +409 -0
  201. package/src/identity-policy.js +86 -0
  202. package/src/index.js +71 -0
  203. package/src/jitsi-agent-bridge.js +141 -0
  204. package/src/jitsi-meeting-controller.js +291 -0
  205. package/src/jitsi-sync-controller.js +198 -0
  206. package/src/kradle-inference-service-controller.js +246 -0
  207. package/src/kubernetes-controller-async.js +531 -0
  208. package/src/kubernetes-controller.js +904 -0
  209. package/src/kubernetes-resource-gateway.js +48 -0
  210. package/src/model-route-controller.js +364 -0
  211. package/src/notification-controller.js +178 -0
  212. package/src/operations.js +112 -0
  213. package/src/org-scoping.js +5 -0
  214. package/src/resource-model.js +282 -0
  215. package/src/runner-controller.js +272 -0
  216. package/src/runners-ci.js +48 -0
  217. package/src/runtime.js +196 -0
  218. package/src/snapshot-cache.js +157 -0
  219. package/src/virtual-model-controller.js +538 -0
  220. package/src/virtual-model-hook-bridge.js +200 -0
  221. package/src/web-ui.js +40 -0
  222. package/tests/agent-adapter-controller.test.js +361 -0
  223. package/tests/agent-approval-controller.test.js +173 -0
  224. package/tests/agent-context-bundles.test.js +278 -0
  225. package/tests/agent-dispatch-controller.test.js +679 -0
  226. package/tests/agent-gateway-config-controller.test.js +386 -0
  227. package/tests/agent-identity-migration.test.js +87 -0
  228. package/tests/agent-memory-controller.test.js +461 -0
  229. package/tests/agent-memory-import-snapshot.test.js +477 -0
  230. package/tests/agent-memory-query.test.js +404 -0
  231. package/tests/agent-memory-repository-source.test.js +514 -0
  232. package/tests/agent-mux-client.test.js +389 -0
  233. package/tests/agent-mux-integration.test.js +971 -0
  234. package/tests/agent-permission-review-v2.test.js +317 -0
  235. package/tests/agent-permission-review.test.js +209 -0
  236. package/tests/agent-persona-controller.test.js +127 -0
  237. package/tests/agent-project-controller.test.js +302 -0
  238. package/tests/agent-prompt-composition.test.js +76 -0
  239. package/tests/agent-provider-config-controller.test.js +376 -0
  240. package/tests/agent-resources.test.js +303 -0
  241. package/tests/agent-secret-config-grant.test.js +231 -0
  242. package/tests/agent-session-transcript-controller.test.js +499 -0
  243. package/tests/agent-stack-controller.test.js +283 -0
  244. package/tests/agent-subagent-controller.test.js +201 -0
  245. package/tests/agent-transport-binding-controller.test.js +294 -0
  246. package/tests/agent-trigger-controller.test.js +271 -0
  247. package/tests/agent-trigger-routes.test.js +190 -0
  248. package/tests/agent-trigger-sources.test.js +245 -0
  249. package/tests/agent-workspace-controller.test.js +181 -0
  250. package/tests/agent-writeback.test.js +292 -0
  251. package/tests/approval-persistence.test.js +171 -0
  252. package/tests/artifact-registry.test.js +511 -0
  253. package/tests/assistant-runtime.test.js +506 -0
  254. package/tests/async-controller.test.js +252 -0
  255. package/tests/audit-controller.test.js +227 -0
  256. package/tests/codespace-controller.test.js +318 -0
  257. package/tests/controller-client.test.js +133 -0
  258. package/tests/deployment.test.js +527 -0
  259. package/tests/e2e/lifecycle.test.js +120 -0
  260. package/tests/event-bus-integration.test.js +355 -0
  261. package/tests/external-github-forge.test.js +560 -0
  262. package/tests/external-github-issues-cicd.test.js +520 -0
  263. package/tests/external-integration.test.js +470 -0
  264. package/tests/external-persistence.test.js +415 -0
  265. package/tests/external-provider-adapter.test.js +365 -0
  266. package/tests/external-resource-model.test.js +223 -0
  267. package/tests/external-webhook-sync.test.js +287 -0
  268. package/tests/external-write-conflict.test.js +353 -0
  269. package/tests/gitea-service.test.js +253 -0
  270. package/tests/health-check-real.test.js +165 -0
  271. package/tests/health-probes.test.js +90 -0
  272. package/tests/hooks-lifecycle.test.js +364 -0
  273. package/tests/integration/full-flow.test.js +266 -0
  274. package/tests/jitsi-agent-bridge.test.js +119 -0
  275. package/tests/jitsi-helm-integration.test.js +77 -0
  276. package/tests/jitsi-meeting-controller.test.js +170 -0
  277. package/tests/jitsi-resource-model.test.js +73 -0
  278. package/tests/jitsi-sync-controller.test.js +112 -0
  279. package/tests/kradle-inference-service.test.js +689 -0
  280. package/tests/kradle.test.js +779 -0
  281. package/tests/memory-search-wiring.test.js +270 -0
  282. package/tests/model-route-controller.test.js +733 -0
  283. package/tests/notification-controller.test.js +196 -0
  284. package/tests/notification-integration.test.js +179 -0
  285. package/tests/org-scoping.test.js +687 -0
  286. package/tests/runner-controller.test.js +327 -0
  287. package/tests/runner-integration.test.js +231 -0
  288. package/tests/session-cookie-hmac.test.js +151 -0
  289. package/tests/snapshot-performance.test.js +315 -0
  290. package/tests/sse-events.test.js +107 -0
  291. package/tests/virtual-model-controller.test.js +877 -0
  292. package/tests/virtual-model-hook-bridge.test.js +384 -0
  293. package/tests/webhook-trigger.test.js +198 -0
  294. package/tests/workspace-volumes.test.js +312 -0
  295. package/tests/writeback-persistence.test.js +207 -0
@@ -0,0 +1,971 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it, beforeEach } from 'node:test';
3
+ import { EventEmitter } from 'node:events';
4
+ import { createAgentMuxClient, createAgentDispatchController, createResource, createEventBus } from '../src/index.js';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Mock resource gateway helper
8
+ // ---------------------------------------------------------------------------
9
+
10
+ function createMockResourceGateway({ applyFn, getFn, deleteFn, getLogsFn } = {}) {
11
+ const applied = [];
12
+ const deleted = [];
13
+ return {
14
+ applied,
15
+ deleted,
16
+ async apply(resource) {
17
+ applied.push(resource);
18
+ if (applyFn) return applyFn(resource);
19
+ return resource;
20
+ },
21
+ async get(kind, name) {
22
+ if (getFn) return getFn(kind, name);
23
+ return null;
24
+ },
25
+ async delete(kind, name) {
26
+ deleted.push({ kind, name });
27
+ if (deleteFn) return deleteFn(kind, name);
28
+ },
29
+ async getLogs(kind, name, namespace) {
30
+ if (getLogsFn) return getLogsFn(kind, name, namespace);
31
+ return '';
32
+ },
33
+ };
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Helpers for dispatch controller tests
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function makeStack(name, specOverrides = {}) {
41
+ return createResource('AgentStack', { name, namespace: 'kradle-org-default' }, {
42
+ organizationRef: 'default',
43
+ baseAgent: 'claude-code',
44
+ adapter: 'claude-code',
45
+ provider: 'anthropic',
46
+ runtimeIdentity: { serviceAccountRef: 'sa-default' },
47
+ ...specOverrides,
48
+ });
49
+ }
50
+
51
+ function buildValidResources(stackName, specOverrides = {}) {
52
+ return {
53
+ AgentStack: [makeStack(stackName, specOverrides)],
54
+ AgentServiceAccount: [createResource('AgentServiceAccount', { name: 'sa-default', namespace: 'kradle-org-default' }, {
55
+ organizationRef: 'default', namespace: 'kradle-org-default', serviceAccountName: 'sa-default',
56
+ })],
57
+ AgentRoleBinding: [createResource('AgentRoleBinding', { name: 'rb-1', namespace: 'kradle-org-default' }, {
58
+ organizationRef: 'default', subject: 'sa-default', roleRef: 'agent-developer', scope: 'namespace',
59
+ })],
60
+ AgentSecretGrant: [createResource('AgentSecretGrant', { name: 'sg-model', namespace: 'kradle-org-default' }, {
61
+ organizationRef: 'default', subject: 'sa-default', secretRef: 'secret-model-provider', purpose: 'model-provider',
62
+ })],
63
+ };
64
+ }
65
+
66
+ // ============================================================================
67
+ // PRIORITY 1: K8s Job Manifest Generation (createAgentJob)
68
+ // ============================================================================
69
+
70
+ describe('createAgentJob', () => {
71
+ it('generates valid K8s Job manifest', () => {
72
+ const client = createAgentMuxClient({});
73
+ const { jobManifest, jobName } = client.createAgentJob({
74
+ adapter: 'claude-code',
75
+ provider: 'anthropic',
76
+ org: 'acme',
77
+ runId: 'run-123',
78
+ });
79
+
80
+ assert.equal(jobManifest.apiVersion, 'batch/v1');
81
+ assert.equal(jobManifest.kind, 'Job');
82
+ assert.equal(jobManifest.metadata.name, 'kradle-agent-run-123');
83
+ assert.equal(jobManifest.metadata.namespace, 'kradle-org-acme');
84
+ assert.equal(jobName, 'kradle-agent-run-123');
85
+ });
86
+
87
+ it('includes correct image, command, and env vars', () => {
88
+ const client = createAgentMuxClient({});
89
+ const { jobManifest } = client.createAgentJob({
90
+ adapter: 'claude-code',
91
+ provider: 'anthropic',
92
+ org: 'acme',
93
+ runId: 'run-456',
94
+ image: 'ghcr.io/custom/agent:v2',
95
+ });
96
+
97
+ const container = jobManifest.spec.template.spec.containers[0];
98
+ assert.equal(container.name, 'agent');
99
+ assert.equal(container.image, 'ghcr.io/custom/agent:v2');
100
+ assert.deepEqual(container.command, ['node', 'dist/cli/index.js', 'launch', 'claude-code', 'anthropic']);
101
+
102
+ // Env vars should include KRADLE_ORG and KRADLE_RUN_ID
103
+ const envMap = Object.fromEntries(container.env.map(e => [e.name, e.value]));
104
+ assert.equal(envMap.KRADLE_ORG, 'acme');
105
+ assert.equal(envMap.KRADLE_RUN_ID, 'run-456');
106
+ assert.equal(envMap.KRADLE_WORKSPACE_PATH, '/workspace');
107
+ });
108
+
109
+ it('includes model arg when provided', () => {
110
+ const client = createAgentMuxClient({});
111
+ const { jobManifest } = client.createAgentJob({
112
+ adapter: 'claude-code',
113
+ provider: 'anthropic',
114
+ model: 'claude-sonnet-4-20250514',
115
+ org: 'acme',
116
+ });
117
+
118
+ const container = jobManifest.spec.template.spec.containers[0];
119
+ assert.deepEqual(container.args, ['--model', 'claude-sonnet-4-20250514']);
120
+ });
121
+
122
+ it('generates empty args when no model provided', () => {
123
+ const client = createAgentMuxClient({});
124
+ const { jobManifest } = client.createAgentJob({
125
+ adapter: 'claude-code',
126
+ org: 'acme',
127
+ });
128
+
129
+ const container = jobManifest.spec.template.spec.containers[0];
130
+ assert.deepEqual(container.args, []);
131
+ });
132
+
133
+ it('mounts workspace PVC when provided', () => {
134
+ const client = createAgentMuxClient({});
135
+ const { jobManifest } = client.createAgentJob({
136
+ adapter: 'claude-code',
137
+ org: 'acme',
138
+ workspace: { pvcName: 'ws-pvc-acme-main' },
139
+ });
140
+
141
+ const podSpec = jobManifest.spec.template.spec;
142
+ assert.equal(podSpec.volumes.length, 1);
143
+ assert.equal(podSpec.volumes[0].name, 'workspace');
144
+ assert.equal(podSpec.volumes[0].persistentVolumeClaim.claimName, 'ws-pvc-acme-main');
145
+
146
+ const container = podSpec.containers[0];
147
+ assert.equal(container.volumeMounts.length, 1);
148
+ assert.equal(container.volumeMounts[0].mountPath, '/workspace');
149
+ });
150
+
151
+ it('omits volumes when no workspace PVC', () => {
152
+ const client = createAgentMuxClient({});
153
+ const { jobManifest } = client.createAgentJob({
154
+ adapter: 'claude-code',
155
+ org: 'acme',
156
+ });
157
+
158
+ const podSpec = jobManifest.spec.template.spec;
159
+ assert.deepEqual(podSpec.volumes, []);
160
+ assert.deepEqual(podSpec.containers[0].volumeMounts, []);
161
+ });
162
+
163
+ it('sets resource limits from config', () => {
164
+ const client = createAgentMuxClient({});
165
+ const customResources = {
166
+ requests: { cpu: '1', memory: '2Gi' },
167
+ limits: { cpu: '4', memory: '8Gi' },
168
+ };
169
+ const { jobManifest } = client.createAgentJob({
170
+ adapter: 'claude-code',
171
+ org: 'acme',
172
+ resources: customResources,
173
+ });
174
+
175
+ const container = jobManifest.spec.template.spec.containers[0];
176
+ assert.deepEqual(container.resources, customResources);
177
+ });
178
+
179
+ it('uses default resource limits when not specified', () => {
180
+ const client = createAgentMuxClient({});
181
+ const { jobManifest } = client.createAgentJob({
182
+ adapter: 'claude-code',
183
+ org: 'acme',
184
+ });
185
+
186
+ const container = jobManifest.spec.template.spec.containers[0];
187
+ assert.deepEqual(container.resources, {
188
+ requests: { cpu: '500m', memory: '1Gi' },
189
+ limits: { cpu: '2', memory: '4Gi' },
190
+ });
191
+ });
192
+
193
+ it('sets activeDeadlineSeconds from budget', () => {
194
+ const client = createAgentMuxClient({});
195
+ const { jobManifest } = client.createAgentJob({
196
+ adapter: 'claude-code',
197
+ org: 'acme',
198
+ budget: { maxDurationSeconds: 7200 },
199
+ });
200
+
201
+ assert.equal(jobManifest.spec.activeDeadlineSeconds, 7200);
202
+ });
203
+
204
+ it('defaults activeDeadlineSeconds to 3600', () => {
205
+ const client = createAgentMuxClient({});
206
+ const { jobManifest } = client.createAgentJob({
207
+ adapter: 'claude-code',
208
+ org: 'acme',
209
+ });
210
+
211
+ assert.equal(jobManifest.spec.activeDeadlineSeconds, 3600);
212
+ });
213
+
214
+ it('includes prompt env vars when provided', () => {
215
+ const client = createAgentMuxClient({});
216
+ const { jobManifest } = client.createAgentJob({
217
+ adapter: 'claude-code',
218
+ org: 'acme',
219
+ prompt: { system: 'You are helpful.', task: 'Fix the CI.' },
220
+ });
221
+
222
+ const envMap = Object.fromEntries(
223
+ jobManifest.spec.template.spec.containers[0].env.map(e => [e.name, e.value])
224
+ );
225
+ assert.equal(envMap.AGENT_SYSTEM_PROMPT, 'You are helpful.');
226
+ assert.equal(envMap.AGENT_TASK, 'Fix the CI.');
227
+ });
228
+
229
+ it('includes callbackUrl in env when provided', () => {
230
+ const client = createAgentMuxClient({});
231
+ const { jobManifest } = client.createAgentJob({
232
+ adapter: 'claude-code',
233
+ org: 'acme',
234
+ callbackUrl: 'https://kradle.example.com/api/callback',
235
+ });
236
+
237
+ const envMap = Object.fromEntries(
238
+ jobManifest.spec.template.spec.containers[0].env.map(e => [e.name, e.value])
239
+ );
240
+ assert.equal(envMap.KRADLE_CALLBACK_URL, 'https://kradle.example.com/api/callback');
241
+ });
242
+
243
+ it('includes custom env vars from config', () => {
244
+ const client = createAgentMuxClient({});
245
+ const { jobManifest } = client.createAgentJob({
246
+ adapter: 'claude-code',
247
+ org: 'acme',
248
+ env: { MY_VAR: 'hello', ANOTHER: 'world' },
249
+ });
250
+
251
+ const envMap = Object.fromEntries(
252
+ jobManifest.spec.template.spec.containers[0].env.map(e => [e.name, e.value])
253
+ );
254
+ assert.equal(envMap.MY_VAR, 'hello');
255
+ assert.equal(envMap.ANOTHER, 'world');
256
+ });
257
+
258
+ it('sets labels including stack and org', () => {
259
+ const client = createAgentMuxClient({});
260
+ const { jobManifest } = client.createAgentJob({
261
+ adapter: 'claude-code',
262
+ org: 'acme',
263
+ runId: 'run-lbl',
264
+ stackName: 'my-stack',
265
+ });
266
+
267
+ const labels = jobManifest.metadata.labels;
268
+ assert.equal(labels['kradle.a5c.ai/component'], 'agent-run');
269
+ assert.equal(labels['kradle.a5c.ai/run'], 'run-lbl');
270
+ assert.equal(labels['kradle.a5c.ai/stack'], 'my-stack');
271
+ assert.equal(labels['kradle.a5c.ai/org'], 'acme');
272
+ });
273
+
274
+ it('omits stack label when stackName not provided', () => {
275
+ const client = createAgentMuxClient({});
276
+ const { jobManifest } = client.createAgentJob({
277
+ adapter: 'claude-code',
278
+ org: 'acme',
279
+ });
280
+
281
+ assert.equal(jobManifest.metadata.labels['kradle.a5c.ai/stack'], undefined);
282
+ });
283
+
284
+ it('sets backoffLimit to 0 and restartPolicy to Never', () => {
285
+ const client = createAgentMuxClient({});
286
+ const { jobManifest } = client.createAgentJob({
287
+ adapter: 'claude-code',
288
+ org: 'acme',
289
+ });
290
+
291
+ assert.equal(jobManifest.spec.backoffLimit, 0);
292
+ assert.equal(jobManifest.spec.template.spec.restartPolicy, 'Never');
293
+ });
294
+
295
+ it('uses custom serviceAccount when provided', () => {
296
+ const client = createAgentMuxClient({});
297
+ const { jobManifest } = client.createAgentJob({
298
+ adapter: 'claude-code',
299
+ org: 'acme',
300
+ serviceAccount: 'sa-custom',
301
+ });
302
+
303
+ assert.equal(jobManifest.spec.template.spec.serviceAccountName, 'sa-custom');
304
+ });
305
+
306
+ it('defaults serviceAccount to kradle', () => {
307
+ const client = createAgentMuxClient({});
308
+ const { jobManifest } = client.createAgentJob({
309
+ adapter: 'claude-code',
310
+ org: 'acme',
311
+ });
312
+
313
+ assert.equal(jobManifest.spec.template.spec.serviceAccountName, 'kradle');
314
+ });
315
+
316
+ it('uses default image when not specified', () => {
317
+ const client = createAgentMuxClient({});
318
+ const { jobManifest } = client.createAgentJob({
319
+ adapter: 'claude-code',
320
+ org: 'acme',
321
+ });
322
+
323
+ assert.equal(jobManifest.spec.template.spec.containers[0].image, 'ghcr.io/a5c-ai/agent-mux:latest');
324
+ });
325
+
326
+ it('throws for unknown adapter', () => {
327
+ const client = createAgentMuxClient({});
328
+ assert.throws(
329
+ () => client.createAgentJob({ adapter: 'unknown-thing', org: 'acme' }),
330
+ { message: /Unknown adapter: unknown-thing/ }
331
+ );
332
+ });
333
+
334
+ it('throws for missing adapter', () => {
335
+ const client = createAgentMuxClient({});
336
+ assert.throws(
337
+ () => client.createAgentJob({ adapter: '', org: 'acme' }),
338
+ { message: /requires a valid adapter name/ }
339
+ );
340
+ });
341
+
342
+ it('throws for missing org', () => {
343
+ const client = createAgentMuxClient({});
344
+ assert.throws(
345
+ () => client.createAgentJob({ adapter: 'claude-code' }),
346
+ { message: /requires an org/ }
347
+ );
348
+ });
349
+
350
+ it('generates unique jobName from runId', () => {
351
+ const client = createAgentMuxClient({});
352
+ const { jobName: name1 } = client.createAgentJob({ adapter: 'claude-code', org: 'acme' });
353
+ const { jobName: name2 } = client.createAgentJob({ adapter: 'claude-code', org: 'acme' });
354
+ // Each call with no explicit runId should produce different names (randomUUID)
355
+ assert.notEqual(name1, name2);
356
+ });
357
+ });
358
+
359
+ // ============================================================================
360
+ // PRIORITY 2: Job Lifecycle Management
361
+ // ============================================================================
362
+
363
+ describe('submitAgentJob', () => {
364
+ it('calls resourceGateway.apply with the manifest', async () => {
365
+ const gw = createMockResourceGateway();
366
+ const client = createAgentMuxClient({ resourceGateway: gw });
367
+
368
+ const manifest = {
369
+ apiVersion: 'batch/v1',
370
+ kind: 'Job',
371
+ metadata: { name: 'kradle-agent-run-1', namespace: 'kradle-org-acme' },
372
+ spec: {},
373
+ };
374
+
375
+ const result = await client.submitAgentJob(manifest);
376
+
377
+ assert.equal(result.jobName, 'kradle-agent-run-1');
378
+ assert.equal(result.namespace, 'kradle-org-acme');
379
+ assert.equal(result.submitted, true);
380
+ assert.equal(gw.applied.length, 1);
381
+ assert.equal(gw.applied[0].metadata.name, 'kradle-agent-run-1');
382
+ });
383
+
384
+ it('throws without resourceGateway', async () => {
385
+ const client = createAgentMuxClient({});
386
+ await assert.rejects(
387
+ () => client.submitAgentJob({ metadata: { name: 'j', namespace: 'ns' } }),
388
+ { message: /requires a resourceGateway/ }
389
+ );
390
+ });
391
+ });
392
+
393
+ describe('getJobStatus', () => {
394
+ it('returns parsed job status', async () => {
395
+ const gw = createMockResourceGateway({
396
+ getFn: (kind, name) => ({
397
+ status: {
398
+ active: 1,
399
+ succeeded: 0,
400
+ failed: 0,
401
+ startTime: '2026-01-01T00:00:00Z',
402
+ completionTime: null,
403
+ conditions: [{ type: 'Running', status: 'True' }],
404
+ },
405
+ }),
406
+ });
407
+ const client = createAgentMuxClient({ resourceGateway: gw });
408
+
409
+ const status = await client.getJobStatus('kradle-agent-run-1', 'kradle-org-acme');
410
+ assert.equal(status.active, 1);
411
+ assert.equal(status.succeeded, 0);
412
+ assert.equal(status.startTime, '2026-01-01T00:00:00Z');
413
+ assert.equal(status.conditions.length, 1);
414
+ });
415
+
416
+ it('returns zeroed status when job not found', async () => {
417
+ const gw = createMockResourceGateway({ getFn: () => null });
418
+ const client = createAgentMuxClient({ resourceGateway: gw });
419
+
420
+ const status = await client.getJobStatus('nonexistent', 'ns');
421
+ assert.equal(status.active, 0);
422
+ assert.equal(status.succeeded, 0);
423
+ assert.equal(status.failed, 0);
424
+ assert.equal(status.startTime, null);
425
+ });
426
+
427
+ it('throws without resourceGateway', async () => {
428
+ const client = createAgentMuxClient({});
429
+ await assert.rejects(
430
+ () => client.getJobStatus('j', 'ns'),
431
+ { message: /requires a resourceGateway/ }
432
+ );
433
+ });
434
+ });
435
+
436
+ describe('getJobLogs', () => {
437
+ it('returns container logs', async () => {
438
+ const gw = createMockResourceGateway({
439
+ getLogsFn: () => 'line 1\nline 2\n',
440
+ });
441
+ const client = createAgentMuxClient({ resourceGateway: gw });
442
+
443
+ const logs = await client.getJobLogs('kradle-agent-run-1', 'kradle-org-acme');
444
+ assert.equal(logs, 'line 1\nline 2\n');
445
+ });
446
+
447
+ it('returns empty string when gateway has no getLogs', async () => {
448
+ const gw = createMockResourceGateway();
449
+ delete gw.getLogs;
450
+ const client = createAgentMuxClient({ resourceGateway: gw });
451
+
452
+ const logs = await client.getJobLogs('kradle-agent-run-1', 'kradle-org-acme');
453
+ assert.equal(logs, '');
454
+ });
455
+
456
+ it('throws without resourceGateway', async () => {
457
+ const client = createAgentMuxClient({});
458
+ await assert.rejects(
459
+ () => client.getJobLogs('j', 'ns'),
460
+ { message: /requires a resourceGateway/ }
461
+ );
462
+ });
463
+ });
464
+
465
+ describe('deleteJob', () => {
466
+ it('deletes job via resourceGateway', async () => {
467
+ const gw = createMockResourceGateway();
468
+ const client = createAgentMuxClient({ resourceGateway: gw });
469
+
470
+ const result = await client.deleteJob('kradle-agent-run-1', 'kradle-org-acme');
471
+ assert.equal(result.deleted, true);
472
+ assert.equal(gw.deleted.length, 1);
473
+ assert.equal(gw.deleted[0].name, 'kradle-agent-run-1');
474
+ });
475
+
476
+ it('throws without resourceGateway', async () => {
477
+ const client = createAgentMuxClient({});
478
+ await assert.rejects(
479
+ () => client.deleteJob('j', 'ns'),
480
+ { message: /requires a resourceGateway/ }
481
+ );
482
+ });
483
+ });
484
+
485
+ // ============================================================================
486
+ // PRIORITY 3: HTTP Gateway Fallback
487
+ // ============================================================================
488
+
489
+ describe('fallback to HTTP gateway when KRADLE_CONTROLLER_URL is set', () => {
490
+ it('isAvailable returns true only when gateway is set and enabled', () => {
491
+ const localClient = createAgentMuxClient({ gateway: '', enabled: false });
492
+ assert.equal(localClient.isAvailable(), false);
493
+
494
+ const gwClient = createAgentMuxClient({ gateway: 'http://localhost:8080', enabled: true });
495
+ assert.equal(gwClient.isAvailable(), true);
496
+ });
497
+
498
+ it('launchSession uses HTTP gateway when available', async () => {
499
+ // Just verify the gateway path returns null for unavailable (no real HTTP call)
500
+ const client = createAgentMuxClient({ gateway: '', enabled: false });
501
+ const result = await client.launchSession({ stack: { baseAgent: 'claude-code' } });
502
+ assert.equal(result, null);
503
+ });
504
+ });
505
+
506
+ // ============================================================================
507
+ // PRIORITY 4: Stack Resolution
508
+ // ============================================================================
509
+
510
+ describe('resolveStack', () => {
511
+ it('resolves basic stack with defaults', () => {
512
+ const stack = makeStack('basic-stack');
513
+ const controller = createAgentDispatchController();
514
+
515
+ const config = controller.resolveStack(stack);
516
+ assert.equal(config.adapter, 'claude-code');
517
+ assert.equal(config.provider, 'anthropic');
518
+ assert.equal(config.model, 'claude-sonnet-4-20250514');
519
+ assert.equal(config.approvalMode, 'prompt');
520
+ assert.deepEqual(config.mcpServers, []);
521
+ assert.deepEqual(config.skills, []);
522
+ assert.equal(config.env.KRADLE_ORG, 'default');
523
+ assert.equal(config.env.KRADLE_STACK_NAME, 'basic-stack');
524
+ });
525
+
526
+ it('uses explicit model, provider, and adapter from spec', () => {
527
+ const stack = makeStack('custom-stack', {
528
+ adapter: 'codex',
529
+ provider: 'openai',
530
+ model: 'o3-pro',
531
+ approvalMode: 'auto-approve',
532
+ });
533
+ const controller = createAgentDispatchController();
534
+
535
+ const config = controller.resolveStack(stack);
536
+ assert.equal(config.adapter, 'codex');
537
+ assert.equal(config.provider, 'openai');
538
+ assert.equal(config.model, 'o3-pro');
539
+ assert.equal(config.approvalMode, 'auto-approve');
540
+ });
541
+
542
+ it('includes prompt fields from spec', () => {
543
+ const stack = makeStack('prompt-stack', {
544
+ systemPrompt: 'You are a helpful agent.',
545
+ developerPrompt: 'Follow best practices.',
546
+ taskPrompt: 'Fix the CI pipeline.',
547
+ });
548
+ const controller = createAgentDispatchController();
549
+
550
+ const config = controller.resolveStack(stack);
551
+ assert.equal(config.prompt.system, 'You are a helpful agent.');
552
+ assert.equal(config.prompt.developer, 'Follow best practices.');
553
+ assert.equal(config.prompt.task, 'Fix the CI pipeline.');
554
+ });
555
+
556
+ it('includes mcpServerRefs and skillRefs', () => {
557
+ const stack = makeStack('refs-stack', {
558
+ mcpServerRefs: ['mcp-github', 'mcp-slack'],
559
+ skillRefs: ['skill-review', 'skill-deploy'],
560
+ });
561
+ const controller = createAgentDispatchController();
562
+
563
+ const config = controller.resolveStack(stack);
564
+ assert.deepEqual(config.mcpServers, ['mcp-github', 'mcp-slack']);
565
+ assert.deepEqual(config.skills, ['skill-review', 'skill-deploy']);
566
+ });
567
+
568
+ it('falls back to baseAgent when adapter is not set', () => {
569
+ const stack = makeStack('fallback-stack');
570
+ // Remove adapter, keep baseAgent
571
+ delete stack.spec.adapter;
572
+ stack.spec.baseAgent = 'gemini-cli';
573
+ const controller = createAgentDispatchController();
574
+
575
+ const config = controller.resolveStack(stack);
576
+ assert.equal(config.adapter, 'gemini-cli');
577
+ });
578
+
579
+ it('uses provided organizationRef in env', () => {
580
+ const stack = makeStack('org-stack');
581
+ const controller = createAgentDispatchController();
582
+
583
+ const config = controller.resolveStack(stack, { organizationRef: 'acme-corp' });
584
+ assert.equal(config.env.KRADLE_ORG, 'acme-corp');
585
+ });
586
+
587
+ it('throws for null stack', () => {
588
+ const controller = createAgentDispatchController();
589
+ assert.throws(() => controller.resolveStack(null), { message: /requires a valid AgentStack/ });
590
+ });
591
+
592
+ it('throws for stack without spec', () => {
593
+ const controller = createAgentDispatchController();
594
+ assert.throws(() => controller.resolveStack({ metadata: { name: 'bad' } }), { message: /requires a valid AgentStack/ });
595
+ });
596
+
597
+ it('returns null prompts when none set', () => {
598
+ const stack = makeStack('noprompt-stack');
599
+ const controller = createAgentDispatchController();
600
+
601
+ const config = controller.resolveStack(stack);
602
+ assert.equal(config.prompt.system, null);
603
+ assert.equal(config.prompt.developer, null);
604
+ assert.equal(config.prompt.task, null);
605
+ });
606
+
607
+ it('clones arrays to prevent mutation', () => {
608
+ const stack = makeStack('clone-stack', {
609
+ mcpServerRefs: ['mcp-a'],
610
+ skillRefs: ['skill-b'],
611
+ });
612
+ const controller = createAgentDispatchController();
613
+
614
+ const config = controller.resolveStack(stack);
615
+ config.mcpServers.push('mcp-c');
616
+ config.skills.push('skill-d');
617
+
618
+ // Original spec should be unchanged
619
+ assert.equal(stack.spec.mcpServerRefs.length, 1);
620
+ assert.equal(stack.spec.skillRefs.length, 1);
621
+ });
622
+ });
623
+
624
+ // ============================================================================
625
+ // PRIORITY 5: Dispatch Flow (K8s Job instead of subprocess)
626
+ // ============================================================================
627
+
628
+ describe('createManualDispatch with K8s Job', () => {
629
+ it('creates job instead of subprocess', async () => {
630
+ const gw = createMockResourceGateway();
631
+ const agentMuxClient = createAgentMuxClient({ resourceGateway: gw });
632
+ const controller = createAgentDispatchController({ agentMuxClient });
633
+ const resources = buildValidResources('test-stack');
634
+
635
+ const result = await controller.createManualDispatch({
636
+ repository: 'test-repo',
637
+ ref: 'main',
638
+ agentStack: 'test-stack',
639
+ actor: 'owner',
640
+ namespace: 'kradle-org-default',
641
+ organizationRef: 'default',
642
+ resources,
643
+ });
644
+
645
+ assert.equal(result.error, false);
646
+ assert.equal(result.run.status.phase, 'Running');
647
+ assert.ok(result.run.spec.jobRef);
648
+ assert.ok(result.attempt.status.jobName);
649
+ assert.equal(result.attempt.status.jobSubmitted, true);
650
+ assert.ok(result.jobResult);
651
+ assert.equal(result.jobResult.submitted, true);
652
+ // The job was submitted to the resource gateway
653
+ assert.equal(gw.applied.length, 1);
654
+ assert.equal(gw.applied[0].kind, 'Job');
655
+ });
656
+
657
+ it('queues run when job submission fails', async () => {
658
+ const gw = createMockResourceGateway({
659
+ applyFn: () => { throw new Error('cluster unreachable'); },
660
+ });
661
+ const agentMuxClient = createAgentMuxClient({ resourceGateway: gw });
662
+ const controller = createAgentDispatchController({ agentMuxClient });
663
+ const resources = buildValidResources('test-stack');
664
+
665
+ const result = await controller.createManualDispatch({
666
+ repository: 'test-repo',
667
+ ref: 'main',
668
+ agentStack: 'test-stack',
669
+ actor: 'owner',
670
+ namespace: 'kradle-org-default',
671
+ organizationRef: 'default',
672
+ resources,
673
+ });
674
+
675
+ assert.equal(result.error, false);
676
+ assert.equal(result.run.status.phase, 'Queued');
677
+ assert.ok(result.run.status.conditions.find(c => c.reason === 'SubmitFailed'));
678
+ assert.equal(result.jobResult.submitted, false);
679
+ });
680
+ });
681
+
682
+ // ============================================================================
683
+ // PRIORITY 6: Event Persistence
684
+ // ============================================================================
685
+
686
+ describe('persistSessionEvent', () => {
687
+ function makeRunAndAttempt() {
688
+ const run = createResource('AgentDispatchRun', { name: 'run-1', namespace: 'kradle-org-default' }, {
689
+ organizationRef: 'default',
690
+ repository: 'test-repo',
691
+ sourceRefs: ['main'],
692
+ agentStack: 'test-stack',
693
+ taskKind: 'diagnostic',
694
+ contextBundleRef: 'bundle-1',
695
+ });
696
+ run.status = { phase: 'Running', queuedAt: new Date().toISOString() };
697
+
698
+ const attempt = createResource('AgentDispatchAttempt', { name: 'run-1-attempt-1', namespace: 'kradle-org-default' }, {
699
+ organizationRef: 'default',
700
+ agentDispatchRun: 'run-1',
701
+ attemptReason: 'initial',
702
+ agentStackSnapshot: {},
703
+ contextBundleDigest: 'sha256:abc',
704
+ });
705
+ attempt.status = { agentMuxSessionId: 'sess-42', agentMuxRunId: 'amux-42', startedAt: new Date().toISOString() };
706
+
707
+ return { run, attempt };
708
+ }
709
+
710
+ it('appends message to transcript', () => {
711
+ const { run, attempt } = makeRunAndAttempt();
712
+ const controller = createAgentDispatchController();
713
+ const event = { role: 'assistant', content: 'Working on it...', timestamp: '2026-01-01T00:00:01Z' };
714
+
715
+ const result = controller.persistSessionEvent(event, run, attempt, {
716
+ namespace: 'kradle-org-default',
717
+ organizationRef: 'default',
718
+ });
719
+
720
+ assert.ok(result.transcript);
721
+ assert.equal(result.transcript.spec.messages.length, 1);
722
+ assert.equal(result.transcript.spec.messages[0].role, 'assistant');
723
+ assert.equal(result.transcript.spec.messages[0].content, 'Working on it...');
724
+ });
725
+
726
+ it('creates transcript if none provided', () => {
727
+ const { run, attempt } = makeRunAndAttempt();
728
+ const controller = createAgentDispatchController();
729
+
730
+ const result = controller.persistSessionEvent(
731
+ { role: 'user', content: 'Hello' },
732
+ run, attempt,
733
+ { namespace: 'kradle-org-default', organizationRef: 'default' }
734
+ );
735
+
736
+ assert.equal(result.transcript.kind, 'AgentSessionTranscript');
737
+ assert.equal(result.transcript.spec.sessionRef, 'sess-42');
738
+ assert.equal(result.transcript.status.phase, 'Streaming');
739
+ });
740
+
741
+ it('reuses existing transcript and appends', () => {
742
+ const { run, attempt } = makeRunAndAttempt();
743
+ const controller = createAgentDispatchController();
744
+ const transcript = createResource('AgentSessionTranscript', { name: 'transcript-sess-42', namespace: 'kradle-org-default' }, {
745
+ organizationRef: 'default',
746
+ sessionRef: 'sess-42',
747
+ messages: [{ role: 'user', content: 'First', timestamp: '2026-01-01T00:00:00Z' }],
748
+ cost: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
749
+ }, { phase: 'Streaming' });
750
+
751
+ const result = controller.persistSessionEvent(
752
+ { role: 'assistant', content: 'Second', usage: { inputTokens: 100, outputTokens: 50 } },
753
+ run, attempt,
754
+ { transcript }
755
+ );
756
+
757
+ assert.equal(result.transcript.spec.messages.length, 2);
758
+ assert.equal(result.transcript.spec.messages[1].content, 'Second');
759
+ assert.equal(result.transcript.spec.cost.inputTokens, 110);
760
+ assert.equal(result.transcript.spec.cost.outputTokens, 55);
761
+ assert.equal(result.transcript.spec.cost.totalTokens, 165);
762
+ });
763
+
764
+ it('marks run as Completed on completion event', () => {
765
+ const { run, attempt } = makeRunAndAttempt();
766
+ const controller = createAgentDispatchController();
767
+
768
+ const result = controller.persistSessionEvent(
769
+ { type: 'completion', role: 'assistant', content: 'All done' },
770
+ run, attempt,
771
+ { namespace: 'kradle-org-default', organizationRef: 'default' }
772
+ );
773
+
774
+ assert.equal(result.run.status.phase, 'Completed');
775
+ assert.ok(result.run.status.completedAt);
776
+ assert.ok(result.attempt.status.completedAt);
777
+ assert.equal(result.transcript.status.phase, 'Reconciled');
778
+ });
779
+
780
+ it('marks run as Failed on error event', () => {
781
+ const { run, attempt } = makeRunAndAttempt();
782
+ const controller = createAgentDispatchController();
783
+
784
+ const result = controller.persistSessionEvent(
785
+ { type: 'error', error: 'Out of tokens' },
786
+ run, attempt,
787
+ { namespace: 'kradle-org-default', organizationRef: 'default' }
788
+ );
789
+
790
+ assert.equal(result.run.status.phase, 'Failed');
791
+ assert.ok(result.run.status.failedAt);
792
+ assert.equal(result.run.status.failureReason, 'Out of tokens');
793
+ assert.ok(result.attempt.status.failedAt);
794
+ assert.equal(result.transcript.status.phase, 'Failed');
795
+ });
796
+
797
+ it('creates notification on completion', () => {
798
+ const { run, attempt } = makeRunAndAttempt();
799
+ const controller = createAgentDispatchController();
800
+
801
+ const result = controller.persistSessionEvent(
802
+ { type: 'completion', role: 'assistant', content: 'Done' },
803
+ run, attempt,
804
+ { organizationRef: 'acme' }
805
+ );
806
+
807
+ assert.ok(result.notification);
808
+ assert.equal(result.notification.type, 'run-complete');
809
+ assert.equal(result.notification.status, 'completed');
810
+ assert.equal(result.notification.org, 'acme');
811
+ });
812
+
813
+ it('creates notification on error', () => {
814
+ const { run, attempt } = makeRunAndAttempt();
815
+ const controller = createAgentDispatchController();
816
+
817
+ const result = controller.persistSessionEvent(
818
+ { type: 'error', message: 'Crashed' },
819
+ run, attempt,
820
+ { organizationRef: 'acme' }
821
+ );
822
+
823
+ assert.ok(result.notification);
824
+ assert.equal(result.notification.status, 'failed');
825
+ });
826
+
827
+ it('emits to event bus on each event', () => {
828
+ const bus = createEventBus();
829
+ const emitted = [];
830
+ bus.subscribe((evt) => emitted.push(evt));
831
+
832
+ const { run, attempt } = makeRunAndAttempt();
833
+ const controller = createAgentDispatchController({ eventBus: bus });
834
+
835
+ controller.persistSessionEvent(
836
+ { type: 'message', role: 'assistant', content: 'Thinking...' },
837
+ run, attempt,
838
+ { namespace: 'default', organizationRef: 'default' }
839
+ );
840
+
841
+ assert.equal(emitted.length, 1);
842
+ assert.equal(emitted[0].type, 'session-event');
843
+ assert.equal(emitted[0].runName, 'run-1');
844
+ });
845
+
846
+ it('emits notification event to bus on completion', () => {
847
+ const bus = createEventBus();
848
+ const emitted = [];
849
+ bus.subscribe((evt) => emitted.push(evt));
850
+
851
+ const { run, attempt } = makeRunAndAttempt();
852
+ const controller = createAgentDispatchController({ eventBus: bus });
853
+
854
+ controller.persistSessionEvent(
855
+ { type: 'completion', role: 'assistant', content: 'Done' },
856
+ run, attempt,
857
+ { namespace: 'default', organizationRef: 'default' }
858
+ );
859
+
860
+ // Should emit session-event, run-complete notification, and lifecycle hook events
861
+ assert.ok(emitted.length >= 2, `Expected at least 2 events, got ${emitted.length}`);
862
+ const sessionEvent = emitted.find(e => e.type === 'session-event');
863
+ assert.ok(sessionEvent, 'Should have a session-event');
864
+ const runComplete = emitted.find(e => e.type === 'run-complete');
865
+ assert.ok(runComplete, 'Should have a run-complete event');
866
+ assert.equal(runComplete.status, 'completed');
867
+ });
868
+
869
+ it('increments eventCount on attempt status', () => {
870
+ const { run, attempt } = makeRunAndAttempt();
871
+ const controller = createAgentDispatchController();
872
+
873
+ controller.persistSessionEvent({ role: 'user', content: 'msg1' }, run, attempt);
874
+ controller.persistSessionEvent({ role: 'assistant', content: 'msg2' }, run, attempt);
875
+ controller.persistSessionEvent({ role: 'assistant', content: 'msg3' }, run, attempt);
876
+
877
+ assert.equal(attempt.status.eventCount, 3);
878
+ assert.ok(attempt.status.lastEventAt);
879
+ });
880
+
881
+ it('handles null/non-object event gracefully', () => {
882
+ const { run, attempt } = makeRunAndAttempt();
883
+ const controller = createAgentDispatchController();
884
+
885
+ const result = controller.persistSessionEvent(null, run, attempt);
886
+ assert.equal(result.notification, null);
887
+ assert.equal(result.transcript, null);
888
+ });
889
+
890
+ it('handles event with toolUse metadata', () => {
891
+ const { run, attempt } = makeRunAndAttempt();
892
+ const controller = createAgentDispatchController();
893
+
894
+ const result = controller.persistSessionEvent(
895
+ { role: 'assistant', content: 'Reading file', toolUse: { name: 'read_file', input: { path: '/tmp/x' } } },
896
+ run, attempt,
897
+ { namespace: 'default', organizationRef: 'default' }
898
+ );
899
+
900
+ assert.deepEqual(result.transcript.spec.messages[0].toolUse, { name: 'read_file', input: { path: '/tmp/x' } });
901
+ });
902
+
903
+ it('accumulates token costs across multiple events', () => {
904
+ const { run, attempt } = makeRunAndAttempt();
905
+ const controller = createAgentDispatchController();
906
+
907
+ let result = controller.persistSessionEvent(
908
+ { role: 'assistant', content: 'A', usage: { inputTokens: 100, outputTokens: 50 } },
909
+ run, attempt,
910
+ { namespace: 'default', organizationRef: 'default' }
911
+ );
912
+
913
+ result = controller.persistSessionEvent(
914
+ { role: 'assistant', content: 'B', usage: { inputTokens: 200, outputTokens: 80 } },
915
+ run, attempt,
916
+ { transcript: result.transcript }
917
+ );
918
+
919
+ assert.equal(result.transcript.spec.cost.inputTokens, 300);
920
+ assert.equal(result.transcript.spec.cost.outputTokens, 130);
921
+ assert.equal(result.transcript.spec.cost.totalTokens, 430);
922
+ });
923
+ });
924
+
925
+ // ============================================================================
926
+ // PRIORITY 7: Callback Endpoint
927
+ // ============================================================================
928
+
929
+ describe('callback endpoint contract', () => {
930
+ it('agent callback payload shape is well-defined', () => {
931
+ // Verify the shape of a callback payload that the agent container posts
932
+ const payload = {
933
+ status: 'completed',
934
+ result: { summary: 'Fixed 3 bugs' },
935
+ transcript: [{ role: 'assistant', content: 'Done' }],
936
+ tokenUsage: { inputTokens: 1000, outputTokens: 500 },
937
+ artifacts: [{ name: 'patch.diff', type: 'file' }],
938
+ };
939
+
940
+ assert.ok(['completed', 'failed'].includes(payload.status));
941
+ assert.ok(payload.result);
942
+ assert.ok(Array.isArray(payload.transcript));
943
+ assert.ok(payload.tokenUsage.inputTokens >= 0);
944
+ assert.ok(payload.tokenUsage.outputTokens >= 0);
945
+ });
946
+
947
+ it('persistSessionEvent handles callback completion event', () => {
948
+ const controller = createAgentDispatchController();
949
+ const run = createResource('AgentDispatchRun', { name: 'run-cb', namespace: 'kradle-org-default' }, {
950
+ organizationRef: 'default', repository: 'repo', sourceRefs: [], agentStack: 'stack', taskKind: 'diagnostic', contextBundleRef: 'b',
951
+ });
952
+ run.status = { phase: 'Running', queuedAt: new Date().toISOString() };
953
+
954
+ const attempt = createResource('AgentDispatchAttempt', { name: 'run-cb-attempt-1', namespace: 'kradle-org-default' }, {
955
+ organizationRef: 'default', agentDispatchRun: 'run-cb', attemptReason: 'initial', agentStackSnapshot: {}, contextBundleDigest: 'sha256:x',
956
+ });
957
+ attempt.status = { agentMuxSessionId: 'sess-cb', startedAt: new Date().toISOString() };
958
+
959
+ // Simulate what the callback route would do after receiving agent POST
960
+ const result = controller.persistSessionEvent(
961
+ { type: 'completion', role: 'agent', content: 'Job finished', usage: { inputTokens: 500, outputTokens: 200 } },
962
+ run, attempt,
963
+ { namespace: 'kradle-org-default', organizationRef: 'default' }
964
+ );
965
+
966
+ assert.equal(result.run.status.phase, 'Completed');
967
+ assert.ok(result.run.status.completedAt);
968
+ assert.equal(result.transcript.spec.cost.inputTokens, 500);
969
+ assert.equal(result.transcript.spec.cost.outputTokens, 200);
970
+ });
971
+ });