@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,282 @@
|
|
|
1
|
+
// Agent Secret/Config Grant Controller — Secret & ConfigMap grant lifecycle
|
|
2
|
+
//
|
|
3
|
+
// Provides:
|
|
4
|
+
// - createAgentSecretGrantController() — validates AgentSecretGrant resources
|
|
5
|
+
// - createAgentConfigGrantController() — validates AgentConfigGrant resources
|
|
6
|
+
// - listGrantsForAgent(agentRef) — filter grants by target agent
|
|
7
|
+
// - revokeGrant(grantName) — mark a grant as revoked
|
|
8
|
+
|
|
9
|
+
import { createResource, clone } from './resource-model.js';
|
|
10
|
+
|
|
11
|
+
export const AGENT_SECRET_GRANT_CONTROLLER_BOUNDARY = {
|
|
12
|
+
role: 'agent-secret-grant-controller',
|
|
13
|
+
scope: 'AgentSecretGrant lifecycle — creation, listing, revocation for agent-to-secret access grants',
|
|
14
|
+
owns: ['grant creation', 'grant listing', 'grant revocation', 'input validation'],
|
|
15
|
+
delegatesTo: ['resource-model'],
|
|
16
|
+
mustNotOwn: ['Kubernetes Secret storage', 'agent execution', 'secret value decryption', 'UI rendering']
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const AGENT_CONFIG_GRANT_CONTROLLER_BOUNDARY = {
|
|
20
|
+
role: 'agent-config-grant-controller',
|
|
21
|
+
scope: 'AgentConfigGrant lifecycle — creation, listing, revocation for agent-to-configmap access grants',
|
|
22
|
+
owns: ['grant creation', 'grant listing', 'grant revocation', 'input validation'],
|
|
23
|
+
delegatesTo: ['resource-model'],
|
|
24
|
+
mustNotOwn: ['Kubernetes ConfigMap storage', 'agent execution', 'UI rendering']
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const VALID_PERMISSIONS = new Set(['read', 'use', 'mount']);
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Validation helpers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate an AgentSecretGrant input.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} input
|
|
37
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
38
|
+
*/
|
|
39
|
+
export function validateAgentSecretGrant(input) {
|
|
40
|
+
const errors = [];
|
|
41
|
+
if (!input) return { valid: false, errors: ['input must not be null or undefined'] };
|
|
42
|
+
if (!input.name || typeof input.name !== 'string') errors.push('name is required and must be a non-empty string');
|
|
43
|
+
if (!input.orgRef || typeof input.orgRef !== 'string') errors.push('orgRef is required and must be a non-empty string');
|
|
44
|
+
if (!input.secretName || typeof input.secretName !== 'string') errors.push('secretName is required and must be a non-empty string');
|
|
45
|
+
if (!input.grantedTo || typeof input.grantedTo !== 'string') errors.push('grantedTo is required and must be a non-empty string');
|
|
46
|
+
if (!Array.isArray(input.permissions) || input.permissions.length === 0) {
|
|
47
|
+
errors.push('permissions is required and must be a non-empty array');
|
|
48
|
+
} else {
|
|
49
|
+
const invalid = input.permissions.filter((p) => !VALID_PERMISSIONS.has(p));
|
|
50
|
+
if (invalid.length > 0) errors.push(`invalid permissions: ${invalid.join(', ')}. Valid values: ${[...VALID_PERMISSIONS].join(', ')}`);
|
|
51
|
+
}
|
|
52
|
+
return { valid: errors.length === 0, errors };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validate an AgentConfigGrant input.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} input
|
|
59
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
60
|
+
*/
|
|
61
|
+
export function validateAgentConfigGrant(input) {
|
|
62
|
+
const errors = [];
|
|
63
|
+
if (!input) return { valid: false, errors: ['input must not be null or undefined'] };
|
|
64
|
+
if (!input.name || typeof input.name !== 'string') errors.push('name is required and must be a non-empty string');
|
|
65
|
+
if (!input.orgRef || typeof input.orgRef !== 'string') errors.push('orgRef is required and must be a non-empty string');
|
|
66
|
+
if (!input.configMapName || typeof input.configMapName !== 'string') errors.push('configMapName is required and must be a non-empty string');
|
|
67
|
+
if (!input.grantedTo || typeof input.grantedTo !== 'string') errors.push('grantedTo is required and must be a non-empty string');
|
|
68
|
+
return { valid: errors.length === 0, errors };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// listGrantsForAgent — shared utility
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Filter grants by the grantedTo (agent ref) field from a collection of grant resources.
|
|
77
|
+
*
|
|
78
|
+
* @param {object[]} grants Array of AgentSecretGrant or AgentConfigGrant resources
|
|
79
|
+
* @param {string} agentRef The agent identifier to filter by
|
|
80
|
+
* @returns {object[]}
|
|
81
|
+
*/
|
|
82
|
+
export function listGrantsForAgent(grants, agentRef) {
|
|
83
|
+
if (!agentRef) return [];
|
|
84
|
+
return grants.filter((g) => g.spec?.grantedTo === agentRef || g.spec?.subject === agentRef);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// revokeGrant — shared utility
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Mark a grant as revoked by returning an updated copy of the resource.
|
|
93
|
+
*
|
|
94
|
+
* @param {object[]} grants Array of grant resources
|
|
95
|
+
* @param {string} grantName Name of the grant to revoke
|
|
96
|
+
* @returns {{ grant: object } | { error: true, reason: string, message: string }}
|
|
97
|
+
*/
|
|
98
|
+
export function revokeGrant(grants, grantName) {
|
|
99
|
+
if (!grantName) return { error: true, reason: 'missing-name', message: 'grantName is required' };
|
|
100
|
+
|
|
101
|
+
const found = grants.find((g) => g.metadata?.name === grantName);
|
|
102
|
+
if (!found) return { error: true, reason: 'not-found', message: `Grant not found: ${grantName}` };
|
|
103
|
+
|
|
104
|
+
if (found.status?.phase === 'Revoked') {
|
|
105
|
+
return { error: true, reason: 'already-revoked', message: `Grant "${grantName}" is already revoked` };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const updated = clone(found);
|
|
109
|
+
updated.status = {
|
|
110
|
+
...updated.status,
|
|
111
|
+
phase: 'Revoked',
|
|
112
|
+
revokedAt: new Date().toISOString()
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return { grant: updated };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// createAgentSecretGrantController
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create a controller for AgentSecretGrant resources.
|
|
124
|
+
*
|
|
125
|
+
* @param {{ persistFn?: (resource: object) => Promise<any> }} [opts]
|
|
126
|
+
* @returns {object}
|
|
127
|
+
*/
|
|
128
|
+
export function createAgentSecretGrantController({ persistFn } = {}) {
|
|
129
|
+
function persist(resource) {
|
|
130
|
+
if (typeof persistFn === 'function') {
|
|
131
|
+
Promise.resolve(persistFn(resource)).catch(() => {});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
role: 'agent-secret-grant-controller',
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create an AgentSecretGrant resource.
|
|
140
|
+
*
|
|
141
|
+
* @param {{ name, orgRef, secretName, grantedTo, permissions, namespace?, keys? }} input
|
|
142
|
+
* @returns {{ grant: object } | { error: true, message: string }}
|
|
143
|
+
*/
|
|
144
|
+
createSecretGrant({
|
|
145
|
+
name,
|
|
146
|
+
orgRef,
|
|
147
|
+
secretName,
|
|
148
|
+
grantedTo,
|
|
149
|
+
permissions = ['read'],
|
|
150
|
+
namespace = 'default',
|
|
151
|
+
keys = []
|
|
152
|
+
}) {
|
|
153
|
+
const validation = validateAgentSecretGrant({ name, orgRef, secretName, grantedTo, permissions });
|
|
154
|
+
if (!validation.valid) {
|
|
155
|
+
return { error: true, message: validation.errors.join('; ') };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const now = new Date().toISOString();
|
|
159
|
+
const grant = createResource('AgentSecretGrant', { name, namespace }, {
|
|
160
|
+
organizationRef: orgRef,
|
|
161
|
+
orgRef,
|
|
162
|
+
secretName,
|
|
163
|
+
secretRef: secretName,
|
|
164
|
+
grantedTo,
|
|
165
|
+
subject: grantedTo,
|
|
166
|
+
permissions,
|
|
167
|
+
keys,
|
|
168
|
+
purpose: permissions.join(',')
|
|
169
|
+
});
|
|
170
|
+
grant.status = { phase: 'Active', createdAt: now };
|
|
171
|
+
|
|
172
|
+
persist(grant);
|
|
173
|
+
return { grant };
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* List all grants for a specific agent.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} agentRef
|
|
180
|
+
* @param {object[]} grants
|
|
181
|
+
* @returns {object[]}
|
|
182
|
+
*/
|
|
183
|
+
listGrantsForAgent(agentRef, grants = []) {
|
|
184
|
+
return listGrantsForAgent(grants, agentRef);
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Revoke a grant by name.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} grantName
|
|
191
|
+
* @param {object[]} grants
|
|
192
|
+
* @returns {{ grant: object } | { error: true, reason: string, message: string }}
|
|
193
|
+
*/
|
|
194
|
+
revokeGrant(grantName, grants = []) {
|
|
195
|
+
const result = revokeGrant(grants, grantName);
|
|
196
|
+
if (!result.error && result.grant) persist(result.grant);
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// createAgentConfigGrantController
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Create a controller for AgentConfigGrant resources.
|
|
208
|
+
*
|
|
209
|
+
* @param {{ persistFn?: (resource: object) => Promise<any> }} [opts]
|
|
210
|
+
* @returns {object}
|
|
211
|
+
*/
|
|
212
|
+
export function createAgentConfigGrantController({ persistFn } = {}) {
|
|
213
|
+
function persist(resource) {
|
|
214
|
+
if (typeof persistFn === 'function') {
|
|
215
|
+
Promise.resolve(persistFn(resource)).catch(() => {});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
role: 'agent-config-grant-controller',
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Create an AgentConfigGrant resource.
|
|
224
|
+
*
|
|
225
|
+
* @param {{ name, orgRef, configMapName, grantedTo, namespace?, keys? }} input
|
|
226
|
+
* @returns {{ grant: object } | { error: true, message: string }}
|
|
227
|
+
*/
|
|
228
|
+
createConfigGrant({
|
|
229
|
+
name,
|
|
230
|
+
orgRef,
|
|
231
|
+
configMapName,
|
|
232
|
+
grantedTo,
|
|
233
|
+
namespace = 'default',
|
|
234
|
+
keys = []
|
|
235
|
+
}) {
|
|
236
|
+
const validation = validateAgentConfigGrant({ name, orgRef, configMapName, grantedTo });
|
|
237
|
+
if (!validation.valid) {
|
|
238
|
+
return { error: true, message: validation.errors.join('; ') };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const now = new Date().toISOString();
|
|
242
|
+
const grant = createResource('AgentConfigGrant', { name, namespace }, {
|
|
243
|
+
organizationRef: orgRef,
|
|
244
|
+
orgRef,
|
|
245
|
+
configMapName,
|
|
246
|
+
configMapRef: configMapName,
|
|
247
|
+
grantedTo,
|
|
248
|
+
subject: grantedTo,
|
|
249
|
+
keys,
|
|
250
|
+
purpose: 'read'
|
|
251
|
+
});
|
|
252
|
+
grant.status = { phase: 'Active', createdAt: now };
|
|
253
|
+
|
|
254
|
+
persist(grant);
|
|
255
|
+
return { grant };
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* List all grants for a specific agent.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} agentRef
|
|
262
|
+
* @param {object[]} grants
|
|
263
|
+
* @returns {object[]}
|
|
264
|
+
*/
|
|
265
|
+
listGrantsForAgent(agentRef, grants = []) {
|
|
266
|
+
return listGrantsForAgent(grants, agentRef);
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Revoke a grant by name.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} grantName
|
|
273
|
+
* @param {object[]} grants
|
|
274
|
+
* @returns {{ grant: object } | { error: true, reason: string, message: string }}
|
|
275
|
+
*/
|
|
276
|
+
revokeGrant(grantName, grants = []) {
|
|
277
|
+
const result = revokeGrant(grants, grantName);
|
|
278
|
+
if (!result.error && result.grant) persist(result.grant);
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Agent Session Transcript Controller — Slice 1.2e
|
|
2
|
+
// Manages AgentSessionTranscript resources: durable transcript storage,
|
|
3
|
+
// message indexing, and pagination support.
|
|
4
|
+
|
|
5
|
+
export const AGENT_SESSION_TRANSCRIPT_CONTROLLER_BOUNDARY = {
|
|
6
|
+
role: 'agent-session-transcript-controller',
|
|
7
|
+
scope: 'AgentSessionTranscript lifecycle: validation, message indexing, pagination, role/tool filtering',
|
|
8
|
+
owns: ['transcript validation', 'message indexing', 'pagination', 'role filter', 'tool name filter'],
|
|
9
|
+
delegatesTo: ['resource-model'],
|
|
10
|
+
mustNotOwn: ['session lifecycle', 'dispatch execution', 'Agent Mux sessions', 'secret values']
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const VALID_ROLES = ['user', 'assistant', 'tool', 'system'];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Validate an AgentSessionTranscript resource. Returns { valid, errors }.
|
|
17
|
+
* @param {object} resource
|
|
18
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
19
|
+
*/
|
|
20
|
+
export function validateAgentSessionTranscript(resource) {
|
|
21
|
+
const errors = [];
|
|
22
|
+
|
|
23
|
+
// Guard against null/undefined resource
|
|
24
|
+
if (resource == null) {
|
|
25
|
+
errors.push('resource must not be null or undefined');
|
|
26
|
+
return { valid: false, errors };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Validate metadata.name
|
|
30
|
+
if (!resource?.metadata?.name) {
|
|
31
|
+
errors.push('metadata.name is required');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const spec = resource?.spec || {};
|
|
35
|
+
|
|
36
|
+
// Validate organizationRef
|
|
37
|
+
if (!spec.organizationRef) {
|
|
38
|
+
errors.push('spec.organizationRef is required');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Validate sessionRef
|
|
42
|
+
if (!spec.sessionRef) {
|
|
43
|
+
errors.push('spec.sessionRef is required; provide the AgentSession ID this transcript is linked to');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Validate messages — must be an array (can be empty for a new transcript)
|
|
47
|
+
const messages = spec.messages;
|
|
48
|
+
if (!Array.isArray(messages)) {
|
|
49
|
+
errors.push('spec.messages must be an array');
|
|
50
|
+
} else {
|
|
51
|
+
// Validate each message shape
|
|
52
|
+
for (let i = 0; i < messages.length; i++) {
|
|
53
|
+
const msg = messages[i];
|
|
54
|
+
if (msg == null || typeof msg !== 'object') {
|
|
55
|
+
errors.push(`spec.messages[${i}] must be an object`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!msg.role) {
|
|
59
|
+
errors.push(`spec.messages[${i}].role is required`);
|
|
60
|
+
} else if (!VALID_ROLES.includes(msg.role)) {
|
|
61
|
+
errors.push(`spec.messages[${i}].role "${msg.role}" is not valid; valid roles are: ${VALID_ROLES.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
if (msg.content == null) {
|
|
64
|
+
errors.push(`spec.messages[${i}].content is required`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Validate pageSize if explicitly set
|
|
70
|
+
const pageSize = spec.pageSize;
|
|
71
|
+
if (pageSize != null && (!Number.isInteger(pageSize) || pageSize < 1)) {
|
|
72
|
+
errors.push('spec.pageSize must be a positive integer when specified');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { valid: errors.length === 0, errors };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Factory that returns an AgentSessionTranscript controller instance.
|
|
80
|
+
*/
|
|
81
|
+
export function createAgentSessionTranscriptController() {
|
|
82
|
+
return {
|
|
83
|
+
role: 'agent-session-transcript-controller',
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validate an AgentSessionTranscript resource.
|
|
87
|
+
* @param {object} resource
|
|
88
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
89
|
+
*/
|
|
90
|
+
validate(resource) {
|
|
91
|
+
return validateAgentSessionTranscript(resource);
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Return all messages in the transcript, in order.
|
|
96
|
+
* @param {object} resource
|
|
97
|
+
* @returns {Array<{ role: string, content: string, timestamp?: string, toolCalls?: object[] }>}
|
|
98
|
+
*/
|
|
99
|
+
getMessages(resource) {
|
|
100
|
+
if (resource == null) {
|
|
101
|
+
throw new Error('resource must not be null or undefined');
|
|
102
|
+
}
|
|
103
|
+
const messages = resource?.spec?.messages;
|
|
104
|
+
if (!Array.isArray(messages)) {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
return [...messages];
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Return the total number of messages in the transcript.
|
|
112
|
+
* @param {object} resource
|
|
113
|
+
* @returns {number}
|
|
114
|
+
*/
|
|
115
|
+
getTotalMessages(resource) {
|
|
116
|
+
if (resource == null) {
|
|
117
|
+
throw new Error('resource must not be null or undefined');
|
|
118
|
+
}
|
|
119
|
+
const messages = resource?.spec?.messages;
|
|
120
|
+
return Array.isArray(messages) ? messages.length : 0;
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Return a page of messages from the transcript.
|
|
125
|
+
* @param {object} resource
|
|
126
|
+
* @param {number} pageIndex - zero-based page index
|
|
127
|
+
* @param {number} [pageSizeOverride] - override spec.pageSize (defaults to spec.pageSize or 20)
|
|
128
|
+
* @returns {{ messages: object[], pageIndex: number, pageSize: number, totalMessages: number, totalPages: number }}
|
|
129
|
+
*/
|
|
130
|
+
getPage(resource, pageIndex, pageSizeOverride) {
|
|
131
|
+
if (resource == null) {
|
|
132
|
+
throw new Error('resource must not be null or undefined');
|
|
133
|
+
}
|
|
134
|
+
const messages = Array.isArray(resource?.spec?.messages) ? resource.spec.messages : [];
|
|
135
|
+
const pageSize = pageSizeOverride ?? resource?.spec?.pageSize ?? 20;
|
|
136
|
+
const totalMessages = messages.length;
|
|
137
|
+
const totalPages = Math.max(1, Math.ceil(totalMessages / pageSize));
|
|
138
|
+
const safePageIndex = Math.max(0, Math.min(pageIndex, totalPages - 1));
|
|
139
|
+
const start = safePageIndex * pageSize;
|
|
140
|
+
const end = start + pageSize;
|
|
141
|
+
return {
|
|
142
|
+
messages: messages.slice(start, end),
|
|
143
|
+
pageIndex: safePageIndex,
|
|
144
|
+
pageSize,
|
|
145
|
+
totalMessages,
|
|
146
|
+
totalPages
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Return all messages filtered by role.
|
|
152
|
+
* @param {object} resource
|
|
153
|
+
* @param {string} role - one of: user, assistant, tool, system
|
|
154
|
+
* @returns {Array<object>}
|
|
155
|
+
*/
|
|
156
|
+
getMessagesByRole(resource, role) {
|
|
157
|
+
if (resource == null) {
|
|
158
|
+
throw new Error('resource must not be null or undefined');
|
|
159
|
+
}
|
|
160
|
+
const messages = Array.isArray(resource?.spec?.messages) ? resource.spec.messages : [];
|
|
161
|
+
return messages.filter((m) => m.role === role);
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Return all messages that contain a tool call with the given tool name.
|
|
166
|
+
* @param {object} resource
|
|
167
|
+
* @param {string} toolName
|
|
168
|
+
* @returns {Array<object>}
|
|
169
|
+
*/
|
|
170
|
+
getMessagesByToolName(resource, toolName) {
|
|
171
|
+
if (resource == null) {
|
|
172
|
+
throw new Error('resource must not be null or undefined');
|
|
173
|
+
}
|
|
174
|
+
const messages = Array.isArray(resource?.spec?.messages) ? resource.spec.messages : [];
|
|
175
|
+
return messages.filter((m) => {
|
|
176
|
+
if (!Array.isArray(m.toolCalls)) return false;
|
|
177
|
+
return m.toolCalls.some((tc) => tc.name === toolName);
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Return the list of valid message roles.
|
|
183
|
+
* @returns {string[]}
|
|
184
|
+
*/
|
|
185
|
+
getValidRoles() {
|
|
186
|
+
return [...VALID_ROLES];
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -4,13 +4,45 @@ import { clone } from './resource-model.js';
|
|
|
4
4
|
export const AGENT_STACK_CONTROLLER_BOUNDARY = {
|
|
5
5
|
role: 'agent-stack-controller',
|
|
6
6
|
scope: 'Stack readiness reconciliation with capability resolution and condition management',
|
|
7
|
-
owns: ['capability resolution', 'stack conditions', 'readiness computation'],
|
|
7
|
+
owns: ['capability resolution', 'stack conditions', 'readiness computation', 'mcp health checks'],
|
|
8
8
|
delegatesTo: ['agent-permission-review', 'resource-model'],
|
|
9
9
|
mustNotOwn: ['secret values', 'dispatch execution', 'Agent Mux sessions']
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
const MCP_HEALTH_TIMEOUT_MS = 3000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Perform an HTTP health check for an MCP server endpoint.
|
|
16
|
+
* @param {string} url
|
|
17
|
+
* @param {Function|null} fetchFn
|
|
18
|
+
* @returns {Promise<{ status: string, latencyMs: number, error?: string }>}
|
|
19
|
+
*/
|
|
20
|
+
async function performMcpHealthCheck(url, fetchFn) {
|
|
21
|
+
const fn = fetchFn || globalThis.fetch;
|
|
22
|
+
const start = Date.now();
|
|
23
|
+
try {
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const timer = setTimeout(() => controller.abort(), MCP_HEALTH_TIMEOUT_MS);
|
|
26
|
+
let response;
|
|
27
|
+
try {
|
|
28
|
+
response = await fn(url, { signal: controller.signal });
|
|
29
|
+
} finally {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
}
|
|
32
|
+
const latencyMs = Date.now() - start;
|
|
33
|
+
if (response.ok) {
|
|
34
|
+
return { status: 'healthy', latencyMs };
|
|
35
|
+
}
|
|
36
|
+
return { status: 'unhealthy', latencyMs, error: `HTTP ${response.status}` };
|
|
37
|
+
} catch (err) {
|
|
38
|
+
const latencyMs = Date.now() - start;
|
|
39
|
+
return { status: 'unhealthy', latencyMs, error: err.message || String(err) };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
12
43
|
export function createAgentStackController(options = {}) {
|
|
13
44
|
const permissionReviewer = options.permissionReviewer || createPermissionReviewer();
|
|
45
|
+
const fetchFn = options.fetch || null;
|
|
14
46
|
|
|
15
47
|
return {
|
|
16
48
|
role: 'agent-stack-controller',
|
|
@@ -291,6 +323,25 @@ export function createAgentStackController(options = {}) {
|
|
|
291
323
|
}
|
|
292
324
|
|
|
293
325
|
return capabilities;
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Perform a health check for an AgentMcpServer resource.
|
|
330
|
+
* If no endpoint is configured in spec, returns { status: 'unknown', reason: 'no-endpoint' }.
|
|
331
|
+
* Otherwise performs a real HTTP GET with a 3s timeout.
|
|
332
|
+
* @param {object} mcpServer
|
|
333
|
+
* @returns {Promise<{ serverName: string, status: string, latencyMs?: number, reason?: string, error?: string }>}
|
|
334
|
+
*/
|
|
335
|
+
async checkMcpHealth(mcpServer) {
|
|
336
|
+
const serverName = mcpServer?.metadata?.name;
|
|
337
|
+
const endpoint = mcpServer?.spec?.endpoint;
|
|
338
|
+
|
|
339
|
+
if (!endpoint) {
|
|
340
|
+
return { serverName, status: 'unknown', reason: 'no-endpoint' };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const checkResult = await performMcpHealthCheck(endpoint, fetchFn);
|
|
344
|
+
return { serverName, ...checkResult };
|
|
294
345
|
}
|
|
295
346
|
};
|
|
296
347
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createResource, clone } from './resource-model.js';
|
|
2
|
+
|
|
3
|
+
export const AGENT_SUBAGENT_CONTROLLER_BOUNDARY = {
|
|
4
|
+
role: 'agent-subagent-controller',
|
|
5
|
+
scope: 'Subagent dispatch orchestration with tool scoping, role-based routing, and supervision protocol',
|
|
6
|
+
owns: ['subagent validation', 'dispatch record creation', 'tool scope resolution', 'task routing', 'supervision config'],
|
|
7
|
+
delegatesTo: ['resource-model'],
|
|
8
|
+
mustNotOwn: ['secret values', 'Agent Mux sessions', 'parent session lifecycle']
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const DEFAULT_SUPERVISION = {
|
|
12
|
+
monitorInterval: 60,
|
|
13
|
+
maxDuration: 7200,
|
|
14
|
+
autoTerminate: false
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function createAgentSubagentController() {
|
|
18
|
+
return {
|
|
19
|
+
role: 'agent-subagent-controller',
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate an AgentSubagent resource.
|
|
23
|
+
* Checks for required fields beyond what the CRD spec.requiredSpec covers:
|
|
24
|
+
* - metadata.name
|
|
25
|
+
* - spec.parentStackRef
|
|
26
|
+
* - spec.role
|
|
27
|
+
*/
|
|
28
|
+
validate(subagent) {
|
|
29
|
+
const errors = [];
|
|
30
|
+
|
|
31
|
+
if (!subagent?.metadata?.name) {
|
|
32
|
+
errors.push('metadata.name is required');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const spec = subagent?.spec || {};
|
|
36
|
+
|
|
37
|
+
if (!spec.parentStackRef) {
|
|
38
|
+
errors.push('spec.parentStackRef is required');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!spec.role) {
|
|
42
|
+
errors.push('spec.role is required');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
valid: errors.length === 0,
|
|
47
|
+
errors
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get the tool scope for a subagent.
|
|
53
|
+
* Returns { unrestricted: true, allowed: [], denied: [] } when no toolScope is set.
|
|
54
|
+
* Returns { unrestricted: false, allowed: [...], denied: [...] } when toolScope is configured.
|
|
55
|
+
*/
|
|
56
|
+
getToolScope(subagent) {
|
|
57
|
+
const toolScope = subagent?.spec?.toolScope;
|
|
58
|
+
if (!toolScope) {
|
|
59
|
+
return {
|
|
60
|
+
unrestricted: true,
|
|
61
|
+
allowed: [],
|
|
62
|
+
denied: []
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
unrestricted: false,
|
|
67
|
+
allowed: clone(toolScope.allowed || []),
|
|
68
|
+
denied: clone(toolScope.denied || [])
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get the list of explicitly denied tools for a subagent.
|
|
74
|
+
*/
|
|
75
|
+
getDeniedTools(subagent) {
|
|
76
|
+
const toolScope = subagent?.spec?.toolScope;
|
|
77
|
+
return clone(toolScope?.denied || []);
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Dispatch a subagent — creates a dispatch record linking the subagent to
|
|
82
|
+
* a parent session. Requires parentSessionRef to be provided.
|
|
83
|
+
*/
|
|
84
|
+
dispatchSubagent({ subagent, parentSessionRef, taskKind, namespace = 'default', organizationRef = 'default', resources = {} }) {
|
|
85
|
+
if (!parentSessionRef) {
|
|
86
|
+
return {
|
|
87
|
+
error: true,
|
|
88
|
+
reason: 'parentSessionRef-required',
|
|
89
|
+
message: 'parentSessionRef is required to dispatch a subagent'
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const subagentName = subagent?.metadata?.name;
|
|
94
|
+
const parentStackRef = subagent?.spec?.parentStackRef;
|
|
95
|
+
const role = subagent?.spec?.role;
|
|
96
|
+
const recordName = `subagent-dispatch-${subagentName}-${Date.now()}`;
|
|
97
|
+
|
|
98
|
+
const dispatchRecord = createResource(
|
|
99
|
+
'AgentDispatchRun',
|
|
100
|
+
{ name: recordName, namespace },
|
|
101
|
+
{
|
|
102
|
+
organizationRef,
|
|
103
|
+
repository: resources.AgentStack?.[0]?.spec?.repositoryRef || 'unknown',
|
|
104
|
+
sourceRefs: [],
|
|
105
|
+
agentStack: parentStackRef || 'unknown',
|
|
106
|
+
taskKind: taskKind || 'general',
|
|
107
|
+
parentSessionRef,
|
|
108
|
+
subagentRef: subagentName,
|
|
109
|
+
subagentRole: role
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
dispatchRecord.status = { phase: 'Queued', queuedAt: new Date().toISOString() };
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
dispatchRecord: clone(dispatchRecord)
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get supervision configuration for the subagent.
|
|
122
|
+
* Returns configured values or defaults when supervision is not set.
|
|
123
|
+
*/
|
|
124
|
+
getSupervisionConfig(subagent) {
|
|
125
|
+
const supervision = subagent?.spec?.supervision;
|
|
126
|
+
if (!supervision) {
|
|
127
|
+
return { ...DEFAULT_SUPERVISION };
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
monitorInterval: supervision.monitorInterval !== undefined ? supervision.monitorInterval : DEFAULT_SUPERVISION.monitorInterval,
|
|
131
|
+
maxDuration: supervision.maxDuration !== undefined ? supervision.maxDuration : DEFAULT_SUPERVISION.maxDuration,
|
|
132
|
+
autoTerminate: supervision.autoTerminate !== undefined ? supervision.autoTerminate : DEFAULT_SUPERVISION.autoTerminate
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Validate task routing: checks that the requested role maps to an available subagent.
|
|
138
|
+
*/
|
|
139
|
+
validateTaskRouting({ role, taskKind, subagents = [] }) {
|
|
140
|
+
const match = subagents.find(s => s?.spec?.role === role);
|
|
141
|
+
if (!match) {
|
|
142
|
+
return {
|
|
143
|
+
valid: false,
|
|
144
|
+
error: `No subagent found for role '${role}'`
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
valid: true,
|
|
149
|
+
matchedSubagent: clone(match)
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get current status of a subagent from its status field.
|
|
155
|
+
*/
|
|
156
|
+
getSubagentStatus(subagent) {
|
|
157
|
+
return clone(subagent?.status || { phase: 'idle' });
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|