@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,272 @@
|
|
|
1
|
+
import { createResource, clone } from './resource-model.js';
|
|
2
|
+
import { serviceAccountForJob } from './identity-policy.js';
|
|
3
|
+
|
|
4
|
+
export const RUNNER_CONTROLLER_BOUNDARY = {
|
|
5
|
+
role: 'runner-controller',
|
|
6
|
+
scope: 'RunnerPool lifecycle, runner scheduling, pod spec generation, job assignment',
|
|
7
|
+
owns: ['pool validation', 'runner lifecycle', 'job scheduling', 'pod spec generation', 'capacity tracking'],
|
|
8
|
+
delegatesTo: ['resource-model', 'identity-policy'],
|
|
9
|
+
mustNotOwn: ['Kubernetes API calls', 'actual pod creation', 'network I/O']
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const RUNNER_STATUSES = new Set(['Idle', 'Running', 'Terminating']);
|
|
13
|
+
const DEFAULT_IMAGE = 'ubuntu:24.04';
|
|
14
|
+
const DEFAULT_SERVICE_ACCOUNT = 'krate-runner';
|
|
15
|
+
const DEFAULT_NAMESPACE = 'krate-org-default';
|
|
16
|
+
|
|
17
|
+
export function createRunnerController() {
|
|
18
|
+
// In-memory runner registry (runner id → runner record)
|
|
19
|
+
const runners = new Map();
|
|
20
|
+
// job ref → runner id
|
|
21
|
+
const jobAssignments = new Map();
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
role: 'runner-controller',
|
|
25
|
+
|
|
26
|
+
// -------------------------------------------------------------------------
|
|
27
|
+
// Pool management
|
|
28
|
+
// -------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
validateRunnerPool(resource) {
|
|
31
|
+
if (!resource || typeof resource !== 'object') {
|
|
32
|
+
return { valid: false, reason: 'missing-resource', message: 'resource is required' };
|
|
33
|
+
}
|
|
34
|
+
const name = resource.metadata?.name;
|
|
35
|
+
if (!name) return { valid: false, reason: 'missing-name', message: 'metadata.name is required' };
|
|
36
|
+
|
|
37
|
+
const spec = resource.spec || {};
|
|
38
|
+
if (spec.organizationRef === undefined || spec.organizationRef === null || String(spec.organizationRef).trim() === '') return { valid: false, reason: 'missing-org', message: 'spec.organizationRef is required' };
|
|
39
|
+
|
|
40
|
+
const min = Number(spec.warmReplicas ?? 0);
|
|
41
|
+
const max = Number(spec.maxReplicas ?? 0);
|
|
42
|
+
if (!Number.isInteger(min) || min < 0) return { valid: false, reason: 'invalid-min-replicas', message: 'spec.warmReplicas must be a non-negative integer' };
|
|
43
|
+
if (!Number.isInteger(max) || max < 1) return { valid: false, reason: 'invalid-max-replicas', message: 'spec.maxReplicas must be a positive integer' };
|
|
44
|
+
if (min > max) return { valid: false, reason: 'replicas-conflict', message: 'spec.warmReplicas must not exceed spec.maxReplicas' };
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
valid: true,
|
|
48
|
+
name,
|
|
49
|
+
organizationRef: spec.organizationRef,
|
|
50
|
+
warmReplicas: min,
|
|
51
|
+
maxReplicas: max,
|
|
52
|
+
image: spec.image || DEFAULT_IMAGE,
|
|
53
|
+
labels: resource.metadata?.labels || {}
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
getPoolStatus(pool) {
|
|
58
|
+
const poolName = pool?.metadata?.name;
|
|
59
|
+
const poolRunners = [...runners.values()].filter((r) => r.poolName === poolName);
|
|
60
|
+
const idle = poolRunners.filter((r) => r.status === 'Idle').length;
|
|
61
|
+
const active = poolRunners.filter((r) => r.status === 'Running').length;
|
|
62
|
+
const terminating = poolRunners.filter((r) => r.status === 'Terminating').length;
|
|
63
|
+
const total = poolRunners.length;
|
|
64
|
+
const desired = Number(pool?.spec?.warmReplicas ?? 0);
|
|
65
|
+
const maxReplicas = Number(pool?.spec?.maxReplicas ?? 0);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
poolName,
|
|
69
|
+
idle,
|
|
70
|
+
active,
|
|
71
|
+
terminating,
|
|
72
|
+
total,
|
|
73
|
+
desired,
|
|
74
|
+
maxReplicas,
|
|
75
|
+
phase: total === 0 ? 'Empty' : active > 0 ? 'Active' : 'Idle',
|
|
76
|
+
scaling: total < desired ? 'ScalingUp' : total > maxReplicas ? 'ScalingDown' : 'Stable'
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
getCapacity(pool) {
|
|
81
|
+
const poolName = pool?.metadata?.name;
|
|
82
|
+
const poolRunners = [...runners.values()].filter((r) => r.poolName === poolName);
|
|
83
|
+
const maxReplicas = Number(pool?.spec?.maxReplicas ?? 0);
|
|
84
|
+
const used = poolRunners.filter((r) => r.status === 'Running').length;
|
|
85
|
+
const available = Math.max(0, maxReplicas - used);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
poolName,
|
|
89
|
+
maxReplicas,
|
|
90
|
+
used,
|
|
91
|
+
available,
|
|
92
|
+
utilizationPct: maxReplicas > 0 ? Math.round((used / maxReplicas) * 100) : 0
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// -------------------------------------------------------------------------
|
|
97
|
+
// Runner lifecycle
|
|
98
|
+
// -------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
createRunner(pool, runRef = null) {
|
|
101
|
+
const poolName = pool?.metadata?.name;
|
|
102
|
+
if (!poolName) return { error: true, reason: 'missing-pool', message: 'pool.metadata.name is required' };
|
|
103
|
+
|
|
104
|
+
const namespace = pool?.metadata?.namespace || DEFAULT_NAMESPACE;
|
|
105
|
+
const spec = pool?.spec || {};
|
|
106
|
+
const runnerId = `runner-${poolName}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
107
|
+
|
|
108
|
+
const workspace = runRef ? { runRef, mountPath: '/workspace' } : null;
|
|
109
|
+
const podSpec = this.generatePodSpec({ runnerId, pool }, workspace);
|
|
110
|
+
|
|
111
|
+
const runner = {
|
|
112
|
+
id: runnerId,
|
|
113
|
+
poolName,
|
|
114
|
+
namespace,
|
|
115
|
+
status: runRef ? 'Running' : 'Idle',
|
|
116
|
+
runRef: runRef || null,
|
|
117
|
+
createdAt: new Date().toISOString(),
|
|
118
|
+
image: spec.image || DEFAULT_IMAGE,
|
|
119
|
+
podSpec
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
runners.set(runnerId, runner);
|
|
123
|
+
if (runRef) jobAssignments.set(runRef, runnerId);
|
|
124
|
+
|
|
125
|
+
return { error: false, runnerId, runner: clone(runner) };
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
terminateRunner(runnerId) {
|
|
129
|
+
const runner = runners.get(runnerId);
|
|
130
|
+
if (!runner) return { error: true, reason: 'not-found', message: `Runner not found: ${runnerId}` };
|
|
131
|
+
|
|
132
|
+
// Remove job assignment if any
|
|
133
|
+
if (runner.runRef) jobAssignments.delete(runner.runRef);
|
|
134
|
+
|
|
135
|
+
runner.status = 'Terminating';
|
|
136
|
+
runner.terminatedAt = new Date().toISOString();
|
|
137
|
+
runners.delete(runnerId);
|
|
138
|
+
|
|
139
|
+
return { error: false, runnerId, terminatedAt: runner.terminatedAt };
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
// -------------------------------------------------------------------------
|
|
143
|
+
// Job scheduling
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
scheduleJob(pool, job) {
|
|
147
|
+
const jobRef = job?.metadata?.name || job?.ref;
|
|
148
|
+
if (!jobRef) return { error: true, reason: 'missing-job-ref', message: 'job.metadata.name or job.ref is required' };
|
|
149
|
+
|
|
150
|
+
const poolName = pool?.metadata?.name;
|
|
151
|
+
if (!poolName) return { error: true, reason: 'missing-pool', message: 'pool.metadata.name is required' };
|
|
152
|
+
|
|
153
|
+
// Check if already assigned
|
|
154
|
+
if (jobAssignments.has(jobRef)) {
|
|
155
|
+
const existingRunnerId = jobAssignments.get(jobRef);
|
|
156
|
+
return { error: false, runnerId: existingRunnerId, reused: true };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Find an idle runner in this pool
|
|
160
|
+
const idleRunner = [...runners.values()].find(
|
|
161
|
+
(r) => r.poolName === poolName && r.status === 'Idle'
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
if (idleRunner) {
|
|
165
|
+
idleRunner.status = 'Running';
|
|
166
|
+
idleRunner.runRef = jobRef;
|
|
167
|
+
idleRunner.assignedAt = new Date().toISOString();
|
|
168
|
+
jobAssignments.set(jobRef, idleRunner.id);
|
|
169
|
+
return { error: false, runnerId: idleRunner.id, reused: false, runner: clone(idleRunner) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Check capacity for a new runner
|
|
173
|
+
const capacity = this.getCapacity(pool);
|
|
174
|
+
if (capacity.available <= 0) {
|
|
175
|
+
return { error: true, reason: 'no-capacity', message: `Pool ${poolName} has no available capacity (${capacity.used}/${capacity.maxReplicas})` };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Create a new runner
|
|
179
|
+
const result = this.createRunner(pool, jobRef);
|
|
180
|
+
if (result.error) return result;
|
|
181
|
+
return { error: false, runnerId: result.runnerId, reused: false, runner: result.runner };
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
getRunnerForJob(jobRef) {
|
|
185
|
+
const runnerId = jobAssignments.get(jobRef);
|
|
186
|
+
if (!runnerId) return null;
|
|
187
|
+
const runner = runners.get(runnerId);
|
|
188
|
+
return runner ? clone(runner) : null;
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
// -------------------------------------------------------------------------
|
|
192
|
+
// Pod spec generation
|
|
193
|
+
// -------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
generatePodSpec({ runnerId, pool }, workspace = null) {
|
|
196
|
+
const spec = pool?.spec || {};
|
|
197
|
+
const namespace = pool?.metadata?.namespace || DEFAULT_NAMESPACE;
|
|
198
|
+
const image = spec.image || DEFAULT_IMAGE;
|
|
199
|
+
const organizationRef = spec.organizationRef || 'default';
|
|
200
|
+
const serviceAccountName = spec.serviceAccount || DEFAULT_SERVICE_ACCOUNT;
|
|
201
|
+
const runId = workspace?.runRef || runnerId;
|
|
202
|
+
|
|
203
|
+
const resourceLimits = spec.resourceLimits || {};
|
|
204
|
+
const resourceRequests = spec.resourceRequests || {};
|
|
205
|
+
|
|
206
|
+
const envVars = [
|
|
207
|
+
{ name: 'KRATE_ORG', value: organizationRef },
|
|
208
|
+
{ name: 'KRATE_RUN_ID', value: runId },
|
|
209
|
+
{ name: 'KRATE_WORKSPACE_PATH', value: workspace?.mountPath || '/workspace' }
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
const volumes = [];
|
|
213
|
+
const volumeMounts = [];
|
|
214
|
+
|
|
215
|
+
if (workspace) {
|
|
216
|
+
const pvcName = workspace.pvcName || `krate-ws-${runId}`;
|
|
217
|
+
volumes.push({
|
|
218
|
+
name: 'workspace',
|
|
219
|
+
persistentVolumeClaim: { claimName: pvcName }
|
|
220
|
+
});
|
|
221
|
+
volumeMounts.push({
|
|
222
|
+
name: 'workspace',
|
|
223
|
+
mountPath: workspace.mountPath || '/workspace'
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
apiVersion: 'v1',
|
|
229
|
+
kind: 'Pod',
|
|
230
|
+
metadata: {
|
|
231
|
+
name: `runner-${runnerId}`,
|
|
232
|
+
namespace,
|
|
233
|
+
labels: {
|
|
234
|
+
'krate.a5c.ai/runner': runnerId,
|
|
235
|
+
'krate.a5c.ai/pool': pool?.metadata?.name || 'unknown',
|
|
236
|
+
'krate.a5c.ai/org': organizationRef
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
spec: {
|
|
240
|
+
serviceAccountName,
|
|
241
|
+
restartPolicy: 'Never',
|
|
242
|
+
containers: [{
|
|
243
|
+
name: 'runner',
|
|
244
|
+
image,
|
|
245
|
+
env: envVars,
|
|
246
|
+
volumeMounts,
|
|
247
|
+
resources: {
|
|
248
|
+
limits: Object.keys(resourceLimits).length ? resourceLimits : { cpu: '2', memory: '4Gi' },
|
|
249
|
+
requests: Object.keys(resourceRequests).length ? resourceRequests : { cpu: '500m', memory: '1Gi' }
|
|
250
|
+
}
|
|
251
|
+
}],
|
|
252
|
+
volumes
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
// -------------------------------------------------------------------------
|
|
258
|
+
// Introspection helpers
|
|
259
|
+
// -------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
listRunners(poolName = null) {
|
|
262
|
+
const all = [...runners.values()];
|
|
263
|
+
if (poolName) return all.filter((r) => r.poolName === poolName).map(clone);
|
|
264
|
+
return all.map(clone);
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
getRunner(runnerId) {
|
|
268
|
+
const r = runners.get(runnerId);
|
|
269
|
+
return r ? clone(r) : null;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot cache with:
|
|
3
|
+
* - Per-org storage (multiple orgs cached simultaneously)
|
|
4
|
+
* - stale-while-revalidate: return stale data immediately, refresh in background
|
|
5
|
+
* - Configurable TTL via KRATE_SNAPSHOT_CACHE_TTL_MS
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const CACHE_TTL_MS = Number(process.env.KRATE_SNAPSHOT_CACHE_TTL_MS || 30_000);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Per-org cache map. Key = org string (or '' for no-org).
|
|
12
|
+
* Value = { data, timestamp, revalidating: boolean }
|
|
13
|
+
*/
|
|
14
|
+
const orgCacheMap = new Map();
|
|
15
|
+
|
|
16
|
+
// Legacy single-org cache kept for backward compatibility with controller-client.js
|
|
17
|
+
let snapshotCache = { data: null, timestamp: 0, org: null };
|
|
18
|
+
|
|
19
|
+
export function getSnapshotCache() {
|
|
20
|
+
return snapshotCache;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function setSnapshotCache(data, org) {
|
|
24
|
+
snapshotCache = { data, timestamp: Date.now(), org };
|
|
25
|
+
// Also update per-org map
|
|
26
|
+
const key = org ?? '';
|
|
27
|
+
const entry = orgCacheMap.get(key) || {};
|
|
28
|
+
orgCacheMap.set(key, { ...entry, data, timestamp: Date.now(), revalidating: false });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function clearSnapshotCache() {
|
|
32
|
+
snapshotCache = { data: null, timestamp: 0, org: null };
|
|
33
|
+
orgCacheMap.clear();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Per-org cache API
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the cached snapshot for a specific org.
|
|
42
|
+
* Returns null when nothing is cached or when the TTL has expired.
|
|
43
|
+
*
|
|
44
|
+
* @param {string|null} org
|
|
45
|
+
* @returns {{ data: object, timestamp: number, revalidating: boolean } | null}
|
|
46
|
+
*/
|
|
47
|
+
export function getOrgCache(org) {
|
|
48
|
+
const key = org ?? '';
|
|
49
|
+
const entry = orgCacheMap.get(key);
|
|
50
|
+
if (!entry || !entry.data) return null;
|
|
51
|
+
return entry;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Store a snapshot for a specific org.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} data
|
|
58
|
+
* @param {string|null} org
|
|
59
|
+
*/
|
|
60
|
+
export function setOrgCache(data, org) {
|
|
61
|
+
const key = org ?? '';
|
|
62
|
+
orgCacheMap.set(key, { data, timestamp: Date.now(), revalidating: false });
|
|
63
|
+
// Keep legacy cache in sync for the most recently written org
|
|
64
|
+
snapshotCache = { data, timestamp: Date.now(), org };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Clear the cache entry for a specific org only.
|
|
69
|
+
*
|
|
70
|
+
* @param {string|null} org
|
|
71
|
+
*/
|
|
72
|
+
export function clearOrgCache(org) {
|
|
73
|
+
orgCacheMap.delete(org ?? '');
|
|
74
|
+
if (snapshotCache.org === org) {
|
|
75
|
+
snapshotCache = { data: null, timestamp: 0, org: null };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Return all orgs currently in the cache (for introspection / debugging).
|
|
81
|
+
*
|
|
82
|
+
* @returns {string[]}
|
|
83
|
+
*/
|
|
84
|
+
export function cachedOrgs() {
|
|
85
|
+
return [...orgCacheMap.keys()];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// stale-while-revalidate helpers
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check whether the cache entry for the given org is fresh.
|
|
94
|
+
*
|
|
95
|
+
* @param {string|null} org
|
|
96
|
+
* @param {number} [ttlMs] - defaults to CACHE_TTL_MS
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
export function isCacheFresh(org, ttlMs = CACHE_TTL_MS) {
|
|
100
|
+
const entry = getOrgCache(org);
|
|
101
|
+
if (!entry) return false;
|
|
102
|
+
return (Date.now() - entry.timestamp) < ttlMs;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* stale-while-revalidate: return stale data immediately if available, then
|
|
107
|
+
* trigger revalidateFn() in the background to refresh the cache entry.
|
|
108
|
+
*
|
|
109
|
+
* @param {string|null} org - Organization key for the cache
|
|
110
|
+
* @param {Function} revalidateFn - Async function that returns fresh data
|
|
111
|
+
* @param {object} [swrOptions]
|
|
112
|
+
* @param {number} [swrOptions.ttlMs] - Fresh TTL in ms (default: CACHE_TTL_MS)
|
|
113
|
+
* @param {number} [swrOptions.staleMs] - Max staleness before we block on revalidate (default: 5 × ttlMs)
|
|
114
|
+
* @returns {Promise<object>} Either the stale cached value or the freshly fetched one
|
|
115
|
+
*/
|
|
116
|
+
export async function staleWhileRevalidate(org, revalidateFn, swrOptions = {}) {
|
|
117
|
+
const ttlMs = swrOptions.ttlMs ?? CACHE_TTL_MS;
|
|
118
|
+
const staleMs = swrOptions.staleMs ?? ttlMs * 5;
|
|
119
|
+
const key = org ?? '';
|
|
120
|
+
const entry = orgCacheMap.get(key);
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
|
|
123
|
+
const isFresh = entry && entry.data && (now - entry.timestamp) < ttlMs;
|
|
124
|
+
const isStale = entry && entry.data && (now - entry.timestamp) < staleMs;
|
|
125
|
+
|
|
126
|
+
if (isFresh) {
|
|
127
|
+
// Cache is fresh: return immediately
|
|
128
|
+
return entry.data;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (isStale && !entry.revalidating) {
|
|
132
|
+
// Stale but still usable: return immediately and revalidate in background
|
|
133
|
+
orgCacheMap.set(key, { ...entry, revalidating: true });
|
|
134
|
+
Promise.resolve().then(async () => {
|
|
135
|
+
try {
|
|
136
|
+
const fresh = await revalidateFn();
|
|
137
|
+
setOrgCache(fresh, org);
|
|
138
|
+
} catch {
|
|
139
|
+
// On background refresh error, clear the revalidating flag so a future
|
|
140
|
+
// request can try again
|
|
141
|
+
const current = orgCacheMap.get(key);
|
|
142
|
+
if (current) orgCacheMap.set(key, { ...current, revalidating: false });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
return entry.data;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isStale && entry.revalidating) {
|
|
149
|
+
// Another caller is already refreshing: return stale data now
|
|
150
|
+
return entry.data;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// No usable cache: block on the revalidation
|
|
154
|
+
const fresh = await revalidateFn();
|
|
155
|
+
setOrgCache(fresh, org);
|
|
156
|
+
return fresh;
|
|
157
|
+
}
|