@soleri/core 9.4.0 → 9.6.0
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/dist/adapters/claude-code-adapter.d.ts +27 -0
- package/dist/adapters/claude-code-adapter.d.ts.map +1 -0
- package/dist/adapters/claude-code-adapter.js +111 -0
- package/dist/adapters/claude-code-adapter.js.map +1 -0
- package/dist/adapters/index.d.ts +9 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +10 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/registry.d.ts +21 -0
- package/dist/adapters/registry.d.ts.map +1 -0
- package/dist/adapters/registry.js +44 -0
- package/dist/adapters/registry.js.map +1 -0
- package/dist/adapters/types.d.ts +93 -0
- package/dist/adapters/types.d.ts.map +1 -0
- package/dist/adapters/types.js +10 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/brain/brain.d.ts +12 -1
- package/dist/brain/brain.d.ts.map +1 -1
- package/dist/brain/brain.js +106 -44
- package/dist/brain/brain.js.map +1 -1
- package/dist/brain/intelligence.d.ts.map +1 -1
- package/dist/brain/intelligence.js +36 -30
- package/dist/brain/intelligence.js.map +1 -1
- package/dist/chat/agent-loop.js +1 -1
- package/dist/chat/agent-loop.js.map +1 -1
- package/dist/chat/notifications.d.ts.map +1 -1
- package/dist/chat/notifications.js +4 -0
- package/dist/chat/notifications.js.map +1 -1
- package/dist/control/intent-router.d.ts +1 -0
- package/dist/control/intent-router.d.ts.map +1 -1
- package/dist/control/intent-router.js +11 -5
- package/dist/control/intent-router.js.map +1 -1
- package/dist/curator/curator.d.ts +4 -0
- package/dist/curator/curator.d.ts.map +1 -1
- package/dist/curator/curator.js +141 -27
- package/dist/curator/curator.js.map +1 -1
- package/dist/hooks/candidate-scorer.d.ts +28 -0
- package/dist/hooks/candidate-scorer.d.ts.map +1 -0
- package/dist/hooks/candidate-scorer.js +20 -0
- package/dist/hooks/candidate-scorer.js.map +1 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +2 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/index.d.ts +14 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/llm/llm-client.d.ts.map +1 -1
- package/dist/llm/llm-client.js +1 -0
- package/dist/llm/llm-client.js.map +1 -1
- package/dist/packs/index.d.ts +3 -2
- package/dist/packs/index.d.ts.map +1 -1
- package/dist/packs/index.js +3 -2
- package/dist/packs/index.js.map +1 -1
- package/dist/packs/lockfile.d.ts +23 -1
- package/dist/packs/lockfile.d.ts.map +1 -1
- package/dist/packs/lockfile.js +50 -4
- package/dist/packs/lockfile.js.map +1 -1
- package/dist/packs/pack-installer.d.ts +10 -0
- package/dist/packs/pack-installer.d.ts.map +1 -1
- package/dist/packs/pack-installer.js +69 -2
- package/dist/packs/pack-installer.js.map +1 -1
- package/dist/packs/pack-lifecycle.d.ts +50 -0
- package/dist/packs/pack-lifecycle.d.ts.map +1 -0
- package/dist/packs/pack-lifecycle.js +91 -0
- package/dist/packs/pack-lifecycle.js.map +1 -0
- package/dist/packs/types.d.ts +64 -44
- package/dist/packs/types.d.ts.map +1 -1
- package/dist/packs/types.js +9 -0
- package/dist/packs/types.js.map +1 -1
- package/dist/persistence/sqlite-provider.d.ts +5 -1
- package/dist/persistence/sqlite-provider.d.ts.map +1 -1
- package/dist/persistence/sqlite-provider.js +22 -2
- package/dist/persistence/sqlite-provider.js.map +1 -1
- package/dist/planning/github-projection.d.ts +8 -8
- package/dist/planning/github-projection.d.ts.map +1 -1
- package/dist/planning/github-projection.js +42 -42
- package/dist/planning/github-projection.js.map +1 -1
- package/dist/planning/plan-lifecycle.d.ts.map +1 -1
- package/dist/planning/plan-lifecycle.js +6 -1
- package/dist/planning/plan-lifecycle.js.map +1 -1
- package/dist/plugins/types.d.ts +21 -21
- package/dist/queue/pipeline-runner.d.ts.map +1 -1
- package/dist/queue/pipeline-runner.js +4 -0
- package/dist/queue/pipeline-runner.js.map +1 -1
- package/dist/runtime/curator-extra-ops.d.ts.map +1 -1
- package/dist/runtime/curator-extra-ops.js +9 -1
- package/dist/runtime/curator-extra-ops.js.map +1 -1
- package/dist/runtime/facades/memory-facade.d.ts.map +1 -1
- package/dist/runtime/facades/memory-facade.js +169 -0
- package/dist/runtime/facades/memory-facade.js.map +1 -1
- package/dist/runtime/orchestrate-ops.d.ts.map +1 -1
- package/dist/runtime/orchestrate-ops.js +133 -4
- package/dist/runtime/orchestrate-ops.js.map +1 -1
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +128 -90
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/runtime/session-briefing.d.ts.map +1 -1
- package/dist/runtime/session-briefing.js +44 -11
- package/dist/runtime/session-briefing.js.map +1 -1
- package/dist/runtime/shutdown-registry.d.ts +36 -0
- package/dist/runtime/shutdown-registry.d.ts.map +1 -0
- package/dist/runtime/shutdown-registry.js +74 -0
- package/dist/runtime/shutdown-registry.js.map +1 -0
- package/dist/runtime/types.d.ts +10 -1
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/subagent/concurrency-manager.d.ts +29 -0
- package/dist/subagent/concurrency-manager.d.ts.map +1 -0
- package/dist/subagent/concurrency-manager.js +73 -0
- package/dist/subagent/concurrency-manager.js.map +1 -0
- package/dist/subagent/dispatcher.d.ts +41 -0
- package/dist/subagent/dispatcher.d.ts.map +1 -0
- package/dist/subagent/dispatcher.js +259 -0
- package/dist/subagent/dispatcher.js.map +1 -0
- package/dist/subagent/index.d.ts +14 -0
- package/dist/subagent/index.d.ts.map +1 -0
- package/dist/subagent/index.js +15 -0
- package/dist/subagent/index.js.map +1 -0
- package/dist/subagent/orphan-reaper.d.ts +37 -0
- package/dist/subagent/orphan-reaper.d.ts.map +1 -0
- package/dist/subagent/orphan-reaper.js +71 -0
- package/dist/subagent/orphan-reaper.js.map +1 -0
- package/dist/subagent/result-aggregator.d.ts +7 -0
- package/dist/subagent/result-aggregator.d.ts.map +1 -0
- package/dist/subagent/result-aggregator.js +57 -0
- package/dist/subagent/result-aggregator.js.map +1 -0
- package/dist/subagent/task-checkout.d.ts +36 -0
- package/dist/subagent/task-checkout.d.ts.map +1 -0
- package/dist/subagent/task-checkout.js +52 -0
- package/dist/subagent/task-checkout.js.map +1 -0
- package/dist/subagent/types.d.ts +114 -0
- package/dist/subagent/types.d.ts.map +1 -0
- package/dist/subagent/types.js +9 -0
- package/dist/subagent/types.js.map +1 -0
- package/dist/subagent/workspace-resolver.d.ts +35 -0
- package/dist/subagent/workspace-resolver.d.ts.map +1 -0
- package/dist/subagent/workspace-resolver.js +99 -0
- package/dist/subagent/workspace-resolver.js.map +1 -0
- package/dist/transport/http-server.d.ts.map +1 -1
- package/dist/transport/http-server.js +49 -3
- package/dist/transport/http-server.js.map +1 -1
- package/dist/transport/ws-server.d.ts.map +1 -1
- package/dist/transport/ws-server.js +7 -0
- package/dist/transport/ws-server.js.map +1 -1
- package/dist/vault/linking.d.ts +3 -4
- package/dist/vault/linking.d.ts.map +1 -1
- package/dist/vault/linking.js +79 -32
- package/dist/vault/linking.js.map +1 -1
- package/dist/vault/vault-maintenance.d.ts.map +1 -1
- package/dist/vault/vault-maintenance.js +7 -14
- package/dist/vault/vault-maintenance.js.map +1 -1
- package/dist/vault/vault-memories.d.ts.map +1 -1
- package/dist/vault/vault-memories.js +19 -9
- package/dist/vault/vault-memories.js.map +1 -1
- package/dist/vault/vault-schema.d.ts +1 -0
- package/dist/vault/vault-schema.d.ts.map +1 -1
- package/dist/vault/vault-schema.js +20 -0
- package/dist/vault/vault-schema.js.map +1 -1
- package/dist/vault/vault.d.ts.map +1 -1
- package/dist/vault/vault.js +7 -3
- package/dist/vault/vault.js.map +1 -1
- package/package.json +8 -2
- package/src/__tests__/adapters/claude-code-adapter.test.ts +167 -0
- package/src/__tests__/adapters/registry.test.ts +100 -0
- package/src/__tests__/packs/pack-lifecycle.test.ts +379 -0
- package/src/__tests__/subagent/concurrency-manager.test.ts +132 -0
- package/src/__tests__/subagent/dispatcher.test.ts +195 -0
- package/src/__tests__/subagent/orphan-reaper.test.ts +141 -0
- package/src/__tests__/subagent/result-aggregator.test.ts +141 -0
- package/src/__tests__/subagent/task-checkout.test.ts +86 -0
- package/src/__tests__/subagent/workspace-resolver.test.ts +138 -0
- package/src/adapters/claude-code-adapter.ts +163 -0
- package/src/adapters/index.ts +22 -0
- package/src/adapters/registry.ts +53 -0
- package/src/adapters/types.ts +114 -0
- package/src/brain/brain.ts +120 -46
- package/src/brain/intelligence.ts +42 -34
- package/src/chat/agent-loop.ts +1 -1
- package/src/chat/notifications.ts +4 -0
- package/src/control/intent-router.ts +10 -8
- package/src/curator/curator.ts +146 -29
- package/src/hooks/candidate-scorer.test.ts +76 -0
- package/src/hooks/candidate-scorer.ts +39 -0
- package/src/index.ts +40 -1
- package/src/llm/llm-client.ts +1 -0
- package/src/packs/index.ts +5 -1
- package/src/packs/lockfile.ts +70 -5
- package/src/packs/pack-installer.ts +78 -2
- package/src/packs/pack-lifecycle.ts +115 -0
- package/src/packs/pack-lockfile.test.ts +1 -1
- package/src/packs/pack-system.test.ts +1 -1
- package/src/packs/types.ts +40 -2
- package/src/persistence/sqlite-provider.ts +27 -2
- package/src/planning/github-projection.ts +48 -44
- package/src/planning/plan-lifecycle.ts +14 -1
- package/src/queue/pipeline-runner.ts +4 -0
- package/src/runtime/admin-setup-ops.test.ts +9 -4
- package/src/runtime/curator-extra-ops.test.ts +7 -0
- package/src/runtime/curator-extra-ops.ts +10 -1
- package/src/runtime/facades/curator-facade.test.ts +7 -0
- package/src/runtime/facades/memory-facade.ts +187 -0
- package/src/runtime/orchestrate-ops.ts +156 -4
- package/src/runtime/runtime.test.ts +50 -2
- package/src/runtime/runtime.ts +132 -89
- package/src/runtime/session-briefing.test.ts +94 -2
- package/src/runtime/session-briefing.ts +48 -12
- package/src/runtime/shutdown-registry.test.ts +151 -0
- package/src/runtime/shutdown-registry.ts +85 -0
- package/src/runtime/types.ts +10 -1
- package/src/subagent/concurrency-manager.ts +89 -0
- package/src/subagent/dispatcher.ts +326 -0
- package/src/subagent/index.ts +28 -0
- package/src/subagent/orphan-reaper.ts +82 -0
- package/src/subagent/result-aggregator.ts +66 -0
- package/src/subagent/task-checkout.ts +60 -0
- package/src/subagent/types.ts +138 -0
- package/src/subagent/workspace-resolver.ts +117 -0
- package/src/transport/http-server.ts +50 -3
- package/src/transport/ws-server.ts +8 -0
- package/src/vault/linking.test.ts +12 -0
- package/src/vault/linking.ts +90 -44
- package/src/vault/vault-maintenance.ts +11 -18
- package/src/vault/vault-memories.ts +21 -13
- package/src/vault/vault-scaling.test.ts +3 -2
- package/src/vault/vault-schema.ts +21 -0
- package/src/vault/vault.ts +8 -3
- package/vitest.config.ts +2 -0
package/src/runtime/runtime.ts
CHANGED
|
@@ -60,6 +60,10 @@ import { loadPersona } from '../persona/loader.js';
|
|
|
60
60
|
import { generatePersonaInstructions } from '../persona/prompt-generator.js';
|
|
61
61
|
import { OperatorProfileStore } from '../operator/operator-profile.js';
|
|
62
62
|
import { ContextHealthMonitor } from './context-health.js';
|
|
63
|
+
import { ShutdownRegistry } from './shutdown-registry.js';
|
|
64
|
+
import { RuntimeAdapterRegistry } from '../adapters/registry.js';
|
|
65
|
+
import { ClaudeCodeRuntimeAdapter } from '../adapters/claude-code-adapter.js';
|
|
66
|
+
import { SubagentDispatcher } from '../subagent/dispatcher.js';
|
|
63
67
|
|
|
64
68
|
/**
|
|
65
69
|
* Create a fully initialized agent runtime.
|
|
@@ -221,6 +225,127 @@ export function createAgentRuntime(config: AgentRuntimeConfig): AgentRuntime {
|
|
|
221
225
|
learningRadar.setOperatorProfile(operatorProfile);
|
|
222
226
|
brainIntelligence.setOperatorProfile(operatorProfile);
|
|
223
227
|
|
|
228
|
+
// ─── Shutdown Registry ────────────────────────────────────────────
|
|
229
|
+
const shutdownRegistry = new ShutdownRegistry();
|
|
230
|
+
|
|
231
|
+
// Build pipeline runner before the runtime object so we can reference it
|
|
232
|
+
const pipelineRunner = (() => {
|
|
233
|
+
const jq = new JobQueue(vault.getProvider());
|
|
234
|
+
const pr = new PipelineRunner(jq);
|
|
235
|
+
// Register default job handlers for curator pipeline
|
|
236
|
+
pr.registerHandler('tag-normalize', async (job) => {
|
|
237
|
+
const entry = vault.get(job.entryId ?? '');
|
|
238
|
+
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
239
|
+
const result = curator.normalizeTag(entry.tags[0] ?? '');
|
|
240
|
+
return result;
|
|
241
|
+
});
|
|
242
|
+
pr.registerHandler('dedup-check', async (job) => {
|
|
243
|
+
const entry = vault.get(job.entryId ?? '');
|
|
244
|
+
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
245
|
+
return curator.detectDuplicates(entry.id);
|
|
246
|
+
});
|
|
247
|
+
pr.registerHandler('auto-link', async (job) => {
|
|
248
|
+
if (linkManager) {
|
|
249
|
+
const suggestions = linkManager.suggestLinks(job.entryId ?? '', 3);
|
|
250
|
+
for (const s of suggestions) {
|
|
251
|
+
linkManager.addLink(
|
|
252
|
+
job.entryId ?? '',
|
|
253
|
+
s.entryId,
|
|
254
|
+
s.suggestedType,
|
|
255
|
+
`pipeline: ${s.reason}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return { linked: suggestions.length };
|
|
259
|
+
}
|
|
260
|
+
return { skipped: true, reason: 'link manager not available' };
|
|
261
|
+
});
|
|
262
|
+
pr.registerHandler('quality-gate', async (job) => {
|
|
263
|
+
const entry = vault.get(job.entryId ?? '');
|
|
264
|
+
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
265
|
+
return evaluateQuality(entry, llmClient);
|
|
266
|
+
});
|
|
267
|
+
pr.registerHandler('classify', async (job) => {
|
|
268
|
+
const entry = vault.get(job.entryId ?? '');
|
|
269
|
+
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
270
|
+
return classifyEntry(entry, llmClient);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ─── 9 additional handlers for full Salvador parity (#216) ────
|
|
274
|
+
pr.registerHandler('enrich-frontmatter', async (job) => {
|
|
275
|
+
const entry = vault.get(job.entryId ?? '');
|
|
276
|
+
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
277
|
+
return curator.enrichMetadata(entry.id);
|
|
278
|
+
});
|
|
279
|
+
pr.registerHandler('detect-staleness', async (job) => {
|
|
280
|
+
const entry = vault.get(job.entryId ?? '');
|
|
281
|
+
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
282
|
+
// Check if entry is older than 90 days (using validFrom or fallback to 0)
|
|
283
|
+
const entryTimestamp = (entry.validFrom ?? 0) * 1000 || Date.now();
|
|
284
|
+
const ageMs = Date.now() - entryTimestamp;
|
|
285
|
+
const staleDays = 90;
|
|
286
|
+
const isStale = ageMs > staleDays * 86400000;
|
|
287
|
+
return { stale: isStale, ageDays: Math.floor(ageMs / 86400000), entryId: entry.id };
|
|
288
|
+
});
|
|
289
|
+
pr.registerHandler('detect-duplicate', async (job) => {
|
|
290
|
+
const entry = vault.get(job.entryId ?? '');
|
|
291
|
+
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
292
|
+
return curator.detectDuplicates(entry.id);
|
|
293
|
+
});
|
|
294
|
+
pr.registerHandler('detect-contradiction', async (job) => {
|
|
295
|
+
const entry = vault.get(job.entryId ?? '');
|
|
296
|
+
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
297
|
+
const contradictions = curator.detectContradictions(0.4);
|
|
298
|
+
const relevant = contradictions.filter(
|
|
299
|
+
(c) => c.patternId === job.entryId || c.antipatternId === job.entryId,
|
|
300
|
+
);
|
|
301
|
+
return { found: relevant.length, contradictions: relevant };
|
|
302
|
+
});
|
|
303
|
+
pr.registerHandler('consolidate-duplicates', async (_job) => {
|
|
304
|
+
return curator.consolidate({ dryRun: false, staleDaysThreshold: 90 });
|
|
305
|
+
});
|
|
306
|
+
pr.registerHandler('archive-stale', async (_job) => {
|
|
307
|
+
// Run consolidation with stale detection
|
|
308
|
+
const result = curator.consolidate({ dryRun: false, staleDaysThreshold: 90 });
|
|
309
|
+
return { archived: result.staleEntries.length, result };
|
|
310
|
+
});
|
|
311
|
+
pr.registerHandler('verify-searchable', async (job) => {
|
|
312
|
+
const entry = vault.get(job.entryId ?? '');
|
|
313
|
+
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
314
|
+
const searchResults = vault.search(entry.title, { limit: 1 });
|
|
315
|
+
const found = searchResults.some((r) => r.entry.id === entry.id);
|
|
316
|
+
return { searchable: found, entryId: entry.id };
|
|
317
|
+
});
|
|
318
|
+
return pr;
|
|
319
|
+
})();
|
|
320
|
+
|
|
321
|
+
// ─── Register cleanup callbacks (LIFO: first registered = last closed) ──
|
|
322
|
+
// Vault manager closes last (other modules may flush to vault during close)
|
|
323
|
+
shutdownRegistry.register('vaultManager', () => vaultManager.close());
|
|
324
|
+
// Pipeline runner — clear its polling interval
|
|
325
|
+
shutdownRegistry.register('pipelineRunner', () => pipelineRunner.stop());
|
|
326
|
+
// Agency manager — close FSWatchers and debounce timers
|
|
327
|
+
shutdownRegistry.register('agencyManager', () => agencyManager.disable());
|
|
328
|
+
|
|
329
|
+
shutdownRegistry.register('subagentDispatcher', () => subagentDispatcher.cleanup());
|
|
330
|
+
// Loop manager — clear accumulated state
|
|
331
|
+
shutdownRegistry.register('loopManager', () => {
|
|
332
|
+
if (loop.isActive()) {
|
|
333
|
+
try {
|
|
334
|
+
loop.cancelLoop();
|
|
335
|
+
} catch {
|
|
336
|
+
// Loop may already be inactive
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Runtime Adapter Registry — dispatch work to different AI CLIs
|
|
342
|
+
const adapterRegistry = new RuntimeAdapterRegistry();
|
|
343
|
+
adapterRegistry.register('claude-code', new ClaudeCodeRuntimeAdapter());
|
|
344
|
+
adapterRegistry.setDefault('claude-code');
|
|
345
|
+
|
|
346
|
+
// Subagent Dispatcher — spawn and manage child agent processes
|
|
347
|
+
const subagentDispatcher = new SubagentDispatcher({ adapterRegistry });
|
|
348
|
+
|
|
224
349
|
return {
|
|
225
350
|
config,
|
|
226
351
|
logger,
|
|
@@ -256,94 +381,7 @@ export function createAgentRuntime(config: AgentRuntimeConfig): AgentRuntime {
|
|
|
256
381
|
knowledgeSynthesizer: new KnowledgeSynthesizer(brain, llmClient),
|
|
257
382
|
chainRunner: new ChainRunner(vault.getProvider()),
|
|
258
383
|
jobQueue: new JobQueue(vault.getProvider()),
|
|
259
|
-
pipelineRunner
|
|
260
|
-
const jq = new JobQueue(vault.getProvider());
|
|
261
|
-
const pr = new PipelineRunner(jq);
|
|
262
|
-
// Register default job handlers for curator pipeline
|
|
263
|
-
pr.registerHandler('tag-normalize', async (job) => {
|
|
264
|
-
const entry = vault.get(job.entryId ?? '');
|
|
265
|
-
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
266
|
-
const result = curator.normalizeTag(entry.tags[0] ?? '');
|
|
267
|
-
return result;
|
|
268
|
-
});
|
|
269
|
-
pr.registerHandler('dedup-check', async (job) => {
|
|
270
|
-
const entry = vault.get(job.entryId ?? '');
|
|
271
|
-
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
272
|
-
return curator.detectDuplicates(entry.id);
|
|
273
|
-
});
|
|
274
|
-
pr.registerHandler('auto-link', async (job) => {
|
|
275
|
-
if (linkManager) {
|
|
276
|
-
const suggestions = linkManager.suggestLinks(job.entryId ?? '', 3);
|
|
277
|
-
for (const s of suggestions) {
|
|
278
|
-
linkManager.addLink(
|
|
279
|
-
job.entryId ?? '',
|
|
280
|
-
s.entryId,
|
|
281
|
-
s.suggestedType,
|
|
282
|
-
`pipeline: ${s.reason}`,
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
return { linked: suggestions.length };
|
|
286
|
-
}
|
|
287
|
-
return { skipped: true, reason: 'link manager not available' };
|
|
288
|
-
});
|
|
289
|
-
pr.registerHandler('quality-gate', async (job) => {
|
|
290
|
-
const entry = vault.get(job.entryId ?? '');
|
|
291
|
-
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
292
|
-
return evaluateQuality(entry, llmClient);
|
|
293
|
-
});
|
|
294
|
-
pr.registerHandler('classify', async (job) => {
|
|
295
|
-
const entry = vault.get(job.entryId ?? '');
|
|
296
|
-
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
297
|
-
return classifyEntry(entry, llmClient);
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
// ─── 9 additional handlers for full Salvador parity (#216) ────
|
|
301
|
-
pr.registerHandler('enrich-frontmatter', async (job) => {
|
|
302
|
-
const entry = vault.get(job.entryId ?? '');
|
|
303
|
-
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
304
|
-
return curator.enrichMetadata(entry.id);
|
|
305
|
-
});
|
|
306
|
-
pr.registerHandler('detect-staleness', async (job) => {
|
|
307
|
-
const entry = vault.get(job.entryId ?? '');
|
|
308
|
-
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
309
|
-
// Check if entry is older than 90 days (using validFrom or fallback to 0)
|
|
310
|
-
const entryTimestamp = (entry.validFrom ?? 0) * 1000 || Date.now();
|
|
311
|
-
const ageMs = Date.now() - entryTimestamp;
|
|
312
|
-
const staleDays = 90;
|
|
313
|
-
const isStale = ageMs > staleDays * 86400000;
|
|
314
|
-
return { stale: isStale, ageDays: Math.floor(ageMs / 86400000), entryId: entry.id };
|
|
315
|
-
});
|
|
316
|
-
pr.registerHandler('detect-duplicate', async (job) => {
|
|
317
|
-
const entry = vault.get(job.entryId ?? '');
|
|
318
|
-
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
319
|
-
return curator.detectDuplicates(entry.id);
|
|
320
|
-
});
|
|
321
|
-
pr.registerHandler('detect-contradiction', async (job) => {
|
|
322
|
-
const entry = vault.get(job.entryId ?? '');
|
|
323
|
-
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
324
|
-
const contradictions = curator.detectContradictions(0.4);
|
|
325
|
-
const relevant = contradictions.filter(
|
|
326
|
-
(c) => c.patternId === job.entryId || c.antipatternId === job.entryId,
|
|
327
|
-
);
|
|
328
|
-
return { found: relevant.length, contradictions: relevant };
|
|
329
|
-
});
|
|
330
|
-
pr.registerHandler('consolidate-duplicates', async (_job) => {
|
|
331
|
-
return curator.consolidate({ dryRun: false, staleDaysThreshold: 90 });
|
|
332
|
-
});
|
|
333
|
-
pr.registerHandler('archive-stale', async (_job) => {
|
|
334
|
-
// Run consolidation with stale detection
|
|
335
|
-
const result = curator.consolidate({ dryRun: false, staleDaysThreshold: 90 });
|
|
336
|
-
return { archived: result.staleEntries.length, result };
|
|
337
|
-
});
|
|
338
|
-
pr.registerHandler('verify-searchable', async (job) => {
|
|
339
|
-
const entry = vault.get(job.entryId ?? '');
|
|
340
|
-
if (!entry) return { skipped: true, reason: 'entry not found' };
|
|
341
|
-
const searchResults = vault.search(entry.title, { limit: 1 });
|
|
342
|
-
const found = searchResults.some((r) => r.entry.id === entry.id);
|
|
343
|
-
return { searchable: found, entryId: entry.id };
|
|
344
|
-
});
|
|
345
|
-
return pr;
|
|
346
|
-
})(),
|
|
384
|
+
pipelineRunner,
|
|
347
385
|
operatorProfile,
|
|
348
386
|
persona: (() => {
|
|
349
387
|
const p = loadPersona(agentId, config.persona ?? undefined);
|
|
@@ -354,10 +392,15 @@ export function createAgentRuntime(config: AgentRuntimeConfig): AgentRuntime {
|
|
|
354
392
|
const p = loadPersona(agentId, config.persona ?? undefined);
|
|
355
393
|
return generatePersonaInstructions(p);
|
|
356
394
|
})(),
|
|
395
|
+
adapterRegistry,
|
|
396
|
+
subagentDispatcher,
|
|
357
397
|
contextHealth: new ContextHealthMonitor(),
|
|
398
|
+
shutdownRegistry,
|
|
358
399
|
createdAt: Date.now(),
|
|
359
400
|
close: () => {
|
|
360
|
-
|
|
401
|
+
// Synchronous close — runs all registered callbacks in LIFO order,
|
|
402
|
+
// then closes the vault (registered first, so runs last).
|
|
403
|
+
shutdownRegistry.closeAllSync();
|
|
361
404
|
},
|
|
362
405
|
};
|
|
363
406
|
}
|
|
@@ -17,6 +17,14 @@ function makeRuntime(overrides?: {
|
|
|
17
17
|
toolsUsed: string[];
|
|
18
18
|
filesModified: string[];
|
|
19
19
|
}>;
|
|
20
|
+
memories?: Array<{
|
|
21
|
+
id?: string;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
projectPath?: string;
|
|
24
|
+
summary?: string;
|
|
25
|
+
context?: string;
|
|
26
|
+
type?: string;
|
|
27
|
+
}>;
|
|
20
28
|
plans?: Array<{
|
|
21
29
|
id: string;
|
|
22
30
|
status: string;
|
|
@@ -45,6 +53,7 @@ function makeRuntime(overrides?: {
|
|
|
45
53
|
vault: {
|
|
46
54
|
stats: () => o.vaultStats ?? { totalEntries: 50, byType: { playbook: 5 } },
|
|
47
55
|
getRecent: (_n: number) => o.recentEntries ?? [],
|
|
56
|
+
listMemories: () => o.memories ?? [],
|
|
48
57
|
},
|
|
49
58
|
curator: {
|
|
50
59
|
healthAudit: () => ({
|
|
@@ -95,11 +104,34 @@ describe('session-briefing', () => {
|
|
|
95
104
|
expect(data.sections.find((s) => s.label === 'Welcome')).toBeUndefined();
|
|
96
105
|
});
|
|
97
106
|
|
|
98
|
-
it('includes Last session
|
|
107
|
+
it('includes Last session from cross-project memories when fresh', async () => {
|
|
108
|
+
const runtime = makeRuntime({
|
|
109
|
+
memories: [
|
|
110
|
+
{
|
|
111
|
+
id: 'mem-1',
|
|
112
|
+
createdAt: Date.now() - 600_000, // 10 min ago (ms)
|
|
113
|
+
projectPath: '/Users/me/projects/other-app',
|
|
114
|
+
summary: 'Fixed KPI card layout in the dashboard',
|
|
115
|
+
type: 'session',
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
const ops = captureOps(createSessionBriefingOps(runtime));
|
|
120
|
+
const res = await executeOp(ops, 'session_briefing', {});
|
|
121
|
+
|
|
122
|
+
const data = res.data as { sections: Array<{ label: string; content: string }> };
|
|
123
|
+
const session = data.sections.find((s) => s.label === 'Last session');
|
|
124
|
+
expect(session).toBeDefined();
|
|
125
|
+
expect(session!.content).toContain('other-app');
|
|
126
|
+
expect(session!.content).toContain('Fixed KPI card layout');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('falls back to brain sessions when no fresh memories exist', async () => {
|
|
99
130
|
const runtime = makeRuntime({
|
|
131
|
+
memories: [], // no memories
|
|
100
132
|
sessions: [
|
|
101
133
|
{
|
|
102
|
-
endedAt: new Date(Date.now() -
|
|
134
|
+
endedAt: new Date(Date.now() - 3600_000).toISOString(), // 1h ago
|
|
103
135
|
domain: 'frontend',
|
|
104
136
|
context: 'Refactored button component',
|
|
105
137
|
toolsUsed: ['vault_search', 'brain_recommend'],
|
|
@@ -117,6 +149,63 @@ describe('session-briefing', () => {
|
|
|
117
149
|
expect(session!.content).toContain('Refactored button component');
|
|
118
150
|
});
|
|
119
151
|
|
|
152
|
+
it('skips Last session when all sessions are stale', async () => {
|
|
153
|
+
const staleTs = Date.now() - 72 * 3600_000; // 72h ago — beyond default 48h
|
|
154
|
+
const runtime = makeRuntime({
|
|
155
|
+
memories: [
|
|
156
|
+
{
|
|
157
|
+
id: 'mem-old',
|
|
158
|
+
createdAt: staleTs,
|
|
159
|
+
projectPath: '/old-project',
|
|
160
|
+
summary: 'Ancient session',
|
|
161
|
+
type: 'session',
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
sessions: [
|
|
165
|
+
{
|
|
166
|
+
endedAt: new Date(staleTs).toISOString(),
|
|
167
|
+
domain: 'old',
|
|
168
|
+
context: 'Ancient brain session',
|
|
169
|
+
toolsUsed: [],
|
|
170
|
+
filesModified: [],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
const ops = captureOps(createSessionBriefingOps(runtime));
|
|
175
|
+
const res = await executeOp(ops, 'session_briefing', {});
|
|
176
|
+
|
|
177
|
+
const data = res.data as { sections: Array<{ label: string; content: string }> };
|
|
178
|
+
const session = data.sections.find((s) => s.label === 'Last session');
|
|
179
|
+
expect(session).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('respects custom recencyHours parameter', async () => {
|
|
183
|
+
const runtime = makeRuntime({
|
|
184
|
+
memories: [
|
|
185
|
+
{
|
|
186
|
+
id: 'mem-3h',
|
|
187
|
+
createdAt: Date.now() - 3 * 3600_000, // 3h ago
|
|
188
|
+
projectPath: '/recent-project',
|
|
189
|
+
summary: 'Recent work',
|
|
190
|
+
type: 'session',
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
const ops = captureOps(createSessionBriefingOps(runtime));
|
|
195
|
+
|
|
196
|
+
// With 1h window — should skip
|
|
197
|
+
const narrow = await executeOp(ops, 'session_briefing', { recencyHours: 1 });
|
|
198
|
+
const narrowData = narrow.data as { sections: Array<{ label: string }> };
|
|
199
|
+
expect(narrowData.sections.find((s) => s.label === 'Last session')).toBeUndefined();
|
|
200
|
+
|
|
201
|
+
// With 4h window — should include
|
|
202
|
+
const wide = await executeOp(ops, 'session_briefing', { recencyHours: 4 });
|
|
203
|
+
const wideData = wide.data as { sections: Array<{ label: string; content: string }> };
|
|
204
|
+
const session = wideData.sections.find((s) => s.label === 'Last session');
|
|
205
|
+
expect(session).toBeDefined();
|
|
206
|
+
expect(session!.content).toContain('Recent work');
|
|
207
|
+
});
|
|
208
|
+
|
|
120
209
|
it('includes Active plans section', async () => {
|
|
121
210
|
const runtime = makeRuntime({
|
|
122
211
|
plans: [
|
|
@@ -234,6 +323,9 @@ describe('session-briefing', () => {
|
|
|
234
323
|
getRecent: () => {
|
|
235
324
|
throw new Error('no vault');
|
|
236
325
|
},
|
|
326
|
+
listMemories: () => {
|
|
327
|
+
throw new Error('no vault');
|
|
328
|
+
},
|
|
237
329
|
},
|
|
238
330
|
curator: {
|
|
239
331
|
healthAudit: () => {
|
|
@@ -57,22 +57,58 @@ export function createSessionBriefingOps(runtime: AgentRuntime): OpDefinition[]
|
|
|
57
57
|
// Vault stats unavailable — skip
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// 1. Last session
|
|
60
|
+
// 1. Last session — cross-project, with staleness threshold
|
|
61
61
|
try {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
62
|
+
const recencyMs = (params.recencyHours as number) * 3600_000;
|
|
63
|
+
const cutoff = Date.now() - recencyMs;
|
|
64
|
+
|
|
65
|
+
// Try cross-project memories first (most recent work, any project)
|
|
66
|
+
const recentMemories = vault.listMemories({ type: 'session', limit: 3 });
|
|
67
|
+
const freshMemory = recentMemories.find((m) => {
|
|
68
|
+
const ts = m.createdAt > 1e12 ? m.createdAt : m.createdAt * 1000;
|
|
69
|
+
return ts > cutoff;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (freshMemory) {
|
|
73
|
+
const ts =
|
|
74
|
+
freshMemory.createdAt > 1e12 ? freshMemory.createdAt : freshMemory.createdAt * 1000;
|
|
75
|
+
const ago = formatTimeAgo(ts);
|
|
76
|
+
const project = freshMemory.projectPath
|
|
77
|
+
? ` [${freshMemory.projectPath.split('/').pop()}]`
|
|
78
|
+
: '';
|
|
79
|
+
const summary = freshMemory.summary
|
|
80
|
+
? `: ${freshMemory.summary.slice(0, 100)}`
|
|
81
|
+
: freshMemory.context
|
|
82
|
+
? `: ${freshMemory.context.slice(0, 100)}`
|
|
83
|
+
: '';
|
|
84
|
+
dataPoints += recentMemories.length;
|
|
72
85
|
sections.push({
|
|
73
86
|
label: 'Last session',
|
|
74
|
-
content: `(${ago})${
|
|
87
|
+
content: `(${ago})${project}${summary}`,
|
|
75
88
|
});
|
|
89
|
+
} else {
|
|
90
|
+
// Fall back to brain sessions (same-project only) if no fresh memory
|
|
91
|
+
const sessions = brainIntelligence.listSessions({ limit: 1, active: false });
|
|
92
|
+
dataPoints += sessions.length;
|
|
93
|
+
if (sessions.length > 0) {
|
|
94
|
+
const last = sessions[0];
|
|
95
|
+
const endTs = last.endedAt ? new Date(last.endedAt).getTime() : 0;
|
|
96
|
+
if (endTs > cutoff) {
|
|
97
|
+
const ago = formatTimeAgo(endTs);
|
|
98
|
+
const domain = last.domain ? ` [${last.domain}]` : '';
|
|
99
|
+
const context = last.context ? `: ${last.context.slice(0, 80)}` : '';
|
|
100
|
+
const tools =
|
|
101
|
+
last.toolsUsed.length > 0 ? `, used ${last.toolsUsed.length} tools` : '';
|
|
102
|
+
const files =
|
|
103
|
+
last.filesModified.length > 0
|
|
104
|
+
? `, modified ${last.filesModified.length} files`
|
|
105
|
+
: '';
|
|
106
|
+
sections.push({
|
|
107
|
+
label: 'Last session',
|
|
108
|
+
content: `(${ago})${domain}${context}${tools}${files}`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
76
112
|
}
|
|
77
113
|
} catch {
|
|
78
114
|
// Session data unavailable — skip
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for ShutdownRegistry — centralized cleanup for agent runtime.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
6
|
+
import { ShutdownRegistry } from './shutdown-registry.js';
|
|
7
|
+
|
|
8
|
+
describe('ShutdownRegistry', () => {
|
|
9
|
+
it('starts with zero entries and not closed', () => {
|
|
10
|
+
const registry = new ShutdownRegistry();
|
|
11
|
+
expect(registry.size).toBe(0);
|
|
12
|
+
expect(registry.isClosed).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('tracks registered entries', () => {
|
|
16
|
+
const registry = new ShutdownRegistry();
|
|
17
|
+
registry.register('a', vi.fn());
|
|
18
|
+
registry.register('b', vi.fn());
|
|
19
|
+
expect(registry.size).toBe(2);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('calls callbacks in LIFO order on closeAll', async () => {
|
|
23
|
+
const order: string[] = [];
|
|
24
|
+
const registry = new ShutdownRegistry();
|
|
25
|
+
registry.register('first', () => {
|
|
26
|
+
order.push('first');
|
|
27
|
+
});
|
|
28
|
+
registry.register('second', () => {
|
|
29
|
+
order.push('second');
|
|
30
|
+
});
|
|
31
|
+
registry.register('third', () => {
|
|
32
|
+
order.push('third');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await registry.closeAll();
|
|
36
|
+
expect(order).toEqual(['third', 'second', 'first']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('calls callbacks in LIFO order on closeAllSync', () => {
|
|
40
|
+
const order: string[] = [];
|
|
41
|
+
const registry = new ShutdownRegistry();
|
|
42
|
+
registry.register('first', () => {
|
|
43
|
+
order.push('first');
|
|
44
|
+
});
|
|
45
|
+
registry.register('second', () => {
|
|
46
|
+
order.push('second');
|
|
47
|
+
});
|
|
48
|
+
registry.register('third', () => {
|
|
49
|
+
order.push('third');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
registry.closeAllSync();
|
|
53
|
+
expect(order).toEqual(['third', 'second', 'first']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('is idempotent — second closeAll is a no-op', async () => {
|
|
57
|
+
const callback = vi.fn();
|
|
58
|
+
const registry = new ShutdownRegistry();
|
|
59
|
+
registry.register('test', callback);
|
|
60
|
+
|
|
61
|
+
await registry.closeAll();
|
|
62
|
+
await registry.closeAll();
|
|
63
|
+
|
|
64
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(registry.isClosed).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('is idempotent — second closeAllSync is a no-op', () => {
|
|
69
|
+
const callback = vi.fn();
|
|
70
|
+
const registry = new ShutdownRegistry();
|
|
71
|
+
registry.register('test', callback);
|
|
72
|
+
|
|
73
|
+
registry.closeAllSync();
|
|
74
|
+
registry.closeAllSync();
|
|
75
|
+
|
|
76
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('handles async callbacks in closeAll', async () => {
|
|
80
|
+
const order: string[] = [];
|
|
81
|
+
const registry = new ShutdownRegistry();
|
|
82
|
+
registry.register('sync', () => {
|
|
83
|
+
order.push('sync');
|
|
84
|
+
});
|
|
85
|
+
registry.register('async', async () => {
|
|
86
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
87
|
+
order.push('async');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await registry.closeAll();
|
|
91
|
+
expect(order).toEqual(['async', 'sync']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('continues on error — remaining callbacks still execute', async () => {
|
|
95
|
+
const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
|
96
|
+
const order: string[] = [];
|
|
97
|
+
const registry = new ShutdownRegistry();
|
|
98
|
+
registry.register('first', () => {
|
|
99
|
+
order.push('first');
|
|
100
|
+
});
|
|
101
|
+
registry.register('failing', () => {
|
|
102
|
+
throw new Error('boom');
|
|
103
|
+
});
|
|
104
|
+
registry.register('third', () => {
|
|
105
|
+
order.push('third');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await registry.closeAll();
|
|
109
|
+
|
|
110
|
+
// third runs first (LIFO), failing throws but first still runs
|
|
111
|
+
expect(order).toEqual(['third', 'first']);
|
|
112
|
+
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('boom'));
|
|
113
|
+
stderrSpy.mockRestore();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('closeAllSync swallows errors silently', () => {
|
|
117
|
+
const order: string[] = [];
|
|
118
|
+
const registry = new ShutdownRegistry();
|
|
119
|
+
registry.register('first', () => {
|
|
120
|
+
order.push('first');
|
|
121
|
+
});
|
|
122
|
+
registry.register('failing', () => {
|
|
123
|
+
throw new Error('boom');
|
|
124
|
+
});
|
|
125
|
+
registry.register('third', () => {
|
|
126
|
+
order.push('third');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Should not throw
|
|
130
|
+
registry.closeAllSync();
|
|
131
|
+
expect(order).toEqual(['third', 'first']);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('ignores registrations after close', async () => {
|
|
135
|
+
const registry = new ShutdownRegistry();
|
|
136
|
+
await registry.closeAll();
|
|
137
|
+
|
|
138
|
+
const callback = vi.fn();
|
|
139
|
+
registry.register('late', callback);
|
|
140
|
+
expect(registry.size).toBe(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('clears entries after closeAll', async () => {
|
|
144
|
+
const registry = new ShutdownRegistry();
|
|
145
|
+
registry.register('test', vi.fn());
|
|
146
|
+
expect(registry.size).toBe(1);
|
|
147
|
+
|
|
148
|
+
await registry.closeAll();
|
|
149
|
+
expect(registry.size).toBe(0);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shutdown Registry — centralized cleanup for agent runtime resources.
|
|
3
|
+
*
|
|
4
|
+
* Modules register their cleanup callbacks (clear timers, close watchers,
|
|
5
|
+
* kill child processes). On shutdown, callbacks run in LIFO order so
|
|
6
|
+
* dependents close before their dependencies.
|
|
7
|
+
*
|
|
8
|
+
* Idempotent: calling `closeAll()` multiple times is safe.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type ShutdownCallback = () => void | Promise<void>;
|
|
12
|
+
|
|
13
|
+
interface ShutdownEntry {
|
|
14
|
+
name: string;
|
|
15
|
+
callback: ShutdownCallback;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ShutdownRegistry {
|
|
19
|
+
private entries: ShutdownEntry[] = [];
|
|
20
|
+
private closed = false;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Register a named cleanup callback.
|
|
24
|
+
* Callbacks are invoked in LIFO order (last registered = first closed).
|
|
25
|
+
*/
|
|
26
|
+
register(name: string, callback: ShutdownCallback): void {
|
|
27
|
+
if (this.closed) return;
|
|
28
|
+
this.entries.push({ name, callback });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run all registered cleanup callbacks in LIFO order.
|
|
33
|
+
* Idempotent — subsequent calls are no-ops.
|
|
34
|
+
* Errors in individual callbacks are caught and logged to stderr
|
|
35
|
+
* so that remaining callbacks still execute.
|
|
36
|
+
*/
|
|
37
|
+
async closeAll(): Promise<void> {
|
|
38
|
+
if (this.closed) return;
|
|
39
|
+
this.closed = true;
|
|
40
|
+
|
|
41
|
+
// LIFO order
|
|
42
|
+
for (let i = this.entries.length - 1; i >= 0; i--) {
|
|
43
|
+
const entry = this.entries[i];
|
|
44
|
+
try {
|
|
45
|
+
await entry.callback();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
// Log but don't throw — remaining cleanups must still run
|
|
48
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
49
|
+
process.stderr.write(`[shutdown] ${entry.name}: ${msg}\n`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.entries = [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Synchronous close — best-effort for non-async callbacks.
|
|
58
|
+
* Use when you can't await (e.g. process.on('exit')).
|
|
59
|
+
*/
|
|
60
|
+
closeAllSync(): void {
|
|
61
|
+
if (this.closed) return;
|
|
62
|
+
this.closed = true;
|
|
63
|
+
|
|
64
|
+
for (let i = this.entries.length - 1; i >= 0; i--) {
|
|
65
|
+
const entry = this.entries[i];
|
|
66
|
+
try {
|
|
67
|
+
entry.callback();
|
|
68
|
+
} catch {
|
|
69
|
+
// Best-effort — swallow errors in sync path
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.entries = [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Number of registered callbacks. */
|
|
77
|
+
get size(): number {
|
|
78
|
+
return this.entries.length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether closeAll() has been called. */
|
|
82
|
+
get isClosed(): boolean {
|
|
83
|
+
return this.closed;
|
|
84
|
+
}
|
|
85
|
+
}
|