@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
package/src/controller-ui.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { giteaIssueSyncPlan, githubProjectIssueSyncPlan } from './gitea-backend.js';
|
|
1
2
|
import { resourceToYaml } from './resource-model.js';
|
|
2
3
|
import { KRATE_ORG_LABEL, KRATE_ORG_NAMESPACE_LABEL, KRATE_RESOURCES, apiResourceName, createKrateKubernetesReconciler, orgNamespaceName } from './kubernetes-controller.js';
|
|
3
4
|
|
|
@@ -31,18 +32,25 @@ const controllerEndpoints = [
|
|
|
31
32
|
{ method: 'GET', path: '/api/orgs/:org/agents/projects', purpose: 'list agent projects with board config' },
|
|
32
33
|
{ method: 'POST', path: '/api/orgs/:org/agents/dispatch', purpose: 'create manual agent dispatch run' },
|
|
33
34
|
{ method: 'POST', path: '/api/orgs/:org/agents/approvals/:name/decide', purpose: 'approve or deny a pending agent approval request' },
|
|
34
|
-
{ method: 'POST', path: '/api/orgs/:org/agents/triggers/process', purpose: 'evaluate an event against trigger rules and dispatch matching agents' }
|
|
35
|
+
{ method: 'POST', path: '/api/orgs/:org/agents/triggers/process', purpose: 'evaluate an event against trigger rules and dispatch matching agents' },
|
|
36
|
+
{ method: 'POST', path: '/api/orgs/:org/agents/workspaces', purpose: 'provision a new agent workspace with worktree and runtime' },
|
|
37
|
+
{ method: 'POST', path: '/api/orgs/:org/agents/workspaces/:name/archive', purpose: 'archive an agent workspace and mark it for cleanup' },
|
|
38
|
+
{ method: 'POST', path: '/api/orgs/:org/agents/workspaces/:name/link', purpose: 'link a work item to an agent workspace' },
|
|
39
|
+
{ method: 'POST', path: '/api/orgs/:org/agents/memory/query', purpose: 'query Company Brain memory with graph and grep search' },
|
|
40
|
+
{ method: 'POST', path: '/api/orgs/:org/agents/memory/imports', purpose: 'create a memory import from a babysitter run' },
|
|
41
|
+
{ method: 'GET', path: '/api/orgs/:org/agents/memory/snapshots', purpose: 'list memory snapshots for an organization' },
|
|
42
|
+
{ method: 'GET', path: '/api/orgs/:org/agents/memory/repositories', purpose: 'list memory repositories for an organization' }
|
|
35
43
|
];
|
|
36
44
|
|
|
37
45
|
const runtimeComponents = [
|
|
38
46
|
{ id: 'identity-access', title: 'Identity and access', area: 'identity', resources: ['User', 'Team', 'Invite', 'IdentityMapping', 'AuthProvider'], docs: 'src/auth.js' },
|
|
39
|
-
{ id: 'api-controller', title: 'Krate API controller', area: 'api', resources: ['Repository', 'PullRequest', 'Pipeline'], docs: 'src/api-controller.js' },
|
|
47
|
+
{ id: 'api-controller', title: 'Krate API controller', area: 'api', resources: ['Repository', 'KrateProject', 'PullRequest', 'Issue', 'Pipeline'], docs: 'src/api-controller.js' },
|
|
40
48
|
{ id: 'krate-resource-client', title: 'Krate resource client', area: 'control-plane', resources: ['Repository', 'BranchProtection', 'RefPolicy'], docs: 'src/kubernetes-controller.js' },
|
|
41
49
|
{ id: 'repository-service', title: 'Repository service', area: 'data-plane', resources: ['Repository', 'BranchProtection', 'RefPolicy'], docs: 'src/data-plane.js' },
|
|
42
50
|
{ id: 'runners-ci', title: 'Runner scheduler', area: 'ci', resources: ['RunnerPool', 'Pipeline', 'Job'], docs: 'src/kubernetes-controller.js' },
|
|
43
51
|
{ id: 'hooks-events', title: 'Webhook bus', area: 'events', resources: ['WebhookSubscription', 'WebhookDelivery'], docs: 'src/kubernetes-controller.js' },
|
|
44
52
|
{ id: 'policy-engine', title: 'Kyverno policy engine', area: 'policy', resources: ['PolicyProfile', 'PolicyTemplate', 'PolicyBinding', 'PolicyExceptionRequest'], docs: 'docs/todo-kyverno' },
|
|
45
|
-
{ id: 'agent-orchestration', title: 'Agent orchestration', area: 'agents', resources: ['AgentStack', 'AgentDispatchRun', 'AgentTriggerRule', 'AgentSession', '
|
|
53
|
+
{ id: 'agent-orchestration', title: 'Agent orchestration', area: 'agents', resources: ['AgentStack', 'AgentDispatchRun', 'AgentTriggerRule', 'AgentSession', 'KrateWorkspace', 'AgentApproval', 'AgentAdapter', 'AgentProviderConfig', 'KrateProject'], docs: 'docs/agents/' }
|
|
46
54
|
];
|
|
47
55
|
|
|
48
56
|
export function createControllerUiModel(source, options = {}) {
|
|
@@ -83,6 +91,7 @@ export function createControllerUiModel(source, options = {}) {
|
|
|
83
91
|
identityView.org = activeOrg?.slug;
|
|
84
92
|
const repositories = filterByOrg(snapshot.resources.Repository || [], activeOrg?.slug);
|
|
85
93
|
const pullRequests = filterByOrg(snapshot.resources.PullRequest || [], activeOrg?.slug);
|
|
94
|
+
const issues = filterByOrg(snapshot.resources.Issue || [], activeOrg?.slug);
|
|
86
95
|
const pipelines = filterByOrg(snapshot.resources.Pipeline || [], activeOrg?.slug);
|
|
87
96
|
const jobs = filterByOrg(snapshot.resources.Job || [], activeOrg?.slug);
|
|
88
97
|
const runnerPools = filterByOrg(snapshot.resources.RunnerPool || [], activeOrg?.slug);
|
|
@@ -97,13 +106,16 @@ export function createControllerUiModel(source, options = {}) {
|
|
|
97
106
|
const agentDispatchRuns = filterByOrg(snapshot.resources.AgentDispatchRun || [], activeOrg?.slug);
|
|
98
107
|
const agentTriggerRules = filterByOrg(snapshot.resources.AgentTriggerRule || [], activeOrg?.slug);
|
|
99
108
|
const agentSessions = filterByOrg(snapshot.resources.AgentSession || [], activeOrg?.slug);
|
|
100
|
-
const agentWorkspaces = filterByOrg(snapshot.resources.
|
|
109
|
+
const agentWorkspaces = filterByOrg(snapshot.resources.KrateWorkspace || [], activeOrg?.slug);
|
|
101
110
|
const agentApprovals = filterByOrg(snapshot.resources.AgentApproval || [], activeOrg?.slug);
|
|
102
111
|
const agentAdapters = filterByOrg(snapshot.resources.AgentAdapter || [], activeOrg?.slug);
|
|
103
112
|
const agentProviders = filterByOrg(snapshot.resources.AgentProviderConfig || [], activeOrg?.slug);
|
|
104
|
-
const agentProjects = filterByOrg(snapshot.resources.
|
|
113
|
+
const agentProjects = filterByOrg(snapshot.resources.KrateProject || [], activeOrg?.slug);
|
|
105
114
|
const agentGateway = filterByOrg(snapshot.resources.AgentGatewayConfig || [], activeOrg?.slug);
|
|
106
115
|
const agentTranscripts = filterByOrg(snapshot.resources.AgentSessionTranscript || [], activeOrg?.slug);
|
|
116
|
+
const memoryRepositories = filterByOrg(snapshot.resources.AgentMemoryRepository || [], activeOrg?.slug);
|
|
117
|
+
const memorySnapshots = filterByOrg(snapshot.resources.AgentMemorySnapshot || [], activeOrg?.slug);
|
|
118
|
+
const memoryImports = filterByOrg(snapshot.resources.AgentRunMemoryImport || [], activeOrg?.slug);
|
|
107
119
|
|
|
108
120
|
const agentView = {
|
|
109
121
|
org: activeOrg?.slug,
|
|
@@ -118,6 +130,9 @@ export function createControllerUiModel(source, options = {}) {
|
|
|
118
130
|
projects: { count: agentProjects.length, items: agentProjects },
|
|
119
131
|
gateway: agentGateway[0] || null,
|
|
120
132
|
transcripts: { count: agentTranscripts.length, items: agentTranscripts },
|
|
133
|
+
memoryRepositories: { count: memoryRepositories.length, items: memoryRepositories },
|
|
134
|
+
memorySnapshots: { count: memorySnapshots.length, items: memorySnapshots },
|
|
135
|
+
memoryImports: { count: memoryImports.length, items: memoryImports, pending: memoryImports.filter(i => !i.status?.phase || i.status.phase === 'Pending' || i.status.phase === 'AwaitingReview') },
|
|
121
136
|
};
|
|
122
137
|
const deploymentApplications = filterByOrg(snapshot.resources.KubeVelaApplication || [], activeOrg?.slug);
|
|
123
138
|
const deploymentReleases = filterByOrg(snapshot.resources.KubeVelaApplicationRevision || [], activeOrg?.slug);
|
|
@@ -170,6 +185,8 @@ export function createControllerUiModel(source, options = {}) {
|
|
|
170
185
|
invites: invites.length,
|
|
171
186
|
repositories: repositories.length,
|
|
172
187
|
pullRequests: pullRequests.length,
|
|
188
|
+
issues: issues.length,
|
|
189
|
+
projects: agentProjects.length,
|
|
173
190
|
pipelines: pipelines.length,
|
|
174
191
|
jobs: jobs.length,
|
|
175
192
|
runnerPools: runnerPools.length,
|
|
@@ -204,8 +221,11 @@ export function createControllerUiModel(source, options = {}) {
|
|
|
204
221
|
views: {
|
|
205
222
|
dashboard: {
|
|
206
223
|
repositories,
|
|
224
|
+
projects: agentProjects,
|
|
225
|
+
issues,
|
|
226
|
+
issueSync: issueSyncView({ org: activeOrg?.slug, projects: agentProjects, repositories, issues }),
|
|
207
227
|
excellentFlows: ['Create or import a repository', 'Browse code and copy clone commands', 'Review and merge a pull request', 'Debug a failing pipeline run', 'Edit runner pool capacity', 'Inspect and replay webhook deliveries', 'Save a triage View'],
|
|
208
|
-
cards: dashboardCards({ repositories, pullRequests, pipelines, runnerPools, webhookDeliveries })
|
|
228
|
+
cards: dashboardCards({ repositories, projects: agentProjects, pullRequests, issues, pipelines, runnerPools, webhookDeliveries })
|
|
209
229
|
},
|
|
210
230
|
pullRequestReview: pullRequests[0] ? pullRequestReview(pullRequests[0], pipelines, jobs) : null,
|
|
211
231
|
failingRun: pipelines.find((pipeline) => pipeline.status?.phase === 'Failed') ? failingRun(pipelines.find((pipeline) => pipeline.status?.phase === 'Failed'), jobs) : null,
|
|
@@ -244,13 +264,14 @@ function filterResourceItemsForOrg(definition, items = [], org) {
|
|
|
244
264
|
return filterByOrg(items, org);
|
|
245
265
|
}
|
|
246
266
|
|
|
247
|
-
function filterByOrg(items = [], org) {
|
|
248
|
-
if (!org) return items;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
267
|
+
function filterByOrg(items = [], org) {
|
|
268
|
+
if (!org) return items;
|
|
269
|
+
const orgNamespace = orgNamespaceName(org);
|
|
270
|
+
return items.filter((item) => {
|
|
271
|
+
const itemOrg = item.spec?.organizationRef || item.metadata?.labels?.[KRATE_ORG_LABEL];
|
|
272
|
+
return itemOrg === org || item.metadata?.namespace === orgNamespace;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
254
275
|
|
|
255
276
|
function normalizeSnapshot(source = {}) {
|
|
256
277
|
const raw = typeof source.snapshot === 'function' ? source.snapshot() : source;
|
|
@@ -281,9 +302,11 @@ function summarizePhases(items) {
|
|
|
281
302
|
}, {});
|
|
282
303
|
}
|
|
283
304
|
|
|
284
|
-
function dashboardCards({ repositories, pullRequests, pipelines, runnerPools, webhookDeliveries }) {
|
|
305
|
+
function dashboardCards({ repositories, projects = [], pullRequests, issues = [], pipelines, runnerPools, webhookDeliveries }) {
|
|
285
306
|
return [
|
|
286
307
|
{ label: 'Repositories', value: repositories.length, href: '/repositories' },
|
|
308
|
+
{ label: 'Projects', value: projects.length, href: '/agents/projects' },
|
|
309
|
+
{ label: 'Issues', value: issues.length, href: '/inbox' },
|
|
287
310
|
{ label: 'Pull requests', value: pullRequests.length, href: '/inbox' },
|
|
288
311
|
{ label: 'Runs', value: pipelines.length, href: '/runs' },
|
|
289
312
|
{ label: 'Runner pools', value: runnerPools.length, href: '/runners-ci' },
|
|
@@ -299,8 +322,65 @@ function defaultArchitecture(namespace) {
|
|
|
299
322
|
deliveryReconciler: { role: 'krate-delivery-reconciler', scope: 'Repository status projection, repository hosting intent, policy projection, and data-plane sync intent; never owns HTTP routes or browser flows', namespace, delegatesTo: ['krate-resource-gateway', 'repository-service'] },
|
|
300
323
|
repositoryService: { role: 'repository-service', scope: 'repository streaming and SSH hosting, object storage, and search indexing', boundary: process.env.KRATE_GITEA_HTTP_URL || 'repository service not configured' }
|
|
301
324
|
};
|
|
302
|
-
}
|
|
303
|
-
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function issueRepositoryRefs(issue = {}) {
|
|
328
|
+
return uniqueStrings([
|
|
329
|
+
issue.spec?.repository,
|
|
330
|
+
issue.spec?.repoRef,
|
|
331
|
+
issue.spec?.repositoryRef,
|
|
332
|
+
issue.metadata?.labels?.repository,
|
|
333
|
+
issue.metadata?.labels?.['krate.a5c.ai/repository'],
|
|
334
|
+
issue.metadata?.annotations?.['krate.a5c.ai/repository'],
|
|
335
|
+
issue.metadata?.annotations?.['krate.a5c.ai/repositories'],
|
|
336
|
+
issue.status?.repository,
|
|
337
|
+
issue.status?.repositoryRef,
|
|
338
|
+
...(issue.spec?.repositories || []),
|
|
339
|
+
...(issue.spec?.repositoryRefs || []),
|
|
340
|
+
...(issue.status?.repositories || []),
|
|
341
|
+
...(issue.status?.repositoryRefs || [])
|
|
342
|
+
]);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function issueProjectRefs(issue = {}) {
|
|
346
|
+
return uniqueStrings([
|
|
347
|
+
issue.spec?.project,
|
|
348
|
+
issue.spec?.projectRef,
|
|
349
|
+
issue.spec?.krateProject,
|
|
350
|
+
issue.spec?.krateProjectRef,
|
|
351
|
+
issue.metadata?.labels?.project,
|
|
352
|
+
issue.metadata?.labels?.['krate.a5c.ai/project'],
|
|
353
|
+
issue.metadata?.labels?.['krate.a5c.ai/krate-project'],
|
|
354
|
+
issue.metadata?.annotations?.['krate.a5c.ai/project'],
|
|
355
|
+
issue.metadata?.annotations?.['krate.a5c.ai/projects'],
|
|
356
|
+
issue.status?.project,
|
|
357
|
+
issue.status?.projectRef,
|
|
358
|
+
...(issue.spec?.projects || []),
|
|
359
|
+
...(issue.spec?.projectRefs || []),
|
|
360
|
+
...(issue.status?.projects || []),
|
|
361
|
+
...(issue.status?.projectRefs || [])
|
|
362
|
+
]);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function uniqueStrings(values = []) {
|
|
366
|
+
return [...new Set(values.flatMap(refNames).filter(Boolean).map(String))];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function refNames(value) {
|
|
370
|
+
if (value === undefined || value === null || value === '') return [];
|
|
371
|
+
if (Array.isArray(value)) return value.flatMap(refNames);
|
|
372
|
+
if (typeof value === 'string') return value.split(',').map((part) => part.trim()).filter(Boolean);
|
|
373
|
+
if (typeof value === 'object') return refNames(value.name || value.repository || value.repo || value.project || value.krateProject || value.metadata?.name || value.ref || value.slug);
|
|
374
|
+
return [String(value)];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function issueSyncView({ org = 'default', projects = [], repositories = [], issues = [] }) {
|
|
378
|
+
return {
|
|
379
|
+
gitea: giteaIssueSyncPlan({ org, project: projects[0]?.metadata?.name || null, issue: issues[0] || null, repositories: repositories.map((repo) => repo.metadata?.name).filter(Boolean) }),
|
|
380
|
+
github: githubProjectIssueSyncPlan({ org, project: projects[0]?.metadata?.name || null, issue: issues[0] || null, repositories: repositories.map((repo) => repo.metadata?.name).filter(Boolean) })
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
304
384
|
function pullRequestReview(pullRequest, pipelines, jobs) {
|
|
305
385
|
const pipelineRuns = pipelines.filter((pipeline) => pipeline.spec?.pullRequest === pullRequest.metadata?.name || pipeline.metadata?.labels?.pullrequest === pullRequest.metadata?.name);
|
|
306
386
|
return {
|
package/src/data-plane.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { giteaRepositoryIntegrationPlan } from './gitea-backend.js';
|
|
1
|
+
import { giteaIssueSyncPlan, giteaRepositoryIntegrationPlan, orgMemoryRepositoryName } from './gitea-backend.js';
|
|
2
2
|
import { clone, createResource } from './resource-model.js';
|
|
3
3
|
|
|
4
4
|
|
|
@@ -22,7 +22,8 @@ export function createGiteaRepositoryHosting({ backend = createDefaultGiteaGitBa
|
|
|
22
22
|
organization: { kind: 'Organization', name: owner, delegatedTo: 'Gitea /api/v1/orgs' },
|
|
23
23
|
sshKeys: { kind: 'SSHKey', scopes: ['user', 'deploy', 'argocd'], delegatedTo: 'Gitea /api/v1/user/keys and /repos/{owner}/{repo}/keys' },
|
|
24
24
|
permissions: { kind: 'RepositoryPermission', defaultCollaborator: 'write', adminTeam: 'maintainers', delegatedTo: 'Gitea collaborators and team repository APIs' },
|
|
25
|
-
forgeRecords: { issues:
|
|
25
|
+
forgeRecords: { issues: `Gitea /repos/${owner}/${orgMemoryRepositoryName(namespace)}/issues`, pullRequests: 'Gitea /repos/{owner}/{repo}/pulls' },
|
|
26
|
+
issueSync: giteaIssueSyncPlan({ org: namespace, repositories: [repository] }),
|
|
26
27
|
webhookUrl,
|
|
27
28
|
integrationPlan: giteaRepositoryIntegrationPlan({ owner, repo: repository, deployKeyTitle: 'krate-argocd', permission: 'write', branch, webhookUrl })
|
|
28
29
|
};
|
package/src/event-bus.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level event bus for SSE real-event streaming.
|
|
3
|
+
* Provides a pub/sub mechanism for resource change events.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates a new event bus with subscribe, unsubscribe, and emit methods.
|
|
8
|
+
* @returns {{ subscribe: Function, unsubscribe: Function, emit: Function, emitResourceChange: Function }}
|
|
9
|
+
*/
|
|
10
|
+
export function createEventBus() {
|
|
11
|
+
const listeners = new Set();
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
/**
|
|
15
|
+
* Subscribe a listener function to receive emitted events.
|
|
16
|
+
* @param {Function} fn - listener receiving the event object
|
|
17
|
+
*/
|
|
18
|
+
subscribe(fn) {
|
|
19
|
+
listeners.add(fn);
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Remove a previously subscribed listener.
|
|
24
|
+
* @param {Function} fn - the listener to remove
|
|
25
|
+
*/
|
|
26
|
+
unsubscribe(fn) {
|
|
27
|
+
listeners.delete(fn);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Emit an event to all current subscribers.
|
|
32
|
+
* @param {object} event - the event payload to broadcast
|
|
33
|
+
*/
|
|
34
|
+
emit(event) {
|
|
35
|
+
for (const fn of listeners) {
|
|
36
|
+
fn(event);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Emit a resource-change event with kind, name, operation, and timestamp.
|
|
42
|
+
* @param {string} kind - resource kind (e.g. 'Repository')
|
|
43
|
+
* @param {string} name - resource name
|
|
44
|
+
* @param {string} operation - operation performed (e.g. 'apply', 'delete')
|
|
45
|
+
*/
|
|
46
|
+
emitResourceChange(kind, name, operation) {
|
|
47
|
+
this.emit({
|
|
48
|
+
type: 'resource-change',
|
|
49
|
+
kind,
|
|
50
|
+
name,
|
|
51
|
+
operation,
|
|
52
|
+
timestamp: new Date().toISOString()
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Module-level singleton event bus shared across the HTTP server and API controller.
|
|
60
|
+
*/
|
|
61
|
+
export const globalEventBus = createEventBus();
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// External Conflict Controller — Slice 3.5
|
|
2
|
+
// Detects field divergence between local Krate state and external provider state,
|
|
3
|
+
// manages resolution workflows, and handles superseded conflict cleanup.
|
|
4
|
+
|
|
5
|
+
import { createResource, clone } from '../resource-model.js';
|
|
6
|
+
|
|
7
|
+
export const CONFLICT_CONTROLLER_BOUNDARY = {
|
|
8
|
+
role: 'external-conflict-controller',
|
|
9
|
+
scope: 'Field-level conflict detection and resolution for ExternalSyncConflict resources',
|
|
10
|
+
owns: ['conflict detection', 'resolution workflow', 'superseded cleanup', 'open conflict listing'],
|
|
11
|
+
delegatesTo: ['resource-model'],
|
|
12
|
+
mustNotOwn: ['write intent lifecycle', 'sync scheduling', 'external API client']
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const VALID_STRATEGIES = ['prefer-external', 'prefer-krate', 'manual', 'ignore'];
|
|
16
|
+
const OPEN_PHASES = new Set(['Open']);
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Validation
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validate a conflict detection input object.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} input
|
|
26
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
27
|
+
*/
|
|
28
|
+
export function validateConflict(input) {
|
|
29
|
+
const errors = [];
|
|
30
|
+
if (!input) {
|
|
31
|
+
return { valid: false, errors: ['input must not be null or undefined'] };
|
|
32
|
+
}
|
|
33
|
+
if (!input.resourceRef || typeof input.resourceRef !== 'string') {
|
|
34
|
+
errors.push('resourceRef is required and must be a non-empty string');
|
|
35
|
+
}
|
|
36
|
+
if (!input.fieldPath || typeof input.fieldPath !== 'string') {
|
|
37
|
+
errors.push('fieldPath is required and must be a non-empty string');
|
|
38
|
+
}
|
|
39
|
+
return { valid: errors.length === 0, errors };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Controller factory
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a ConflictController that manages ExternalSyncConflict resources.
|
|
48
|
+
*
|
|
49
|
+
* @param {{ persistFn?: (resource: object) => Promise<any> }} [opts]
|
|
50
|
+
* Optional persistFn is called (fire-and-forget) after conflict state changes.
|
|
51
|
+
* @returns {object}
|
|
52
|
+
*/
|
|
53
|
+
export function createConflictController({ persistFn } = {}) {
|
|
54
|
+
/**
|
|
55
|
+
* Fire-and-forget persistence helper.
|
|
56
|
+
* @param {object} resource
|
|
57
|
+
*/
|
|
58
|
+
function persist(resource) {
|
|
59
|
+
if (typeof persistFn === 'function') {
|
|
60
|
+
Promise.resolve(persistFn(resource)).catch(() => {});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
role: 'conflict-controller',
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Detect a conflict between local and external field values.
|
|
69
|
+
* Returns { conflict: null } when values match (no conflict).
|
|
70
|
+
* Returns { conflict: ExternalSyncConflict } when values differ.
|
|
71
|
+
*
|
|
72
|
+
* @param {{ resourceRef, fieldPath, localValue, externalValue, namespace?, organizationRef? }} input
|
|
73
|
+
* @returns {{ conflict: object|null }}
|
|
74
|
+
*/
|
|
75
|
+
detectConflict({
|
|
76
|
+
resourceRef,
|
|
77
|
+
fieldPath,
|
|
78
|
+
localValue,
|
|
79
|
+
externalValue,
|
|
80
|
+
namespace = 'default',
|
|
81
|
+
organizationRef = 'default'
|
|
82
|
+
}) {
|
|
83
|
+
const validation = validateConflict({ resourceRef, fieldPath });
|
|
84
|
+
if (!validation.valid) {
|
|
85
|
+
return { conflict: null, error: true, message: validation.errors.join('; ') };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Values match — no conflict
|
|
89
|
+
if (localValue === externalValue) {
|
|
90
|
+
return { conflict: null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const now = new Date().toISOString();
|
|
94
|
+
const conflictName = `conflict-${resourceRef.replace(/[^a-zA-Z0-9]/g, '-')}-${fieldPath.replace(/[^a-zA-Z0-9]/g, '-')}-${Date.now()}`;
|
|
95
|
+
|
|
96
|
+
const conflict = createResource('ExternalSyncConflict', { name: conflictName, namespace }, {
|
|
97
|
+
organizationRef,
|
|
98
|
+
resourceRef,
|
|
99
|
+
fieldPath,
|
|
100
|
+
localValue,
|
|
101
|
+
externalValue,
|
|
102
|
+
detectedAt: now
|
|
103
|
+
});
|
|
104
|
+
conflict.status = {
|
|
105
|
+
phase: 'Open',
|
|
106
|
+
detectedAt: now
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
persist(conflict);
|
|
110
|
+
return { conflict };
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve an Open conflict using the specified strategy.
|
|
115
|
+
*
|
|
116
|
+
* Strategies:
|
|
117
|
+
* - prefer-external: choose externalValue, phase → Resolved
|
|
118
|
+
* - prefer-krate: choose localValue, phase → Resolved
|
|
119
|
+
* - manual: choose resolvedValue, phase → Resolved (requires resolvedValue)
|
|
120
|
+
* - ignore: phase → Ignored (no value chosen)
|
|
121
|
+
*
|
|
122
|
+
* @param {{ conflictName, strategy, resolvedValue?, resources }} opts
|
|
123
|
+
* @returns {{ conflict: object, resolution: object } | { error: true, message: string }}
|
|
124
|
+
*/
|
|
125
|
+
resolveConflict({ conflictName, strategy, resolvedValue, resources = {} }) {
|
|
126
|
+
if (!conflictName) {
|
|
127
|
+
return { error: true, reason: 'missing-name', message: 'conflictName is required' };
|
|
128
|
+
}
|
|
129
|
+
if (!strategy || !VALID_STRATEGIES.includes(strategy)) {
|
|
130
|
+
return {
|
|
131
|
+
error: true,
|
|
132
|
+
reason: 'invalid-strategy',
|
|
133
|
+
message: `strategy must be one of: ${VALID_STRATEGIES.join(', ')}`
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const conflicts = resources.ExternalSyncConflict || [];
|
|
138
|
+
const found = conflicts.find((c) => c.metadata?.name === conflictName);
|
|
139
|
+
if (!found) {
|
|
140
|
+
return { error: true, reason: 'not-found', message: `ExternalSyncConflict not found: ${conflictName}` };
|
|
141
|
+
}
|
|
142
|
+
if (found.status?.phase !== 'Open') {
|
|
143
|
+
return { error: true, reason: 'invalid-phase', message: `Conflict is not Open: ${found.status?.phase}` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const updated = clone(found);
|
|
147
|
+
const now = new Date().toISOString();
|
|
148
|
+
let chosenValue;
|
|
149
|
+
let newPhase;
|
|
150
|
+
|
|
151
|
+
if (strategy === 'ignore') {
|
|
152
|
+
newPhase = 'Ignored';
|
|
153
|
+
chosenValue = undefined;
|
|
154
|
+
} else {
|
|
155
|
+
newPhase = 'Resolved';
|
|
156
|
+
if (strategy === 'prefer-external') {
|
|
157
|
+
chosenValue = found.spec.externalValue;
|
|
158
|
+
} else if (strategy === 'prefer-krate') {
|
|
159
|
+
chosenValue = found.spec.localValue;
|
|
160
|
+
} else if (strategy === 'manual') {
|
|
161
|
+
if (resolvedValue === undefined) {
|
|
162
|
+
return { error: true, reason: 'missing-resolved-value', message: 'resolvedValue is required for manual strategy' };
|
|
163
|
+
}
|
|
164
|
+
chosenValue = resolvedValue;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
updated.status = {
|
|
169
|
+
...updated.status,
|
|
170
|
+
phase: newPhase,
|
|
171
|
+
resolvedAt: now,
|
|
172
|
+
strategy,
|
|
173
|
+
chosenValue
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const resolution = { strategy, chosenValue };
|
|
177
|
+
|
|
178
|
+
persist(updated);
|
|
179
|
+
return { conflict: updated, resolution };
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Mark all Open conflicts for a given (resourceRef, fieldPath) pair as Superseded.
|
|
184
|
+
* Used when a new sync event arrives that makes old conflicts irrelevant.
|
|
185
|
+
*
|
|
186
|
+
* @param {{ resourceRef, fieldPath, resources }} opts
|
|
187
|
+
* @returns {{ superseded: object[] }}
|
|
188
|
+
*/
|
|
189
|
+
supersededCheck({ resourceRef, fieldPath, resources = {} }) {
|
|
190
|
+
const conflicts = resources.ExternalSyncConflict || [];
|
|
191
|
+
const now = new Date().toISOString();
|
|
192
|
+
|
|
193
|
+
const superseded = [];
|
|
194
|
+
for (const c of conflicts) {
|
|
195
|
+
if (
|
|
196
|
+
c.spec?.resourceRef === resourceRef &&
|
|
197
|
+
c.spec?.fieldPath === fieldPath &&
|
|
198
|
+
c.status?.phase === 'Open'
|
|
199
|
+
) {
|
|
200
|
+
const updated = clone(c);
|
|
201
|
+
updated.status = {
|
|
202
|
+
...updated.status,
|
|
203
|
+
phase: 'Superseded',
|
|
204
|
+
supersededAt: now
|
|
205
|
+
};
|
|
206
|
+
superseded.push(updated);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { superseded };
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Return all Open (non-resolved, non-ignored, non-superseded) conflicts.
|
|
215
|
+
*
|
|
216
|
+
* @param {{ resources }} opts
|
|
217
|
+
* @returns {{ conflicts: object[] }}
|
|
218
|
+
*/
|
|
219
|
+
getOpenConflicts({ resources = {} } = {}) {
|
|
220
|
+
const conflicts = resources.ExternalSyncConflict || [];
|
|
221
|
+
const open = conflicts.filter((c) => OPEN_PHASES.has(c.status?.phase));
|
|
222
|
+
return { conflicts: open };
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// GitHub App authentication helpers — Slice 3.3a
|
|
2
|
+
// Provides JWT signing (HMAC-SHA256 for test, RSA-SHA256 for production)
|
|
3
|
+
// and installation token exchange. No external dependencies; uses node:crypto.
|
|
4
|
+
|
|
5
|
+
import { createHmac, createSign } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
const GITHUB_API = 'https://api.github.com';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Encode a value as Base64url (RFC 4648 §5, no padding).
|
|
11
|
+
* @param {string|Buffer} data
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
function b64url(data) {
|
|
15
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
16
|
+
return buf.toString('base64url');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a GitHub App JWT.
|
|
21
|
+
*
|
|
22
|
+
* In production, pass a PEM-encoded RSA private key and the function will
|
|
23
|
+
* use RS256. For unit tests, pass any string key; if it does not look like
|
|
24
|
+
* a PEM file the function falls back to HS256 (HMAC-SHA256) so tests can
|
|
25
|
+
* run without real RSA keys.
|
|
26
|
+
*
|
|
27
|
+
* @param {{ appId: string, privateKey: string, expiresInSeconds?: number }} opts
|
|
28
|
+
* @returns {Promise<string>} A signed JWT string.
|
|
29
|
+
*/
|
|
30
|
+
export async function createGitHubJwt({ appId, privateKey, expiresInSeconds = 600 } = {}) {
|
|
31
|
+
if (!appId) throw new Error('createGitHubJwt: appId is required');
|
|
32
|
+
if (!privateKey) throw new Error('createGitHubJwt: privateKey is required');
|
|
33
|
+
|
|
34
|
+
const now = Math.floor(Date.now() / 1000);
|
|
35
|
+
const isRsa = privateKey.includes('-----BEGIN');
|
|
36
|
+
|
|
37
|
+
const alg = isRsa ? 'RS256' : 'HS256';
|
|
38
|
+
|
|
39
|
+
const header = b64url(JSON.stringify({ alg, typ: 'JWT' }));
|
|
40
|
+
const payload = b64url(JSON.stringify({
|
|
41
|
+
iat: now,
|
|
42
|
+
exp: now + expiresInSeconds,
|
|
43
|
+
iss: appId
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const signingInput = `${header}.${payload}`;
|
|
47
|
+
|
|
48
|
+
let signature;
|
|
49
|
+
if (isRsa) {
|
|
50
|
+
const sign = createSign('RSA-SHA256');
|
|
51
|
+
sign.update(signingInput);
|
|
52
|
+
sign.end();
|
|
53
|
+
signature = sign.sign(privateKey, 'base64url');
|
|
54
|
+
} else {
|
|
55
|
+
// HMAC-SHA256 fallback (test mode only)
|
|
56
|
+
const hmac = createHmac('sha256', privateKey);
|
|
57
|
+
hmac.update(signingInput);
|
|
58
|
+
signature = hmac.digest('base64url');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return `${signingInput}.${signature}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Exchange a GitHub App JWT for an installation access token.
|
|
66
|
+
*
|
|
67
|
+
* @param {{ appJwt: string, installationId: string|number, fetchImpl?: Function }} opts
|
|
68
|
+
* @returns {Promise<{ token: string, expiresAt: string }>}
|
|
69
|
+
*/
|
|
70
|
+
export async function exchangeInstallationToken({ appJwt, installationId, fetchImpl = globalThis.fetch } = {}) {
|
|
71
|
+
if (!appJwt) throw new Error('exchangeInstallationToken: appJwt is required');
|
|
72
|
+
if (!installationId) throw new Error('exchangeInstallationToken: installationId is required');
|
|
73
|
+
if (!fetchImpl) throw new Error('exchangeInstallationToken: a fetch implementation is required');
|
|
74
|
+
|
|
75
|
+
const url = `${GITHUB_API}/app/installations/${installationId}/access_tokens`;
|
|
76
|
+
|
|
77
|
+
const response = await fetchImpl(url, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: {
|
|
80
|
+
Accept: 'application/vnd.github+json',
|
|
81
|
+
Authorization: `Bearer ${appJwt}`,
|
|
82
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
83
|
+
'Content-Type': 'application/json'
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
throw new Error(`exchangeInstallationToken: GitHub API returned ${response.status} — authentication or token exchange failed`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const data = await response.json();
|
|
92
|
+
return {
|
|
93
|
+
token: data.token,
|
|
94
|
+
expiresAt: data.expires_at
|
|
95
|
+
};
|
|
96
|
+
}
|