@vellumai/assistant 0.10.6-staging.1 → 0.10.6
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/openapi.yaml +43 -6
- package/package.json +1 -1
- package/src/__tests__/attachment-stored-path-annotation.test.ts +151 -0
- package/src/__tests__/attachments-store.test.ts +37 -1
- package/src/__tests__/attachments.test.ts +53 -0
- package/src/__tests__/channel-reply-delivery.test.ts +214 -5
- package/src/__tests__/config-loader-backfill.test.ts +16 -36
- package/src/__tests__/image-source-path-reinject.test.ts +155 -14
- package/src/__tests__/managed-profile-guard.test.ts +2 -0
- package/src/__tests__/mtime-cache.test.ts +265 -80
- package/src/__tests__/plugin-config-data-migration.test.ts +2 -4
- package/src/__tests__/plugin-effective-enabled-set.test.ts +9 -6
- package/src/__tests__/workspace-migration-125-repoint-managed-connections-to-vellum.test.ts +140 -0
- package/src/agent/attachments.ts +55 -13
- package/src/cli/commands/platform/__tests__/credits.test.ts +111 -0
- package/src/cli/commands/platform/index.ts +86 -4
- package/src/cli/lib/__tests__/confirm-prompt.test.ts +9 -9
- package/src/cli/lib/diff-plugin.ts +7 -3
- package/src/cli/lib/inspect-plugin.ts +28 -10
- package/src/cli/lib/install-from-github.ts +69 -24
- package/src/cli/lib/ipc-params.ts +3 -3
- package/src/cli/lib/list-installed-plugins.ts +11 -5
- package/src/cli/lib/merge-plugin-tree.ts +15 -9
- package/src/cli/lib/plugin-fingerprint.ts +0 -6
- package/src/cli/lib/publish-plugin.ts +1 -1
- package/src/cli/lib/unknown-command.ts +3 -1
- package/src/cli/lib/upgrade-plugin.ts +2 -5
- package/src/config/__tests__/sync-gated-profiles.test.ts +2 -2
- package/src/config/seed-inference-profiles.ts +5 -4
- package/src/daemon/conversation-lifecycle.ts +37 -15
- package/src/daemon/conversation-messaging.ts +56 -28
- package/src/daemon/conversation.ts +2 -2
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
- package/src/hooks/__tests__/hook-live-reload.test.ts +110 -96
- package/src/hooks/hook-loader.ts +57 -144
- package/src/hooks/registry.ts +13 -9
- package/src/persistence/attachments-store.ts +2 -1
- package/src/persistence/conversation-crud.ts +24 -0
- package/src/plugin-api/types.ts +4 -1
- package/src/plugins/mtime-cache.ts +347 -33
- package/src/plugins/pipeline.ts +3 -2
- package/src/providers/__tests__/inference.test.ts +42 -31
- package/src/providers/inference/__tests__/connections-label.test.ts +9 -28
- package/src/providers/inference/auth.ts +1 -2
- package/src/providers/inference/backfill.ts +7 -5
- package/src/providers/inference/connections.ts +54 -30
- package/src/runtime/channel-reply-delivery.ts +83 -24
- package/src/runtime/channel-retry-sweep.ts +4 -0
- package/src/runtime/{slack-no-response.ts → no-response.ts} +26 -6
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +43 -3
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +61 -24
- package/src/runtime/routes/conversation-query-routes.ts +18 -1
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -1
- package/src/runtime/routes/inference-provider-connection-routes.ts +4 -3
- package/src/runtime/routes/platform-routes.ts +82 -1
- package/src/runtime/slack-reply-session.ts +1 -1
- package/src/workspace/migrations/125-repoint-managed-connections-to-vellum.ts +99 -0
- package/src/workspace/migrations/registry.ts +2 -0
|
@@ -9,14 +9,21 @@
|
|
|
9
9
|
* plugins directory, registers tools, and drives hook pre-import / init /
|
|
10
10
|
* shutdown by handing the discovered directories to the hook loader.
|
|
11
11
|
*
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* - Boot does one full discovery scan; after that, every change (plugin
|
|
13
|
+
* installed, removed, disabled, or any source file inside a plugin edited
|
|
14
|
+
* — including helper modules hooks/tools import) arrives through the
|
|
15
|
+
* source-versions sentinel published by the resource monitor's watcher.
|
|
16
|
+
* The dispatch path stats that one file and otherwise runs on memory.
|
|
17
|
+
* - A changed plugin is redeployed in place: the old version's `shutdown`
|
|
18
|
+
* runs, its hook/tool cache entries and module-registry entries are
|
|
19
|
+
* swept, and reactivation re-imports fresh source and runs `init`. The
|
|
20
|
+
* whole directory is the reload unit so a re-imported hook can never pair
|
|
21
|
+
* with a stale cached helper.
|
|
15
22
|
* - Plugins are never "registered" as a unit — we register their tools into
|
|
16
|
-
* the global tool registry
|
|
23
|
+
* the global tool registry.
|
|
17
24
|
*
|
|
18
|
-
* The cache is populated at boot by `loadUserPlugins()`
|
|
19
|
-
*
|
|
25
|
+
* The cache is populated at boot by `loadUserPlugins()`; `getHooksFor`
|
|
26
|
+
* reads it on every dispatch via {@link getUserHookEntriesFor}.
|
|
20
27
|
*/
|
|
21
28
|
|
|
22
29
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
@@ -42,15 +49,25 @@ import {
|
|
|
42
49
|
import { finalizeTool } from "../tools/tool-defaults.js";
|
|
43
50
|
import type { Tool, ToolDefinition } from "../tools/types.js";
|
|
44
51
|
import { getLogger } from "../util/logger.js";
|
|
45
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
getWorkspaceHooksDir,
|
|
54
|
+
getWorkspacePluginsDir,
|
|
55
|
+
} from "../util/platform.js";
|
|
46
56
|
import { APP_VERSION } from "../version.js";
|
|
47
57
|
import {
|
|
48
58
|
deriveToolName,
|
|
49
59
|
listSurfaceDir,
|
|
50
60
|
parsePluginManifest,
|
|
51
61
|
} from "./external-plugin-loader.js";
|
|
62
|
+
import { snapshotPluginSource } from "./source-fingerprint.js";
|
|
63
|
+
import type { PluginSourceVersion } from "./source-versions.js";
|
|
64
|
+
import {
|
|
65
|
+
getSourceVersionsPath,
|
|
66
|
+
readSourceVersions,
|
|
67
|
+
} from "./source-versions.js";
|
|
52
68
|
import {
|
|
53
69
|
clearSurfaceImportInflight,
|
|
70
|
+
evictModule,
|
|
54
71
|
getMtime,
|
|
55
72
|
importWithTimeout,
|
|
56
73
|
setSurfaceImportTimeout,
|
|
@@ -93,7 +110,9 @@ const INSTALL_META_FILENAME = "install-meta.json";
|
|
|
93
110
|
*/
|
|
94
111
|
function getInstallDate(pluginDir: string): number {
|
|
95
112
|
const cached = installDateCache.get(pluginDir);
|
|
96
|
-
if (cached !== undefined)
|
|
113
|
+
if (cached !== undefined) {
|
|
114
|
+
return cached;
|
|
115
|
+
}
|
|
97
116
|
|
|
98
117
|
// Try install-meta.json first.
|
|
99
118
|
const metaPath = join(pluginDir, INSTALL_META_FILENAME);
|
|
@@ -171,13 +190,29 @@ const discoveredPluginDirs = new Map<string, string>();
|
|
|
171
190
|
*/
|
|
172
191
|
const disabledPluginDirs = new Set<string>();
|
|
173
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The source-versions state this process last applied, keyed by directory
|
|
195
|
+
* (see `./source-versions.ts`). Seeded at boot from the daemon's own walk —
|
|
196
|
+
* the same fingerprint algorithm over the same disk yields the same stamps
|
|
197
|
+
* as the watcher, so the first sentinel publication after boot diffs
|
|
198
|
+
* correctly even against edits made while the daemon was down.
|
|
199
|
+
*/
|
|
200
|
+
let lastVersions: Record<string, PluginSourceVersion> = {};
|
|
201
|
+
|
|
202
|
+
/** mtime of the sentinel document as of the last look; 0 = never seen. */
|
|
203
|
+
let lastSentinelMtime = 0;
|
|
204
|
+
|
|
205
|
+
/** In-flight reconcile — concurrent dispatches await it rather than racing. */
|
|
206
|
+
let reconcileInFlight: Promise<void> | null = null;
|
|
207
|
+
|
|
174
208
|
// ─── Hook reads ──────────────────────────────────────────────────────────────
|
|
175
209
|
|
|
176
210
|
/**
|
|
177
211
|
* Get all hooks for a given event name from user plugins and standalone
|
|
178
|
-
* workspace hooks.
|
|
179
|
-
*
|
|
180
|
-
* the
|
|
212
|
+
* workspace hooks. Checks the source-versions sentinel first (one stat;
|
|
213
|
+
* reconciles only when the watcher published a change), then delegates to
|
|
214
|
+
* the hook loader's in-memory cache. Plugin hooks run in install-date
|
|
215
|
+
* order, the workspace hook runs last.
|
|
181
216
|
*
|
|
182
217
|
* `effectiveEnabledPlugins` carries the per-chat plugin scope: when non-null,
|
|
183
218
|
* user plugins outside the set are skipped (standalone workspace hooks always
|
|
@@ -187,7 +222,7 @@ export async function getUserHookEntriesFor<TCtx = unknown>(
|
|
|
187
222
|
hookName: string,
|
|
188
223
|
effectiveEnabledPlugins?: Set<string> | null,
|
|
189
224
|
): Promise<HookEntry<TCtx>[]> {
|
|
190
|
-
await
|
|
225
|
+
await maybeReconcileFromSentinel();
|
|
191
226
|
return collectUserHookEntries<TCtx>(
|
|
192
227
|
hookName,
|
|
193
228
|
discoveredPluginDirs,
|
|
@@ -210,6 +245,246 @@ export async function getUserHooksFor<TCtx = unknown>(
|
|
|
210
245
|
return entries.map((e) => e.fn);
|
|
211
246
|
}
|
|
212
247
|
|
|
248
|
+
// ─── Source-versions reconcile ───────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Dispatch-path gate: one stat of the source-versions sentinel. When its
|
|
252
|
+
* mtime is unchanged since the last look (the overwhelmingly common case)
|
|
253
|
+
* this returns immediately, and dispatch runs entirely on memory. When the
|
|
254
|
+
* watcher published a change, the document is applied before the dispatch
|
|
255
|
+
* proceeds, so the turn that follows an edit already runs the new code.
|
|
256
|
+
*
|
|
257
|
+
* A missing or unreadable sentinel is degraded mode, not an error: the
|
|
258
|
+
* boot-time state keeps serving, and live reload resumes when the monitor
|
|
259
|
+
* publishes again.
|
|
260
|
+
*/
|
|
261
|
+
async function maybeReconcileFromSentinel(): Promise<void> {
|
|
262
|
+
if (reconcileInFlight !== null) {
|
|
263
|
+
await reconcileInFlight;
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const mtime = getMtime(getSourceVersionsPath());
|
|
267
|
+
if (mtime === lastSentinelMtime) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// Claim the mtime before any async work, so a concurrent dispatch either
|
|
271
|
+
// finds the in-flight promise above or skips on the updated mtime.
|
|
272
|
+
lastSentinelMtime = mtime;
|
|
273
|
+
if (mtime === 0) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const doc = readSourceVersions();
|
|
277
|
+
if (doc === null) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
reconcileInFlight = applySourceVersions(doc.plugins).finally(() => {
|
|
281
|
+
reconcileInFlight = null;
|
|
282
|
+
});
|
|
283
|
+
await reconcileInFlight;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Apply a published source-versions map: diff it against the state last
|
|
288
|
+
* applied and redeploy exactly what changed. Never throws — a failed apply
|
|
289
|
+
* is logged and the next publication retries from the sentinel's truth.
|
|
290
|
+
*
|
|
291
|
+
* Per directory, the transitions are:
|
|
292
|
+
* - present + enabled with a moved fingerprint → in-place redeploy:
|
|
293
|
+
* `shutdown` (reason `reload`) → sweep hook/tool caches and the module
|
|
294
|
+
* registry → re-import, re-register tools, `init`. The whole directory is
|
|
295
|
+
* the reload unit on purpose: partial eviction would let a re-imported
|
|
296
|
+
* hook pair with a stale cached helper, silently mixing versions.
|
|
297
|
+
* - newly present (installed, or `.disabled` removed) → bring up.
|
|
298
|
+
* - gone, or newly disabled → tear down (`uninstall` / `disable`).
|
|
299
|
+
* The workspace hooks pseudo-entry gets the same treatment through its own
|
|
300
|
+
* `init`/`shutdown` lifecycle.
|
|
301
|
+
*/
|
|
302
|
+
async function applySourceVersions(
|
|
303
|
+
next: Readonly<Record<string, PluginSourceVersion>>,
|
|
304
|
+
): Promise<void> {
|
|
305
|
+
try {
|
|
306
|
+
const workspaceHooksDir = getWorkspaceHooksDir();
|
|
307
|
+
const prev = lastVersions;
|
|
308
|
+
const dirs = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
|
309
|
+
let membershipChanged = false;
|
|
310
|
+
|
|
311
|
+
for (const dir of dirs) {
|
|
312
|
+
if (dir === workspaceHooksDir) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const before = prev[dir];
|
|
316
|
+
const after = next[dir];
|
|
317
|
+
const activeName = discoveredPluginDirs.get(dir);
|
|
318
|
+
const shouldBeUp = after !== undefined && !after.disabled;
|
|
319
|
+
|
|
320
|
+
if (activeName !== undefined && !shouldBeUp) {
|
|
321
|
+
const reason: ShutdownReason =
|
|
322
|
+
after === undefined ? "uninstall" : "disable";
|
|
323
|
+
await deactivatePlugin(activeName, reason);
|
|
324
|
+
await evictPlugin(dir, activeName);
|
|
325
|
+
sweepModules(before, after);
|
|
326
|
+
membershipChanged = true;
|
|
327
|
+
} else if (activeName === undefined && shouldBeUp) {
|
|
328
|
+
// Reinstalls land at the same path: sweep any modules cached from a
|
|
329
|
+
// prior install before the fresh import.
|
|
330
|
+
sweepModules(before, after);
|
|
331
|
+
if (await bringUpPlugin(dir)) {
|
|
332
|
+
membershipChanged = true;
|
|
333
|
+
}
|
|
334
|
+
} else if (
|
|
335
|
+
activeName !== undefined &&
|
|
336
|
+
before !== undefined &&
|
|
337
|
+
after !== undefined &&
|
|
338
|
+
before.fingerprint !== after.fingerprint
|
|
339
|
+
) {
|
|
340
|
+
log.info(
|
|
341
|
+
{ plugin: activeName, dir },
|
|
342
|
+
"plugin source changed — reloading",
|
|
343
|
+
);
|
|
344
|
+
// Shutdown reads the old version's hook from the cache, so it must
|
|
345
|
+
// run before the hook cache is cleared.
|
|
346
|
+
await deactivatePlugin(activeName, "reload");
|
|
347
|
+
evictHooksForOwner(activeName);
|
|
348
|
+
evictToolCacheEntries(activeName);
|
|
349
|
+
sweepModules(before, after);
|
|
350
|
+
// Re-read the manifest — the edit may have renamed the plugin (or
|
|
351
|
+
// broken the manifest, in which case the plugin stays down until a
|
|
352
|
+
// later edit fixes it and moves the fingerprint again).
|
|
353
|
+
const manifest = await parsePluginManifest(dir);
|
|
354
|
+
if (manifest === undefined) {
|
|
355
|
+
discoveredPluginDirs.delete(dir);
|
|
356
|
+
membershipChanged = true;
|
|
357
|
+
} else {
|
|
358
|
+
if (manifest.name !== activeName) {
|
|
359
|
+
discoveredPluginDirs.set(dir, manifest.name);
|
|
360
|
+
membershipChanged = true;
|
|
361
|
+
}
|
|
362
|
+
await reconcilePluginTools(dir, manifest.name);
|
|
363
|
+
await activatePlugin(dir, manifest.name);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (membershipChanged) {
|
|
369
|
+
sortDiscoveredPluginDirs();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
await reconcileWorkspaceHooks(
|
|
373
|
+
prev[workspaceHooksDir],
|
|
374
|
+
next[workspaceHooksDir],
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
lastVersions = { ...next };
|
|
378
|
+
} catch (err) {
|
|
379
|
+
log.error(
|
|
380
|
+
{ err },
|
|
381
|
+
"source-versions reconcile failed — keeping current state",
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Bring up a directory the sentinel reports as present and enabled. Returns
|
|
388
|
+
* whether the plugin joined the discovered set (a malformed manifest is
|
|
389
|
+
* logged by the parser and the directory is skipped until it changes again).
|
|
390
|
+
*/
|
|
391
|
+
async function bringUpPlugin(dir: string): Promise<boolean> {
|
|
392
|
+
const manifest = await parsePluginManifest(dir);
|
|
393
|
+
if (manifest === undefined) {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
discoveredPluginDirs.set(dir, manifest.name);
|
|
397
|
+
disabledPluginDirs.delete(dir);
|
|
398
|
+
log.info({ plugin: manifest.name, dir }, "plugin discovered");
|
|
399
|
+
await reconcilePluginTools(dir, manifest.name);
|
|
400
|
+
await activatePlugin(dir, manifest.name);
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Evict the union of two version entries' module paths from the module
|
|
406
|
+
* registry, so re-imports re-evaluate a mutually consistent set (deleted
|
|
407
|
+
* files may still be cached — hence the union, not just the new list).
|
|
408
|
+
*/
|
|
409
|
+
function sweepModules(
|
|
410
|
+
before: PluginSourceVersion | undefined,
|
|
411
|
+
after: PluginSourceVersion | undefined,
|
|
412
|
+
): void {
|
|
413
|
+
const paths = new Set([
|
|
414
|
+
...(before?.evictionPaths ?? []),
|
|
415
|
+
...(after?.evictionPaths ?? []),
|
|
416
|
+
]);
|
|
417
|
+
for (const path of paths) {
|
|
418
|
+
evictModule(path);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Reconcile the standalone workspace hooks pseudo-entry: same
|
|
424
|
+
* shutdown → sweep → re-import → init cycle a plugin gets, through the
|
|
425
|
+
* workspace owner's lifecycle.
|
|
426
|
+
*/
|
|
427
|
+
async function reconcileWorkspaceHooks(
|
|
428
|
+
before: PluginSourceVersion | undefined,
|
|
429
|
+
after: PluginSourceVersion | undefined,
|
|
430
|
+
): Promise<void> {
|
|
431
|
+
if (before?.fingerprint === after?.fingerprint) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const reason: ShutdownReason = after === undefined ? "uninstall" : "reload";
|
|
435
|
+
const activeIdx = activatedPlugins.findIndex(
|
|
436
|
+
(p) => p.name === WORKSPACE_HOOKS_OWNER,
|
|
437
|
+
);
|
|
438
|
+
if (activeIdx >= 0) {
|
|
439
|
+
await runShutdownHook(
|
|
440
|
+
WORKSPACE_HOOKS_OWNER,
|
|
441
|
+
{ assistantVersion: APP_VERSION, reason },
|
|
442
|
+
reason,
|
|
443
|
+
);
|
|
444
|
+
activatedPlugins.splice(activeIdx, 1);
|
|
445
|
+
}
|
|
446
|
+
evictHooksForOwner(WORKSPACE_HOOKS_OWNER);
|
|
447
|
+
sweepModules(before, after);
|
|
448
|
+
if (after !== undefined) {
|
|
449
|
+
await preImportWorkspaceHooks();
|
|
450
|
+
await runInitHook(WORKSPACE_HOOKS_OWNER);
|
|
451
|
+
activatedPlugins.push({ name: WORKSPACE_HOOKS_OWNER });
|
|
452
|
+
log.info("workspace hooks reloaded");
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Seed the reconcile baseline at boot from the daemon's own walk, and record
|
|
458
|
+
* the sentinel's current mtime without applying its content. Both sides run
|
|
459
|
+
* the same fingerprint algorithm over the same disk, so the seed matches
|
|
460
|
+
* whatever a healthy watcher would publish for the state boot just loaded —
|
|
461
|
+
* and the first publication that differs (including edits made while the
|
|
462
|
+
* daemon was down, which the watcher detects against its own adopted state)
|
|
463
|
+
* diffs correctly against it.
|
|
464
|
+
*/
|
|
465
|
+
function seedVersionBaseline(): void {
|
|
466
|
+
const seeded: Record<string, PluginSourceVersion> = {};
|
|
467
|
+
for (const [dir] of discoveredPluginDirs) {
|
|
468
|
+
const snapshot = snapshotPluginSource(dir);
|
|
469
|
+
seeded[dir] = {
|
|
470
|
+
fingerprint: snapshot.fingerprint,
|
|
471
|
+
evictionPaths: snapshot.evictionPaths,
|
|
472
|
+
disabled: false,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
const workspaceHooksDir = getWorkspaceHooksDir();
|
|
476
|
+
if (existsSync(workspaceHooksDir)) {
|
|
477
|
+
const snapshot = snapshotPluginSource(workspaceHooksDir);
|
|
478
|
+
seeded[workspaceHooksDir] = {
|
|
479
|
+
fingerprint: snapshot.fingerprint,
|
|
480
|
+
evictionPaths: snapshot.evictionPaths,
|
|
481
|
+
disabled: false,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
lastVersions = seeded;
|
|
485
|
+
lastSentinelMtime = getMtime(getSourceVersionsPath());
|
|
486
|
+
}
|
|
487
|
+
|
|
213
488
|
// ─── Tool cache ──────────────────────────────────────────────────────────────
|
|
214
489
|
|
|
215
490
|
/**
|
|
@@ -282,7 +557,9 @@ async function reconcilePluginTools(
|
|
|
282
557
|
// Evict cached tools whose files no longer exist on disk.
|
|
283
558
|
for (const key of toolCache.keys()) {
|
|
284
559
|
const [cachedPluginName, cachedToolName] = key.split("/");
|
|
285
|
-
if (cachedPluginName !== pluginName)
|
|
560
|
+
if (cachedPluginName !== pluginName) {
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
286
563
|
if (!onDiskNames.has(cachedToolName)) {
|
|
287
564
|
toolCache.delete(key);
|
|
288
565
|
}
|
|
@@ -303,10 +580,10 @@ export function getCachedUserTools(): Tool[] {
|
|
|
303
580
|
* Scan the plugins directory, update the discovered set, and reconcile
|
|
304
581
|
* tools for each plugin. Also evicts cache entries for deleted plugins.
|
|
305
582
|
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
583
|
+
* Runs once at boot ({@link populateCacheAtBoot}); steady-state changes —
|
|
584
|
+
* installs, removals, disables, and source edits — arrive through the
|
|
585
|
+
* source-versions reconcile instead, so the dispatch path never pays for
|
|
586
|
+
* this walk.
|
|
310
587
|
*/
|
|
311
588
|
async function scanPlugins(): Promise<void> {
|
|
312
589
|
const pluginsDir = getWorkspacePluginsDir();
|
|
@@ -330,11 +607,15 @@ async function scanPlugins(): Promise<void> {
|
|
|
330
607
|
for (const entry of entries) {
|
|
331
608
|
const pluginDir = join(pluginsDir, entry);
|
|
332
609
|
try {
|
|
333
|
-
if (!statSync(pluginDir).isDirectory())
|
|
610
|
+
if (!statSync(pluginDir).isDirectory()) {
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
334
613
|
} catch {
|
|
335
614
|
continue;
|
|
336
615
|
}
|
|
337
|
-
if (!existsSync(join(pluginDir, "package.json")))
|
|
616
|
+
if (!existsSync(join(pluginDir, "package.json"))) {
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
338
619
|
|
|
339
620
|
// Check for the .disabled sentinel. A plugin is disabled when a file
|
|
340
621
|
// named `.disabled` exists inside its plugin directory. Disabled
|
|
@@ -358,7 +639,9 @@ async function scanPlugins(): Promise<void> {
|
|
|
358
639
|
}
|
|
359
640
|
|
|
360
641
|
const manifest = await parsePluginManifest(pluginDir);
|
|
361
|
-
if (manifest === undefined)
|
|
642
|
+
if (manifest === undefined) {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
362
645
|
const { name: pluginName } = manifest;
|
|
363
646
|
|
|
364
647
|
currentDirs.set(pluginDir, pluginName);
|
|
@@ -383,12 +666,10 @@ async function scanPlugins(): Promise<void> {
|
|
|
383
666
|
// Update the discovered set, sorted by original install date so
|
|
384
667
|
// hook execution order and tool registration order are deterministic.
|
|
385
668
|
discoveredPluginDirs.clear();
|
|
386
|
-
const
|
|
387
|
-
([dirA], [dirB]) => getInstallDate(dirA) - getInstallDate(dirB),
|
|
388
|
-
);
|
|
389
|
-
for (const [dir, name] of sorted) {
|
|
669
|
+
for (const [dir, name] of currentDirs) {
|
|
390
670
|
discoveredPluginDirs.set(dir, name);
|
|
391
671
|
}
|
|
672
|
+
sortDiscoveredPluginDirs();
|
|
392
673
|
|
|
393
674
|
// Activate any plugin not yet brought up. Idempotent: already-active plugins
|
|
394
675
|
// are skipped by the `activatedNames` guard, so steady-state scans (one per
|
|
@@ -400,6 +681,21 @@ async function scanPlugins(): Promise<void> {
|
|
|
400
681
|
}
|
|
401
682
|
}
|
|
402
683
|
|
|
684
|
+
/**
|
|
685
|
+
* Re-sort the discovered set by original install date, so hook execution
|
|
686
|
+
* order and tool registration order stay deterministic as membership
|
|
687
|
+
* changes at runtime.
|
|
688
|
+
*/
|
|
689
|
+
function sortDiscoveredPluginDirs(): void {
|
|
690
|
+
const sorted = [...discoveredPluginDirs.entries()].sort(
|
|
691
|
+
([dirA], [dirB]) => getInstallDate(dirA) - getInstallDate(dirB),
|
|
692
|
+
);
|
|
693
|
+
discoveredPluginDirs.clear();
|
|
694
|
+
for (const [dir, name] of sorted) {
|
|
695
|
+
discoveredPluginDirs.set(dir, name);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
403
699
|
/**
|
|
404
700
|
* Evict all cache entries for a deleted plugin directory. The plugin name
|
|
405
701
|
* is passed in from the discoveredPluginDirs map (captured when the plugin
|
|
@@ -413,12 +709,7 @@ async function evictPlugin(
|
|
|
413
709
|
evictHooksForOwner(pluginName);
|
|
414
710
|
|
|
415
711
|
// Evict tools.
|
|
416
|
-
|
|
417
|
-
for (const key of toolCache.keys()) {
|
|
418
|
-
if (key.startsWith(toolPrefix)) {
|
|
419
|
-
toolCache.delete(key);
|
|
420
|
-
}
|
|
421
|
-
}
|
|
712
|
+
evictToolCacheEntries(pluginName);
|
|
422
713
|
|
|
423
714
|
log.info(
|
|
424
715
|
{ plugin: pluginName, pluginDir },
|
|
@@ -428,6 +719,16 @@ async function evictPlugin(
|
|
|
428
719
|
installDateCache.delete(pluginDir);
|
|
429
720
|
}
|
|
430
721
|
|
|
722
|
+
/** Drop every `toolCache` entry owned by `pluginName`. */
|
|
723
|
+
function evictToolCacheEntries(pluginName: string): void {
|
|
724
|
+
const toolPrefix = `${pluginName}/`;
|
|
725
|
+
for (const key of toolCache.keys()) {
|
|
726
|
+
if (key.startsWith(toolPrefix)) {
|
|
727
|
+
toolCache.delete(key);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
431
732
|
/**
|
|
432
733
|
* Evict all plugin-owned cache entries (when the plugins directory is gone
|
|
433
734
|
* entirely). Standalone workspace hooks are preserved by the hook loader:
|
|
@@ -477,7 +778,9 @@ async function activatePlugin(
|
|
|
477
778
|
pluginDir: string,
|
|
478
779
|
pluginName: string,
|
|
479
780
|
): Promise<void> {
|
|
480
|
-
if (activatedNames.has(pluginName))
|
|
781
|
+
if (activatedNames.has(pluginName)) {
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
481
784
|
// Reserve synchronously, before any await, so a re-entrant or concurrent
|
|
482
785
|
// scan observes this plugin as already handled.
|
|
483
786
|
activatedNames.add(pluginName);
|
|
@@ -523,10 +826,14 @@ async function deactivatePlugin(
|
|
|
523
826
|
pluginName: string,
|
|
524
827
|
reason: ShutdownReason,
|
|
525
828
|
): Promise<void> {
|
|
526
|
-
if (!activatedNames.has(pluginName))
|
|
829
|
+
if (!activatedNames.has(pluginName)) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
527
832
|
activatedNames.delete(pluginName);
|
|
528
833
|
const idx = activatedPlugins.findIndex((p) => p.name === pluginName);
|
|
529
|
-
if (idx >= 0)
|
|
834
|
+
if (idx >= 0) {
|
|
835
|
+
activatedPlugins.splice(idx, 1);
|
|
836
|
+
}
|
|
530
837
|
|
|
531
838
|
// Unregister tools before running shutdown so the model-visible surface is
|
|
532
839
|
// clean before teardown.
|
|
@@ -587,6 +894,10 @@ export async function populateCacheAtBoot(
|
|
|
587
894
|
await runInitHook(WORKSPACE_HOOKS_OWNER);
|
|
588
895
|
activatedPlugins.push({ name: WORKSPACE_HOOKS_OWNER });
|
|
589
896
|
}
|
|
897
|
+
|
|
898
|
+
// Boot is self-sufficient (the scan above never waits on the monitor);
|
|
899
|
+
// from here on, changes arrive via the sentinel diffed against this seed.
|
|
900
|
+
seedVersionBaseline();
|
|
590
901
|
}
|
|
591
902
|
|
|
592
903
|
// ─── Test hooks ──────────────────────────────────────────────────────────────
|
|
@@ -610,6 +921,9 @@ export function resetPluginCacheForTests(): void {
|
|
|
610
921
|
activatedPlugins.length = 0;
|
|
611
922
|
activatedNames.clear();
|
|
612
923
|
disabledPluginDirs.clear();
|
|
924
|
+
lastVersions = {};
|
|
925
|
+
lastSentinelMtime = 0;
|
|
926
|
+
reconcileInFlight = null;
|
|
613
927
|
}
|
|
614
928
|
|
|
615
929
|
/**
|
package/src/plugins/pipeline.ts
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* place (returning `void`) or returns a partial context whose fields are merged
|
|
10
10
|
* onto the draft. Failed hook drafts are discarded.
|
|
11
11
|
*
|
|
12
|
-
* `getHooksFor` is now async — it pulls user-land hooks from the
|
|
13
|
-
* cache (filesystem-as-truth
|
|
12
|
+
* `getHooksFor` is now async — it pulls user-land hooks from the plugin
|
|
13
|
+
* cache (filesystem-as-truth via the source-versions reconcile) and default
|
|
14
|
+
* plugin hooks from the registry
|
|
14
15
|
* in a single unified call.
|
|
15
16
|
*
|
|
16
17
|
* Design doc: `.private/plans/agent-plugin-system.md`.
|
|
@@ -53,35 +53,42 @@ describe("migrateCreateProviderConnections", () => {
|
|
|
53
53
|
expect(Array.isArray(rows)).toBe(true);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
test("seeds
|
|
56
|
+
test("seedCanonicalConnections seeds the provider-agnostic vellum connection", () => {
|
|
57
57
|
const { db } = setupDb();
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
expect(conn).not.toBeNull();
|
|
66
|
-
}
|
|
58
|
+
seedCanonicalConnections(db);
|
|
59
|
+
const vellum = getConnection(db, "vellum");
|
|
60
|
+
expect(vellum).not.toBeNull();
|
|
61
|
+
expect(vellum?.provider).toBe("vellum");
|
|
62
|
+
expect(vellum?.auth.type).toBe("platform");
|
|
63
|
+
// Derived from MANAGED_CONNECTION_NAMES → write-protected at the route layer.
|
|
64
|
+
expect(vellum?.isManaged).toBe(true);
|
|
67
65
|
});
|
|
68
66
|
|
|
69
|
-
test("
|
|
67
|
+
test("seedCanonicalConnections is idempotent", () => {
|
|
70
68
|
const { db } = setupDb();
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
// Run twice — should not throw or create duplicate vellum rows.
|
|
70
|
+
seedCanonicalConnections(db);
|
|
71
|
+
seedCanonicalConnections(db);
|
|
72
|
+
const managed = listConnections(db, { provider: "vellum" });
|
|
73
|
+
expect(managed.filter((c) => c.name === "vellum").length).toBe(1);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
test("seedCanonicalConnections
|
|
76
|
+
test("seedCanonicalConnections does not clobber a user connection named vellum", () => {
|
|
77
77
|
const { db } = setupDb();
|
|
78
|
-
//
|
|
79
|
-
|
|
78
|
+
// `vellum` was not reserved before consolidation, so an install may already
|
|
79
|
+
// have a BYOK connection keyed `vellum`. Seeding must not rewrite it to the
|
|
80
|
+
// platform-auth sentinel.
|
|
81
|
+
createConnection(db, {
|
|
82
|
+
name: "vellum",
|
|
83
|
+
provider: "anthropic",
|
|
84
|
+
auth: { type: "api_key", credential: "credential/anthropic/api_key" },
|
|
85
|
+
});
|
|
86
|
+
|
|
80
87
|
seedCanonicalConnections(db);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
);
|
|
88
|
+
|
|
89
|
+
const conn = getConnection(db, "vellum");
|
|
90
|
+
expect(conn?.provider).toBe("anthropic");
|
|
91
|
+
expect(conn?.auth.type).toBe("api_key");
|
|
85
92
|
});
|
|
86
93
|
});
|
|
87
94
|
|
|
@@ -274,32 +281,36 @@ describe("Mix-and-match: two profiles, same provider, different connections", ()
|
|
|
274
281
|
test("getConnection returns the right auth for each connection name", () => {
|
|
275
282
|
const { db } = setupDb();
|
|
276
283
|
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
284
|
+
// Two connections for the same provider with distinct auth resolve
|
|
285
|
+
// independently: one platform-auth, one personal api_key.
|
|
286
|
+
createConnection(db, {
|
|
287
|
+
name: "anthropic-platform",
|
|
288
|
+
provider: "anthropic",
|
|
289
|
+
auth: { type: "platform" },
|
|
290
|
+
});
|
|
282
291
|
createConnection(db, {
|
|
283
292
|
name: "anthropic-personal",
|
|
284
293
|
provider: "anthropic",
|
|
285
294
|
auth: { type: "api_key", credential: "credential/anthropic/api_key" },
|
|
286
295
|
});
|
|
287
296
|
|
|
288
|
-
|
|
289
|
-
expect(
|
|
297
|
+
expect(getConnection(db, "anthropic-platform")?.auth.type).toBe("platform");
|
|
298
|
+
expect(getConnection(db, "anthropic-personal")?.auth.type).toBe("api_key");
|
|
290
299
|
|
|
291
300
|
// Both connections exist for the same provider.
|
|
292
301
|
const anthropicConns = listConnections(db, { provider: "anthropic" });
|
|
293
302
|
const names = anthropicConns.map((c) => c.name);
|
|
294
|
-
expect(names).toContain("anthropic-
|
|
303
|
+
expect(names).toContain("anthropic-platform");
|
|
295
304
|
expect(names).toContain("anthropic-personal");
|
|
296
305
|
|
|
297
306
|
// Auth is distinct per connection.
|
|
298
|
-
const
|
|
307
|
+
const platform = anthropicConns.find(
|
|
308
|
+
(c) => c.name === "anthropic-platform",
|
|
309
|
+
);
|
|
299
310
|
const personal = anthropicConns.find(
|
|
300
311
|
(c) => c.name === "anthropic-personal",
|
|
301
312
|
);
|
|
302
|
-
expect(
|
|
313
|
+
expect(platform?.auth.type).toBe("platform");
|
|
303
314
|
expect(personal?.auth.type).toBe("api_key");
|
|
304
315
|
});
|
|
305
316
|
});
|