@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.
- package/Dockerfile +31 -29
- package/bin/krate-demo.mjs +0 -0
- package/bin/krate-server.mjs +0 -0
- package/dist/krate-controller-ui.json +808 -10
- package/dist/krate-lifecycle.json +1 -1
- package/dist/krate-runtime-snapshot.json +223 -53
- package/dist/krate-summary.json +40 -3
- package/docs/agents/gaps-agent-mux-to-krate-crds.md +298 -0
- package/docs/architecture-v2.md +431 -0
- package/docs/openapi.yaml +1275 -0
- package/docs/requirements-v2.md +238 -0
- package/docs/sdk-api-reference.md +782 -0
- package/docs/system-spec-v2.md +352 -0
- package/docs/todos.md +4 -0
- package/docs/web-console-spec.md +433 -0
- package/package.json +1 -1
- package/scripts/validate-ui.mjs +305 -207
- package/src/agent-adapter-controller.js +169 -0
- package/src/agent-approval-controller.js +47 -0
- package/src/agent-dispatch-controller.js +130 -7
- package/src/agent-gateway-config-controller.js +147 -0
- package/src/agent-memory-controller.js +357 -0
- package/src/agent-memory-import.js +327 -0
- package/src/agent-memory-query.js +292 -0
- package/src/agent-memory-repository-source-controller.js +255 -0
- package/src/agent-mux-client.js +1 -1
- package/src/agent-permission-review.js +102 -14
- package/src/agent-project-controller.js +117 -0
- package/src/agent-provider-config-controller.js +150 -0
- package/src/agent-secret-config-grant-controller.js +282 -0
- package/src/agent-session-transcript-controller.js +189 -0
- package/src/agent-stack-controller.js +52 -1
- package/src/agent-subagent-controller.js +160 -0
- package/src/agent-transport-binding-controller.js +121 -0
- package/src/agent-trigger-controller.js +273 -0
- package/src/agent-workspace-controller.js +702 -0
- package/src/agent-writeback-controller.js +302 -0
- package/src/api-controller.js +338 -3
- package/src/async-controller.js +207 -0
- package/src/audit-controller.js +191 -0
- package/src/auth.js +48 -6
- package/src/controller-client.js +112 -38
- package/src/controller-ui.js +96 -16
- package/src/data-plane.js +3 -2
- package/src/event-bus.js +61 -0
- package/src/external/conflict-controller.js +225 -0
- package/src/external/github/auth.js +96 -0
- package/src/external/github/cicd.js +180 -0
- package/src/external/github/git-forge.js +240 -0
- package/src/external/github/index.js +144 -0
- package/src/external/github/issue-tracking.js +163 -0
- package/src/external/provider-adapter.js +161 -0
- package/src/external/provider-resource-factory.js +161 -0
- package/src/external/sync-controller.js +235 -0
- package/src/external/webhook-controller.js +144 -0
- package/src/external/write-controller.js +283 -0
- package/src/gitea-backend.js +36 -0
- package/src/gitea-service.js +173 -0
- package/src/http-server.js +226 -0
- package/src/index.js +27 -0
- package/src/kubernetes-controller-async.js +531 -0
- package/src/kubernetes-controller.js +156 -84
- package/src/notification-controller.js +178 -0
- package/src/org-scoping.js +5 -0
- package/src/resource-model.js +26 -8
- package/src/runner-controller.js +272 -0
- package/src/snapshot-cache.js +157 -0
- package/tests/agent-adapter-controller.test.js +361 -0
- package/tests/agent-dispatch-controller.test.js +139 -0
- package/tests/agent-gateway-config-controller.test.js +386 -0
- package/tests/agent-memory-controller.test.js +308 -0
- package/tests/agent-memory-import-snapshot.test.js +477 -0
- package/tests/agent-memory-query.test.js +404 -0
- package/tests/agent-memory-repository-source.test.js +514 -0
- package/tests/agent-permission-review-v2.test.js +317 -0
- package/tests/agent-project-controller.test.js +302 -0
- package/tests/agent-provider-config-controller.test.js +376 -0
- package/tests/agent-resources.test.js +35 -19
- package/tests/agent-secret-config-grant.test.js +231 -0
- package/tests/agent-session-transcript-controller.test.js +499 -0
- package/tests/agent-subagent-controller.test.js +201 -0
- package/tests/agent-transport-binding-controller.test.js +294 -0
- package/tests/agent-trigger-routes.test.js +190 -0
- package/tests/agent-trigger-sources.test.js +245 -0
- package/tests/agent-workspace-controller.test.js +181 -0
- package/tests/agent-writeback.test.js +292 -0
- package/tests/approval-persistence.test.js +171 -0
- package/tests/async-controller.test.js +252 -0
- package/tests/audit-controller.test.js +227 -0
- package/tests/codespace-controller.test.js +318 -0
- package/tests/controller-client.test.js +133 -0
- package/tests/deployment.test.js +43 -29
- package/tests/e2e/lifecycle.test.js +5 -2
- package/tests/event-bus-integration.test.js +190 -0
- package/tests/external-github-forge.test.js +560 -0
- package/tests/external-github-issues-cicd.test.js +520 -0
- package/tests/external-integration.test.js +470 -0
- package/tests/external-persistence.test.js +340 -0
- package/tests/external-provider-adapter.test.js +365 -0
- package/tests/external-resource-model.test.js +215 -0
- package/tests/external-webhook-sync.test.js +287 -0
- package/tests/external-write-conflict.test.js +353 -0
- package/tests/gitea-service.test.js +253 -0
- package/tests/health-check-real.test.js +165 -0
- package/tests/integration/full-flow.test.js +266 -0
- package/tests/krate.test.js +58 -6
- package/tests/memory-search-wiring.test.js +270 -0
- package/tests/notification-controller.test.js +196 -0
- package/tests/notification-integration.test.js +179 -0
- package/tests/org-scoping.test.js +687 -0
- package/tests/runner-controller.test.js +327 -0
- package/tests/runner-integration.test.js +231 -0
- package/tests/session-cookie-hmac.test.js +151 -0
- package/tests/snapshot-performance.test.js +315 -0
- package/tests/sse-events.test.js +107 -0
- package/tests/webhook-trigger.test.js +198 -0
- package/tests/workspace-volumes.test.js +312 -0
- package/tests/writeback-persistence.test.js +207 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createPermissionReviewer } from '../src/agent-permission-review.js';
|
|
4
|
+
import { createResource } from '../src/resource-model.js';
|
|
5
|
+
|
|
6
|
+
// ---------- shared helpers ----------
|
|
7
|
+
|
|
8
|
+
function makeStack(name, specOverrides = {}) {
|
|
9
|
+
return createResource('AgentStack', { name }, {
|
|
10
|
+
organizationRef: 'org-a',
|
|
11
|
+
baseAgent: 'claude-code',
|
|
12
|
+
adapter: 'babysitter',
|
|
13
|
+
runtimeIdentity: { serviceAccountRef: 'sa-agent' },
|
|
14
|
+
...specOverrides
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeServiceAccount(name, orgRef = 'org-a') {
|
|
19
|
+
return createResource('AgentServiceAccount', { name }, {
|
|
20
|
+
organizationRef: orgRef,
|
|
21
|
+
namespace: 'krate-agents',
|
|
22
|
+
serviceAccountName: name
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeRoleBinding(name, subject, orgRef = 'org-a') {
|
|
27
|
+
return createResource('AgentRoleBinding', { name }, {
|
|
28
|
+
organizationRef: orgRef,
|
|
29
|
+
subject,
|
|
30
|
+
roleRef: 'agent-role',
|
|
31
|
+
scope: 'namespace'
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeSecretGrant(name, subject, purpose, overrides = {}) {
|
|
36
|
+
return createResource('AgentSecretGrant', { name }, {
|
|
37
|
+
organizationRef: 'org-a',
|
|
38
|
+
subject,
|
|
39
|
+
secretRef: 'api-keys',
|
|
40
|
+
purpose,
|
|
41
|
+
...overrides
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeWorkspacePolicy(name, specOverrides = {}) {
|
|
46
|
+
return createResource('KrateWorkspacePolicy', { name }, {
|
|
47
|
+
organizationRef: 'org-a',
|
|
48
|
+
mode: 'ephemeral',
|
|
49
|
+
retentionPolicy: 'delete-on-completion',
|
|
50
|
+
...specOverrides
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fullyGrantedResources(stackOverrides = {}) {
|
|
55
|
+
return {
|
|
56
|
+
AgentStack: [makeStack('test-stack', stackOverrides)],
|
|
57
|
+
AgentServiceAccount: [makeServiceAccount('sa-agent')],
|
|
58
|
+
AgentRoleBinding: [makeRoleBinding('rb-1', 'sa-agent')],
|
|
59
|
+
AgentSecretGrant: [makeSecretGrant('sg-model', 'sa-agent', 'model-provider')],
|
|
60
|
+
AgentConfigGrant: [],
|
|
61
|
+
AgentMcpServer: [],
|
|
62
|
+
KrateWorkspacePolicy: []
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const baseInput = {
|
|
67
|
+
repository: 'org-a/my-repo',
|
|
68
|
+
ref: 'refs/heads/main',
|
|
69
|
+
actor: 'user-1',
|
|
70
|
+
agentStack: 'test-stack',
|
|
71
|
+
triggerSource: 'manual',
|
|
72
|
+
taskKind: 'fix'
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ==========================================================
|
|
76
|
+
// Group 1 — Cross-org denial
|
|
77
|
+
// ==========================================================
|
|
78
|
+
|
|
79
|
+
describe('agent permission review v2 — cross-org denial', () => {
|
|
80
|
+
it('allows access when agent org matches repository org', () => {
|
|
81
|
+
const reviewer = createPermissionReviewer();
|
|
82
|
+
const resources = fullyGrantedResources({ organizationRef: 'org-a' });
|
|
83
|
+
const result = reviewer.reviewPermissions({
|
|
84
|
+
...baseInput,
|
|
85
|
+
repository: 'org-a/my-repo',
|
|
86
|
+
resources
|
|
87
|
+
});
|
|
88
|
+
assert.equal(result.decision, 'allowed');
|
|
89
|
+
assert.ok(Array.isArray(result.crossOrgDenials), 'crossOrgDenials should be present');
|
|
90
|
+
assert.equal(result.crossOrgDenials.length, 0, 'no cross-org denials expected');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('denies access and populates crossOrgDenials when agent org differs from repository org', () => {
|
|
94
|
+
const reviewer = createPermissionReviewer();
|
|
95
|
+
// stack is in org-a but repository is in org-b
|
|
96
|
+
const resources = fullyGrantedResources({ organizationRef: 'org-a' });
|
|
97
|
+
const result = reviewer.reviewPermissions({
|
|
98
|
+
...baseInput,
|
|
99
|
+
repository: 'org-b/their-repo',
|
|
100
|
+
resources
|
|
101
|
+
});
|
|
102
|
+
assert.equal(result.decision, 'denied');
|
|
103
|
+
assert.ok(Array.isArray(result.crossOrgDenials), 'crossOrgDenials must be an array');
|
|
104
|
+
assert.ok(result.crossOrgDenials.length > 0, 'should have at least one cross-org denial entry');
|
|
105
|
+
const denial = result.crossOrgDenials[0];
|
|
106
|
+
assert.ok(denial.agentOrg, 'denial should include agentOrg');
|
|
107
|
+
assert.ok(denial.resourceOrg, 'denial should include resourceOrg');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('cross-org denial error is reflected in the reasons array with severity error', () => {
|
|
111
|
+
const reviewer = createPermissionReviewer();
|
|
112
|
+
const resources = fullyGrantedResources({ organizationRef: 'org-a' });
|
|
113
|
+
const result = reviewer.reviewPermissions({
|
|
114
|
+
...baseInput,
|
|
115
|
+
repository: 'org-z/external-repo',
|
|
116
|
+
resources
|
|
117
|
+
});
|
|
118
|
+
assert.ok(
|
|
119
|
+
result.reasons.some((r) => r.severity === 'error' && r.message.toLowerCase().includes('org')),
|
|
120
|
+
'reasons should contain an error mentioning org mismatch'
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ==========================================================
|
|
126
|
+
// Group 2 — Approval mode validation
|
|
127
|
+
// ==========================================================
|
|
128
|
+
|
|
129
|
+
describe('agent permission review v2 — approval mode', () => {
|
|
130
|
+
it("approvalMode 'yolo' auto-approves and decision is allowed when permissions are otherwise valid", () => {
|
|
131
|
+
const reviewer = createPermissionReviewer();
|
|
132
|
+
const resources = fullyGrantedResources({ approvalMode: 'yolo' });
|
|
133
|
+
const result = reviewer.reviewPermissions({
|
|
134
|
+
...baseInput,
|
|
135
|
+
resources
|
|
136
|
+
});
|
|
137
|
+
assert.equal(result.decision, 'allowed');
|
|
138
|
+
assert.equal(result.approvalMode, 'yolo');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("approvalMode 'deny' blocks all requests and returns denied", () => {
|
|
142
|
+
const reviewer = createPermissionReviewer();
|
|
143
|
+
const resources = fullyGrantedResources({ approvalMode: 'deny' });
|
|
144
|
+
const result = reviewer.reviewPermissions({
|
|
145
|
+
...baseInput,
|
|
146
|
+
resources
|
|
147
|
+
});
|
|
148
|
+
assert.equal(result.decision, 'denied');
|
|
149
|
+
assert.equal(result.approvalMode, 'deny');
|
|
150
|
+
assert.ok(
|
|
151
|
+
result.reasons.some((r) => r.severity === 'error' && r.message.toLowerCase().includes('deny')),
|
|
152
|
+
'reason should mention deny mode'
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("approvalMode 'prompt' keeps requires-approval when grants need approval", () => {
|
|
157
|
+
const reviewer = createPermissionReviewer();
|
|
158
|
+
const mcpServer = createResource('AgentMcpServer', { name: 'mcp-prod' }, {
|
|
159
|
+
organizationRef: 'org-a',
|
|
160
|
+
transport: 'stdio',
|
|
161
|
+
scope: 'workspace',
|
|
162
|
+
secretRef: 'prod-secret'
|
|
163
|
+
});
|
|
164
|
+
const stack = makeStack('test-stack', {
|
|
165
|
+
approvalMode: 'prompt',
|
|
166
|
+
mcpServerRefs: ['mcp-prod']
|
|
167
|
+
});
|
|
168
|
+
const mcpGrant = makeSecretGrant('sg-prod', 'sa-agent', 'mcp-server:mcp-prod', {
|
|
169
|
+
requiredApproval: 'always'
|
|
170
|
+
});
|
|
171
|
+
const resources = {
|
|
172
|
+
AgentStack: [stack],
|
|
173
|
+
AgentServiceAccount: [makeServiceAccount('sa-agent')],
|
|
174
|
+
AgentRoleBinding: [makeRoleBinding('rb-1', 'sa-agent')],
|
|
175
|
+
AgentSecretGrant: [mcpGrant, makeSecretGrant('sg-model', 'sa-agent', 'model-provider')],
|
|
176
|
+
AgentConfigGrant: [],
|
|
177
|
+
AgentMcpServer: [mcpServer],
|
|
178
|
+
KrateWorkspacePolicy: []
|
|
179
|
+
};
|
|
180
|
+
const result = reviewer.reviewPermissions({ ...baseInput, resources });
|
|
181
|
+
assert.equal(result.decision, 'requires-approval');
|
|
182
|
+
assert.equal(result.approvalMode, 'prompt');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('invalid approvalMode value causes denied with validation error', () => {
|
|
186
|
+
const reviewer = createPermissionReviewer();
|
|
187
|
+
const resources = fullyGrantedResources({ approvalMode: 'auto-accept-everything' });
|
|
188
|
+
const result = reviewer.reviewPermissions({
|
|
189
|
+
...baseInput,
|
|
190
|
+
resources
|
|
191
|
+
});
|
|
192
|
+
assert.equal(result.decision, 'denied');
|
|
193
|
+
assert.ok(
|
|
194
|
+
result.reasons.some((r) => r.severity === 'error' && r.message.toLowerCase().includes('approvalmode')),
|
|
195
|
+
'reasons should flag invalid approvalMode'
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ==========================================================
|
|
201
|
+
// Group 3 — Workspace policy enforcement
|
|
202
|
+
// ==========================================================
|
|
203
|
+
|
|
204
|
+
describe('agent permission review v2 — workspace policy', () => {
|
|
205
|
+
it('allowed when requested tool is in workspace policy allowedTools', () => {
|
|
206
|
+
const reviewer = createPermissionReviewer();
|
|
207
|
+
const policy = makeWorkspacePolicy('wp-1', { allowedTools: ['bash', 'read_file'] });
|
|
208
|
+
const resources = {
|
|
209
|
+
...fullyGrantedResources(),
|
|
210
|
+
KrateWorkspacePolicy: [policy]
|
|
211
|
+
};
|
|
212
|
+
const result = reviewer.reviewPermissions({
|
|
213
|
+
...baseInput,
|
|
214
|
+
workspacePolicyRef: 'wp-1',
|
|
215
|
+
toolRefs: ['bash'],
|
|
216
|
+
resources
|
|
217
|
+
});
|
|
218
|
+
assert.equal(result.decision, 'allowed');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('denied when requested tool is in workspace policy deniedTools', () => {
|
|
222
|
+
const reviewer = createPermissionReviewer();
|
|
223
|
+
const policy = makeWorkspacePolicy('wp-1', { deniedTools: ['bash'] });
|
|
224
|
+
const resources = {
|
|
225
|
+
...fullyGrantedResources(),
|
|
226
|
+
KrateWorkspacePolicy: [policy]
|
|
227
|
+
};
|
|
228
|
+
const result = reviewer.reviewPermissions({
|
|
229
|
+
...baseInput,
|
|
230
|
+
workspacePolicyRef: 'wp-1',
|
|
231
|
+
toolRefs: ['bash'],
|
|
232
|
+
resources
|
|
233
|
+
});
|
|
234
|
+
assert.equal(result.decision, 'denied');
|
|
235
|
+
assert.ok(
|
|
236
|
+
result.reasons.some((r) => r.severity === 'error' && r.message.toLowerCase().includes('denied')),
|
|
237
|
+
'reason should mention denied tool'
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('denied when maxConcurrentSessions is 0 (no sessions allowed by policy)', () => {
|
|
242
|
+
const reviewer = createPermissionReviewer();
|
|
243
|
+
const policy = makeWorkspacePolicy('wp-strict', { maxConcurrentSessions: 0 });
|
|
244
|
+
const resources = {
|
|
245
|
+
...fullyGrantedResources(),
|
|
246
|
+
KrateWorkspacePolicy: [policy]
|
|
247
|
+
};
|
|
248
|
+
const result = reviewer.reviewPermissions({
|
|
249
|
+
...baseInput,
|
|
250
|
+
workspacePolicyRef: 'wp-strict',
|
|
251
|
+
resources
|
|
252
|
+
});
|
|
253
|
+
assert.equal(result.decision, 'denied');
|
|
254
|
+
assert.ok(
|
|
255
|
+
result.reasons.some((r) => r.severity === 'error' && r.message.toLowerCase().includes('maxconcurrentsessions')),
|
|
256
|
+
'reason should mention maxConcurrentSessions'
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ==========================================================
|
|
262
|
+
// Group 4 — Untrusted fork detection
|
|
263
|
+
// ==========================================================
|
|
264
|
+
|
|
265
|
+
describe('agent permission review v2 — untrusted fork detection', () => {
|
|
266
|
+
it('no untrustedForkWarnings when ref is from the canonical repository', () => {
|
|
267
|
+
const reviewer = createPermissionReviewer();
|
|
268
|
+
const resources = fullyGrantedResources();
|
|
269
|
+
const result = reviewer.reviewPermissions({
|
|
270
|
+
...baseInput,
|
|
271
|
+
repository: 'org-a/my-repo',
|
|
272
|
+
ref: 'refs/heads/main',
|
|
273
|
+
resources
|
|
274
|
+
});
|
|
275
|
+
assert.ok(Array.isArray(result.untrustedForkWarnings), 'untrustedForkWarnings must be present');
|
|
276
|
+
assert.equal(result.untrustedForkWarnings.length, 0);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('untrustedForkWarnings populated when ref indicates a fork (refs/pull/*/head from external fork)', () => {
|
|
280
|
+
const reviewer = createPermissionReviewer();
|
|
281
|
+
// A pull_request ref from a fork is commonly refs/pull/<n>/head where the head sha is from a fork
|
|
282
|
+
const resources = fullyGrantedResources();
|
|
283
|
+
const result = reviewer.reviewPermissions({
|
|
284
|
+
...baseInput,
|
|
285
|
+
repository: 'org-a/my-repo',
|
|
286
|
+
ref: 'refs/pull/42/head',
|
|
287
|
+
isFork: true,
|
|
288
|
+
resources
|
|
289
|
+
});
|
|
290
|
+
assert.ok(Array.isArray(result.untrustedForkWarnings), 'untrustedForkWarnings must be present');
|
|
291
|
+
assert.ok(result.untrustedForkWarnings.length > 0, 'should have at least one fork warning');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('privileged grants (ServiceAccount, Secret) are blocked for untrusted forks', () => {
|
|
295
|
+
const reviewer = createPermissionReviewer();
|
|
296
|
+
const resources = fullyGrantedResources();
|
|
297
|
+
const result = reviewer.reviewPermissions({
|
|
298
|
+
...baseInput,
|
|
299
|
+
repository: 'org-a/my-repo',
|
|
300
|
+
ref: 'refs/pull/99/head',
|
|
301
|
+
isFork: true,
|
|
302
|
+
resources
|
|
303
|
+
});
|
|
304
|
+
// Privileged grants that existed should not be in the approved grants list
|
|
305
|
+
// or the decision should downgrade
|
|
306
|
+
const hasPrivilegedGrant = result.grants.some(
|
|
307
|
+
(g) => (g.kind === 'AgentServiceAccount' || g.kind === 'AgentSecretGrant') && g.status === 'granted'
|
|
308
|
+
);
|
|
309
|
+
// Either the grants are stripped or decision is denied/requires-approval
|
|
310
|
+
const isRestricted = !hasPrivilegedGrant || result.decision === 'denied' || result.decision === 'requires-approval';
|
|
311
|
+
assert.ok(isRestricted, 'privileged grants must not be silently approved for untrusted forks');
|
|
312
|
+
assert.ok(
|
|
313
|
+
result.untrustedForkWarnings.some((w) => w.blockedKinds && w.blockedKinds.length > 0),
|
|
314
|
+
'fork warning should list blocked kinds'
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { createAgentProjectController, validateAgentProject, createResource, AGENT_PROJECT_CONTROLLER_BOUNDARY } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Acceptance criteria: Slice 1.2d — Agent Project Controller
|
|
7
|
+
//
|
|
8
|
+
// A KrateProject groups issues and tasks into a kanban board with workflow
|
|
9
|
+
// columns, default column assignment, issue tracking, and board state management.
|
|
10
|
+
//
|
|
11
|
+
// All tests in this file are expected to FAIL until the controller is
|
|
12
|
+
// implemented and exported from src/index.js.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function makeProject(name, overrides = {}) {
|
|
16
|
+
return createResource('KrateProject', { name, namespace: 'krate-org-default' }, {
|
|
17
|
+
organizationRef: 'default',
|
|
18
|
+
workflowColumns: [
|
|
19
|
+
{ id: 'todo', displayName: 'To Do', color: '#888888', default: true },
|
|
20
|
+
{ id: 'in-progress', displayName: 'In Progress', color: '#0075ca' },
|
|
21
|
+
{ id: 'review', displayName: 'Review', color: '#e4e669' },
|
|
22
|
+
{ id: 'done', displayName: 'Done', color: '#0e8a16' }
|
|
23
|
+
],
|
|
24
|
+
...overrides
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// 1. Factory and shape
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
test('createAgentProjectController returns a controller with validate, getWorkflowColumns, getBoardState', () => {
|
|
33
|
+
const controller = createAgentProjectController();
|
|
34
|
+
assert.ok(controller, 'controller must be truthy');
|
|
35
|
+
assert.equal(typeof controller.validate, 'function', 'controller must expose a validate method');
|
|
36
|
+
assert.equal(typeof controller.getWorkflowColumns, 'function', 'controller must expose a getWorkflowColumns method');
|
|
37
|
+
assert.equal(typeof controller.getBoardState, 'function', 'controller must expose a getBoardState method');
|
|
38
|
+
assert.equal(controller.role, 'agent-project-controller', 'controller must declare its role');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// 2. validate — happy path
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
test('validate accepts valid project with name, organizationRef, workflowColumns', () => {
|
|
46
|
+
const controller = createAgentProjectController();
|
|
47
|
+
const project = makeProject('my-sprint');
|
|
48
|
+
const result = controller.validate(project);
|
|
49
|
+
|
|
50
|
+
assert.equal(result.valid, true, 'valid project must pass validation');
|
|
51
|
+
assert.ok(Array.isArray(result.errors), 'result must contain an errors array');
|
|
52
|
+
assert.equal(result.errors.length, 0, 'errors array must be empty for a valid project');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// 3. validate — missing name
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
test('validate rejects project with missing name', () => {
|
|
60
|
+
const controller = createAgentProjectController();
|
|
61
|
+
const project = {
|
|
62
|
+
apiVersion: 'krate.a5c.ai/v1alpha1',
|
|
63
|
+
kind: 'KrateProject',
|
|
64
|
+
metadata: { namespace: 'krate-org-default', labels: {}, annotations: {} },
|
|
65
|
+
spec: {
|
|
66
|
+
organizationRef: 'default',
|
|
67
|
+
workflowColumns: [{ id: 'todo', displayName: 'To Do', color: '#888888' }]
|
|
68
|
+
},
|
|
69
|
+
status: {}
|
|
70
|
+
};
|
|
71
|
+
const result = controller.validate(project);
|
|
72
|
+
|
|
73
|
+
assert.equal(result.valid, false, 'project without name must fail validation');
|
|
74
|
+
assert.ok(result.errors.length > 0, 'errors array must not be empty');
|
|
75
|
+
assert.ok(
|
|
76
|
+
result.errors.some((e) => /name/i.test(e)),
|
|
77
|
+
'at least one error must mention "name"'
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// 4. validate — missing organizationRef
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
test('validate rejects project with missing organizationRef', () => {
|
|
86
|
+
const controller = createAgentProjectController();
|
|
87
|
+
const project = makeProject('no-org-project', { organizationRef: undefined });
|
|
88
|
+
delete project.spec.organizationRef;
|
|
89
|
+
const result = controller.validate(project);
|
|
90
|
+
|
|
91
|
+
assert.equal(result.valid, false, 'project without organizationRef must fail validation');
|
|
92
|
+
assert.ok(result.errors.length > 0, 'errors array must not be empty');
|
|
93
|
+
assert.ok(
|
|
94
|
+
result.errors.some((e) => /organizationRef/i.test(e)),
|
|
95
|
+
'at least one error must mention "organizationRef"'
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// 5. validate — empty workflowColumns array
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
test('validate rejects project with empty workflowColumns array', () => {
|
|
104
|
+
const controller = createAgentProjectController();
|
|
105
|
+
const project = makeProject('no-columns-project', { workflowColumns: [] });
|
|
106
|
+
const result = controller.validate(project);
|
|
107
|
+
|
|
108
|
+
assert.equal(result.valid, false, 'project with empty workflowColumns must fail validation');
|
|
109
|
+
assert.ok(result.errors.length > 0, 'errors array must not be empty');
|
|
110
|
+
assert.ok(
|
|
111
|
+
result.errors.some((e) => /workflowColumns/i.test(e)),
|
|
112
|
+
'at least one error must mention "workflowColumns"'
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// 6. validate — duplicate column IDs
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
test('validate rejects project with duplicate column IDs', () => {
|
|
121
|
+
const controller = createAgentProjectController();
|
|
122
|
+
const project = makeProject('dup-columns-project', {
|
|
123
|
+
workflowColumns: [
|
|
124
|
+
{ id: 'todo', displayName: 'To Do', color: '#888888' },
|
|
125
|
+
{ id: 'todo', displayName: 'Also To Do', color: '#999999' },
|
|
126
|
+
{ id: 'done', displayName: 'Done', color: '#0e8a16' }
|
|
127
|
+
]
|
|
128
|
+
});
|
|
129
|
+
const result = controller.validate(project);
|
|
130
|
+
|
|
131
|
+
assert.equal(result.valid, false, 'project with duplicate column IDs must fail validation');
|
|
132
|
+
assert.ok(result.errors.length > 0, 'errors array must not be empty');
|
|
133
|
+
assert.ok(
|
|
134
|
+
result.errors.some((e) => /duplicate|column/i.test(e)),
|
|
135
|
+
'at least one error must mention duplicate column IDs'
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// 7. getWorkflowColumns — returns columns in order
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
test('getWorkflowColumns returns columns from spec with order preserved', () => {
|
|
144
|
+
const controller = createAgentProjectController();
|
|
145
|
+
const columns = [
|
|
146
|
+
{ id: 'backlog', displayName: 'Backlog', color: '#cccccc' },
|
|
147
|
+
{ id: 'todo', displayName: 'To Do', color: '#888888', default: true },
|
|
148
|
+
{ id: 'doing', displayName: 'Doing', color: '#0075ca' },
|
|
149
|
+
{ id: 'done', displayName: 'Done', color: '#0e8a16' }
|
|
150
|
+
];
|
|
151
|
+
const project = makeProject('ordered-project', { workflowColumns: columns });
|
|
152
|
+
const result = controller.getWorkflowColumns(project);
|
|
153
|
+
|
|
154
|
+
assert.ok(Array.isArray(result), 'getWorkflowColumns must return an array');
|
|
155
|
+
assert.equal(result.length, 4, 'must return all four columns');
|
|
156
|
+
assert.equal(result[0].id, 'backlog', 'first column must be backlog');
|
|
157
|
+
assert.equal(result[1].id, 'todo', 'second column must be todo');
|
|
158
|
+
assert.equal(result[2].id, 'doing', 'third column must be doing');
|
|
159
|
+
assert.equal(result[3].id, 'done', 'fourth column must be done');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// 8. getDefaultColumn — returns marked default or first column
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
test('getDefaultColumn returns the column marked as default', () => {
|
|
167
|
+
const controller = createAgentProjectController();
|
|
168
|
+
const project = makeProject('default-col-project', {
|
|
169
|
+
workflowColumns: [
|
|
170
|
+
{ id: 'todo', displayName: 'To Do', color: '#888888' },
|
|
171
|
+
{ id: 'in-progress', displayName: 'In Progress', color: '#0075ca', default: true },
|
|
172
|
+
{ id: 'done', displayName: 'Done', color: '#0e8a16' }
|
|
173
|
+
]
|
|
174
|
+
});
|
|
175
|
+
const col = controller.getDefaultColumn(project);
|
|
176
|
+
|
|
177
|
+
assert.ok(col, 'getDefaultColumn must return a column');
|
|
178
|
+
assert.equal(col.id, 'in-progress', 'must return the column marked as default');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('getDefaultColumn returns first column when none is marked as default', () => {
|
|
182
|
+
const controller = createAgentProjectController();
|
|
183
|
+
const project = makeProject('no-default-col-project', {
|
|
184
|
+
workflowColumns: [
|
|
185
|
+
{ id: 'backlog', displayName: 'Backlog', color: '#cccccc' },
|
|
186
|
+
{ id: 'todo', displayName: 'To Do', color: '#888888' },
|
|
187
|
+
{ id: 'done', displayName: 'Done', color: '#0e8a16' }
|
|
188
|
+
]
|
|
189
|
+
});
|
|
190
|
+
const col = controller.getDefaultColumn(project);
|
|
191
|
+
|
|
192
|
+
assert.ok(col, 'getDefaultColumn must return a column');
|
|
193
|
+
assert.equal(col.id, 'backlog', 'must return first column when no default is marked');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// 9. getBoardState — default is 'active'
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
test('getBoardState returns "active" by default when boardState is not set', () => {
|
|
201
|
+
const controller = createAgentProjectController();
|
|
202
|
+
const project = makeProject('active-project');
|
|
203
|
+
const state = controller.getBoardState(project);
|
|
204
|
+
|
|
205
|
+
assert.equal(state, 'active', 'getBoardState must return "active" by default');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// 10. getBoardState — returns spec.boardState when set
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
test('getBoardState returns spec.boardState when explicitly set', () => {
|
|
213
|
+
const controller = createAgentProjectController();
|
|
214
|
+
const project = makeProject('archived-project', { boardState: 'archived' });
|
|
215
|
+
const state = controller.getBoardState(project);
|
|
216
|
+
|
|
217
|
+
assert.equal(state, 'archived', 'getBoardState must return the spec boardState value');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// 11. validate — null resource
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
test('validate rejects null resource with a clear error', () => {
|
|
225
|
+
const controller = createAgentProjectController();
|
|
226
|
+
const result = controller.validate(null);
|
|
227
|
+
|
|
228
|
+
assert.equal(result.valid, false, 'null resource must fail validation');
|
|
229
|
+
assert.ok(result.errors.length > 0, 'errors array must not be empty');
|
|
230
|
+
assert.ok(
|
|
231
|
+
result.errors.some((e) => /null|undefined/i.test(e)),
|
|
232
|
+
'error must mention null or undefined'
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// 12. BOUNDARY exported with correct role
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
test('AGENT_PROJECT_CONTROLLER_BOUNDARY is exported and has correct role', () => {
|
|
241
|
+
assert.ok(AGENT_PROJECT_CONTROLLER_BOUNDARY, 'BOUNDARY must be exported');
|
|
242
|
+
assert.equal(
|
|
243
|
+
AGENT_PROJECT_CONTROLLER_BOUNDARY.role,
|
|
244
|
+
'agent-project-controller',
|
|
245
|
+
'BOUNDARY role must be "agent-project-controller"'
|
|
246
|
+
);
|
|
247
|
+
assert.ok(
|
|
248
|
+
Array.isArray(AGENT_PROJECT_CONTROLLER_BOUNDARY.owns),
|
|
249
|
+
'BOUNDARY must declare owned concerns'
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// 13. validateAgentProject — standalone export
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
test('validateAgentProject exported as standalone function follows existing pattern', () => {
|
|
258
|
+
assert.equal(typeof validateAgentProject, 'function', 'validateAgentProject must be a named export');
|
|
259
|
+
|
|
260
|
+
const project = makeProject('standalone-validate-project');
|
|
261
|
+
const result = validateAgentProject(project);
|
|
262
|
+
|
|
263
|
+
assert.ok(result, 'validateAgentProject must return a result');
|
|
264
|
+
assert.ok('valid' in result, 'result must have a valid property');
|
|
265
|
+
assert.ok(Array.isArray(result.errors), 'result must have an errors array');
|
|
266
|
+
assert.equal(result.valid, true, 'a fully-specified project must pass standalone validation');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// 14. validate — missing workflowColumns field entirely
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
test('validate rejects project with no workflowColumns field', () => {
|
|
274
|
+
const controller = createAgentProjectController();
|
|
275
|
+
const project = makeProject('missing-cols-project');
|
|
276
|
+
delete project.spec.workflowColumns;
|
|
277
|
+
const result = controller.validate(project);
|
|
278
|
+
|
|
279
|
+
assert.equal(result.valid, false, 'project missing workflowColumns must fail validation');
|
|
280
|
+
assert.ok(
|
|
281
|
+
result.errors.some((e) => /workflowColumns/i.test(e)),
|
|
282
|
+
'at least one error must mention "workflowColumns"'
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// 15. getWorkflowColumns — returns empty array when no spec
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
test('getWorkflowColumns handles resource with no spec gracefully', () => {
|
|
291
|
+
const controller = createAgentProjectController();
|
|
292
|
+
const resource = {
|
|
293
|
+
apiVersion: 'krate.a5c.ai/v1alpha1',
|
|
294
|
+
kind: 'KrateProject',
|
|
295
|
+
metadata: { name: 'spec-less-project', namespace: 'krate-org-default', labels: {}, annotations: {} },
|
|
296
|
+
status: {}
|
|
297
|
+
};
|
|
298
|
+
const result = controller.getWorkflowColumns(resource);
|
|
299
|
+
|
|
300
|
+
assert.ok(Array.isArray(result), 'getWorkflowColumns must return an array');
|
|
301
|
+
assert.equal(result.length, 0, 'must return empty array when spec is absent');
|
|
302
|
+
});
|