@vellumai/assistant 0.10.5-dev.202607060745.b26cd90 → 0.10.5-dev.202607061137.28aab2f
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 +31 -0
- package/package.json +1 -1
- package/src/__tests__/background-workers-disk-pressure.test.ts +2 -1
- package/src/__tests__/job-handler-registration-guard.test.ts +1 -1
- package/src/__tests__/memory-jobs-worker-backoff.test.ts +1 -1
- package/src/__tests__/memory-jobs-worker-lanes.test.ts +1 -1
- package/src/__tests__/memory-worker-tool-registry-guard.test.ts +2 -2
- package/src/__tests__/mtime-cache.test.ts +3 -4
- package/src/__tests__/persistence-layering-guard.test.ts +3 -7
- package/src/__tests__/plugin-import-boundary-guard.test.ts +7 -1
- package/src/cli/commands/monitoring.ts +16 -0
- package/src/config/schemas/monitoring.ts +10 -0
- package/src/daemon/shutdown-handlers.ts +0 -5
- package/src/hooks/__tests__/hook-live-reload.test.ts +153 -0
- package/src/hooks/hook-loader.ts +40 -8
- package/src/monitoring/__tests__/active-conversations.test.ts +87 -0
- package/src/monitoring/__tests__/resource-sampler.test.ts +1 -0
- package/src/monitoring/__tests__/stall-capture.test.ts +1 -0
- package/src/monitoring/active-conversations.ts +97 -0
- package/src/monitoring/resource-sampler.ts +62 -21
- package/src/persistence/job-handlers/message-lexical.ts +1 -1
- package/src/persistence/worker-control.ts +1 -1
- package/src/plugins/defaults/index.ts +2 -0
- package/src/{persistence → plugins/defaults/memory}/__tests__/jobs-worker-memory-disabled.test.ts +8 -8
- package/src/plugins/defaults/memory/__tests__/jobs-worker-pkb-schedule.test.ts +1 -1
- package/src/plugins/defaults/memory/__tests__/jobs-worker-v2-graph-trigger-embed.test.ts +1 -1
- package/src/plugins/defaults/memory/__tests__/jobs-worker-v2-schedule.test.ts +1 -2
- package/src/plugins/defaults/memory/__tests__/shutdown-hook.test.ts +26 -0
- package/src/plugins/defaults/memory/hooks/shutdown.ts +21 -0
- package/src/plugins/defaults/memory/job-handler-registration.ts +1 -1
- package/src/{persistence → plugins/defaults/memory}/jobs-worker.ts +17 -14
- package/src/plugins/defaults/memory/startup.ts +1 -1
- package/src/{jobs → plugins/defaults/memory}/worker.ts +7 -7
- package/src/plugins/surface-import.ts +27 -6
- package/src/plugins/types.ts +1 -1
- package/src/runtime/routes/__tests__/monitoring-routes.test.ts +45 -0
- package/src/runtime/routes/consolidation-routes.ts +4 -2
- package/src/runtime/routes/filing-routes.ts +1 -1
- package/src/runtime/routes/monitoring-routes.ts +23 -1
- package/src/util/process-tree.ts +3 -3
package/openapi.yaml
CHANGED
|
@@ -19830,6 +19830,36 @@ paths:
|
|
|
19830
19830
|
- freeMb
|
|
19831
19831
|
additionalProperties: false
|
|
19832
19832
|
- type: "null"
|
|
19833
|
+
activeConversations:
|
|
19834
|
+
anyOf:
|
|
19835
|
+
- type: array
|
|
19836
|
+
items:
|
|
19837
|
+
type: object
|
|
19838
|
+
properties:
|
|
19839
|
+
conversationId:
|
|
19840
|
+
type: string
|
|
19841
|
+
title:
|
|
19842
|
+
anyOf:
|
|
19843
|
+
- type: string
|
|
19844
|
+
- type: "null"
|
|
19845
|
+
originChannel:
|
|
19846
|
+
anyOf:
|
|
19847
|
+
- type: string
|
|
19848
|
+
- type: "null"
|
|
19849
|
+
originInterface:
|
|
19850
|
+
anyOf:
|
|
19851
|
+
- type: string
|
|
19852
|
+
- type: "null"
|
|
19853
|
+
processingStartedAt:
|
|
19854
|
+
type: number
|
|
19855
|
+
required:
|
|
19856
|
+
- conversationId
|
|
19857
|
+
- title
|
|
19858
|
+
- originChannel
|
|
19859
|
+
- originInterface
|
|
19860
|
+
- processingStartedAt
|
|
19861
|
+
additionalProperties: false
|
|
19862
|
+
- type: "null"
|
|
19833
19863
|
required:
|
|
19834
19864
|
- ts
|
|
19835
19865
|
- memory
|
|
@@ -19839,6 +19869,7 @@ paths:
|
|
|
19839
19869
|
- events
|
|
19840
19870
|
- deltas
|
|
19841
19871
|
- disk
|
|
19872
|
+
- activeConversations
|
|
19842
19873
|
additionalProperties: false
|
|
19843
19874
|
- type: "null"
|
|
19844
19875
|
required:
|
package/package.json
CHANGED
|
@@ -213,7 +213,8 @@ mock.module("../persistence/cleanup-schedule-state.js", () => ({
|
|
|
213
213
|
markScheduledCleanupEnqueued: mock(() => {}),
|
|
214
214
|
}));
|
|
215
215
|
|
|
216
|
-
const { runMemoryJobsOnce } =
|
|
216
|
+
const { runMemoryJobsOnce } =
|
|
217
|
+
await import("../plugins/defaults/memory/jobs-worker.js");
|
|
217
218
|
const { WorkspaceHeartbeatService } =
|
|
218
219
|
await import("../workspace/heartbeat-service.js");
|
|
219
220
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, spyOn } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import * as jobsWorker from "../persistence/jobs-worker.js";
|
|
4
3
|
import { registerMemoryPluginJobHandlers } from "../plugins/defaults/memory/job-handler-registration.js";
|
|
4
|
+
import * as jobsWorker from "../plugins/defaults/memory/jobs-worker.js";
|
|
5
5
|
import { registerDefaultPluginPersistenceHooks } from "../plugins/defaults/memory/persistence-hooks-registration.js";
|
|
6
6
|
import {
|
|
7
7
|
getMemoryPersistenceHooks,
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
POLL_INTERVAL_MAX_MS,
|
|
42
42
|
POLL_INTERVAL_MIN_MS,
|
|
43
43
|
startMemoryJobsWorker,
|
|
44
|
-
} from "../
|
|
44
|
+
} from "../plugins/defaults/memory/jobs-worker.js";
|
|
45
45
|
|
|
46
46
|
describe("memory jobs worker adaptive poll interval", () => {
|
|
47
47
|
test("exports expected poll interval constants", () => {
|
|
@@ -114,9 +114,9 @@ import { getMemoryDb } from "../persistence/db-connection.js";
|
|
|
114
114
|
import { initializeDb } from "../persistence/db-init.js";
|
|
115
115
|
import { _resetQdrantBreaker } from "../persistence/embeddings/qdrant-circuit-breaker.js";
|
|
116
116
|
import { enqueueMemoryJob } from "../persistence/jobs-store.js";
|
|
117
|
-
import { runMemoryJobsOnce } from "../persistence/jobs-worker.js";
|
|
118
117
|
import { memoryJobs } from "../persistence/schema/index.js";
|
|
119
118
|
import { registerMemoryPluginJobHandlers } from "../plugins/defaults/memory/job-handler-registration.js";
|
|
119
|
+
import { runMemoryJobsOnce } from "../plugins/defaults/memory/jobs-worker.js";
|
|
120
120
|
|
|
121
121
|
describe("memory jobs worker lane scheduling", () => {
|
|
122
122
|
beforeAll(async () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Guard test: memory-worker tool registry bootstrap
|
|
3
3
|
*
|
|
4
|
-
* The standalone memory jobs worker (`src/
|
|
4
|
+
* The standalone memory jobs worker (`src/plugins/defaults/memory/worker.ts`) hosts real agent
|
|
5
5
|
* conversations — retrospective and consolidation passes, plus any subagents
|
|
6
6
|
* they spawn — and those conversations resolve their tool surface from that
|
|
7
7
|
* process's registry. The daemon and the schedule worker populate the
|
|
@@ -34,7 +34,7 @@ afterAll(() => {
|
|
|
34
34
|
describe("memory worker tool registry", () => {
|
|
35
35
|
test("worker entrypoint populates the tool registry at startup", () => {
|
|
36
36
|
const source = readFileSync(
|
|
37
|
-
join(import.meta.dir, "..", "
|
|
37
|
+
join(import.meta.dir, "..", "plugins", "defaults", "memory", "worker.ts"),
|
|
38
38
|
"utf8",
|
|
39
39
|
);
|
|
40
40
|
expect(source).toContain("await initializeTools()");
|
|
@@ -438,10 +438,9 @@ describe("workspace hooks (<workspace>/hooks/)", () => {
|
|
|
438
438
|
});
|
|
439
439
|
|
|
440
440
|
// NB: each test below uses a distinct hook event name so the workspace
|
|
441
|
-
// hook file path is unique
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
// by using a fresh plugin directory per test).
|
|
441
|
+
// hook file path is unique and each test stays independent of the cache
|
|
442
|
+
// state its siblings leave behind (the plugin tests get the same isolation
|
|
443
|
+
// from a fresh plugin directory per test).
|
|
445
444
|
test("plugin hooks run before the workspace hook for the same event", async () => {
|
|
446
445
|
const dir = freshPluginDir("ordering-plugin");
|
|
447
446
|
writePackageJson(dir, { ...SIMPLE_PKG, name: "ordering-plugin" });
|
|
@@ -45,10 +45,9 @@ const MEMORY_DIR = join(ASSISTANT_SRC, "plugins", "defaults", "memory");
|
|
|
45
45
|
* (`persistence-lifecycle-seam.ts` — every event and query on it is
|
|
46
46
|
* memory-domain), and these persistence call sites invoke the seam's
|
|
47
47
|
* registered handlers through `getMemoryPersistenceHooks()` (no-op when
|
|
48
|
-
* memory is not present). Decoupling plan: the
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* the conversation-crud entry).
|
|
48
|
+
* memory is not present). Decoupling plan: the per-event lifecycle
|
|
49
|
+
* notifications migrate to the first-class `hooks` system, retiring the
|
|
50
|
+
* conversation-crud entry.
|
|
52
51
|
*
|
|
53
52
|
* Keyed by the importing persistence file (relative to repo root); the value
|
|
54
53
|
* is the set of allowed `memory/<specifier>` module paths it may import.
|
|
@@ -57,9 +56,6 @@ const PERSISTENCE_TO_MEMORY_ALLOWLIST: Record<string, ReadonlySet<string>> = {
|
|
|
57
56
|
"assistant/src/persistence/conversation-crud.ts": new Set([
|
|
58
57
|
"persistence-lifecycle-seam",
|
|
59
58
|
]),
|
|
60
|
-
"assistant/src/persistence/jobs-worker.ts": new Set([
|
|
61
|
-
"persistence-lifecycle-seam",
|
|
62
|
-
]),
|
|
63
59
|
};
|
|
64
60
|
|
|
65
61
|
/**
|
|
@@ -168,6 +168,7 @@ const BASELINE: Record<string, readonly string[]> = {
|
|
|
168
168
|
"../../../context/token-estimator.js",
|
|
169
169
|
"../../../conversations/job-handlers/summarization.js",
|
|
170
170
|
"../../../daemon/date-context.js",
|
|
171
|
+
"../../../daemon/disk-pressure-background-gate.js",
|
|
171
172
|
"../../../daemon/embedding-reconcile.js",
|
|
172
173
|
"../../../daemon/pkb-context-tracker.js",
|
|
173
174
|
"../../../daemon/pkb-reminder-builder.js",
|
|
@@ -178,12 +179,16 @@ const BASELINE: Record<string, readonly string[]> = {
|
|
|
178
179
|
"../../../media/job-handlers/media-processing.js",
|
|
179
180
|
"../../../permissions/types.js",
|
|
180
181
|
"../../../persistence/checkpoints.js",
|
|
182
|
+
"../../../persistence/cleanup-schedule-state.js",
|
|
181
183
|
"../../../persistence/conversation-crud.js",
|
|
182
184
|
"../../../persistence/db-connection.js",
|
|
185
|
+
"../../../persistence/db-maintenance.js",
|
|
183
186
|
"../../../persistence/embeddings/embedding-backend.js",
|
|
187
|
+
"../../../persistence/embeddings/embedding-billing-breaker.js",
|
|
184
188
|
"../../../persistence/embeddings/embedding-runtime-manager.js",
|
|
185
189
|
"../../../persistence/embeddings/embedding-types.js",
|
|
186
190
|
"../../../persistence/embeddings/messages-lexical-index.js",
|
|
191
|
+
"../../../persistence/embeddings/qdrant-circuit-breaker.js",
|
|
187
192
|
"../../../persistence/embeddings/qdrant-client.js",
|
|
188
193
|
"../../../persistence/embeddings/qdrant-manager.js",
|
|
189
194
|
"../../../persistence/job-handlers/cleanup.js",
|
|
@@ -191,10 +196,10 @@ const BASELINE: Record<string, readonly string[]> = {
|
|
|
191
196
|
"../../../persistence/job-handlers/message-lexical.js",
|
|
192
197
|
"../../../persistence/job-utils.js",
|
|
193
198
|
"../../../persistence/jobs-store.js",
|
|
194
|
-
"../../../persistence/jobs-worker.js",
|
|
195
199
|
"../../../persistence/message-content.js",
|
|
196
200
|
"../../../persistence/raw-query.js",
|
|
197
201
|
"../../../persistence/schema/index.js",
|
|
202
|
+
"../../../persistence/worker-control.js",
|
|
198
203
|
"../../../prompts/persona-resolver.js",
|
|
199
204
|
"../../../prompts/system-prompt.js",
|
|
200
205
|
"../../../runtime/actor-trust-resolver.js",
|
|
@@ -204,6 +209,7 @@ const BASELINE: Record<string, readonly string[]> = {
|
|
|
204
209
|
"../../../runtime/services/auto-analysis-enqueue.js",
|
|
205
210
|
"../../../runtime/services/auto-analysis-guard.js",
|
|
206
211
|
"../../../runtime/services/conversation-analyze-job.js",
|
|
212
|
+
"../../../tools/registry.js",
|
|
207
213
|
"../../../tools/types.js",
|
|
208
214
|
"../../../types.js",
|
|
209
215
|
"../../../util/logger.js",
|
|
@@ -76,6 +76,13 @@ interface LatestSample {
|
|
|
76
76
|
totalMb: number;
|
|
77
77
|
freeMb: number;
|
|
78
78
|
} | null;
|
|
79
|
+
activeConversations: Array<{
|
|
80
|
+
conversationId: string;
|
|
81
|
+
title: string | null;
|
|
82
|
+
originChannel: string | null;
|
|
83
|
+
originInterface: string | null;
|
|
84
|
+
processingStartedAt: number;
|
|
85
|
+
}> | null;
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
interface StartResponse {
|
|
@@ -262,6 +269,15 @@ Examples:
|
|
|
262
269
|
` Disk (${sample.disk.path}): ${sample.disk.usedMb} / ${sample.disk.totalMb} MiB used`,
|
|
263
270
|
);
|
|
264
271
|
}
|
|
272
|
+
if (sample.activeConversations?.length) {
|
|
273
|
+
const items = sample.activeConversations
|
|
274
|
+
.map(
|
|
275
|
+
(c) =>
|
|
276
|
+
`${c.conversationId}${c.title ? ` "${c.title}"` : ""}${c.originChannel ? ` via ${c.originChannel}` : ""}`,
|
|
277
|
+
)
|
|
278
|
+
.join(", ");
|
|
279
|
+
log.info(` Processing: ${items}`);
|
|
280
|
+
}
|
|
265
281
|
});
|
|
266
282
|
},
|
|
267
283
|
});
|
|
@@ -55,6 +55,16 @@ export const MonitoringConfigSchema = z
|
|
|
55
55
|
.describe(
|
|
56
56
|
"Minimum interval between high-memory snapshots, in milliseconds, so a sustained spike does not write a snapshot on every sample.",
|
|
57
57
|
),
|
|
58
|
+
baselineSnapshotIntervalMs: z
|
|
59
|
+
.number({
|
|
60
|
+
error: "monitoring.baselineSnapshotIntervalMs must be a number",
|
|
61
|
+
})
|
|
62
|
+
.int("monitoring.baselineSnapshotIntervalMs must be an integer")
|
|
63
|
+
.min(60_000, "monitoring.baselineSnapshotIntervalMs must be >= 60000ms")
|
|
64
|
+
.default(600_000)
|
|
65
|
+
.describe(
|
|
66
|
+
"Interval between periodic baseline snapshots, in milliseconds (default 10 minutes). Baselines capture the same forensics as high-memory snapshots but on a steady cadence, so a spike can be diffed against a healthy reference instead of only against other over-threshold captures. Retained separately from high-memory snapshots.",
|
|
67
|
+
),
|
|
58
68
|
})
|
|
59
69
|
.describe("Resource monitor process configuration");
|
|
60
70
|
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
} from "../persistence/db-async-query.js";
|
|
14
14
|
import { getSqlite, isDbOpen, resetDb } from "../persistence/db-connection.js";
|
|
15
15
|
import { stopQdrantManager } from "../persistence/embeddings/qdrant-manager.js";
|
|
16
|
-
import { stopMemoryJobsWorker } from "../persistence/jobs-worker.js";
|
|
17
16
|
import { stopMemoryWorkerProcess } from "../persistence/worker-control.js";
|
|
18
17
|
import { stopConsentRefresh } from "../platform/consent-cache.js";
|
|
19
18
|
import { HOOKS } from "../plugin-api/constants.js";
|
|
@@ -165,10 +164,6 @@ async function shutdown(): Promise<void> {
|
|
|
165
164
|
cleanupShellOutputTempFiles();
|
|
166
165
|
stopScheduler();
|
|
167
166
|
|
|
168
|
-
// Stop the in-process memory worker supervisor if it was started on the
|
|
169
|
-
// daemon's event loop (memory.worker.enabled = false).
|
|
170
|
-
stopMemoryJobsWorker();
|
|
171
|
-
|
|
172
167
|
// Stop the out-of-process memory worker if it's actually running. This is
|
|
173
168
|
// keyed off live state rather than config: the worker may have been
|
|
174
169
|
// spawned at startup (memory.worker.enabled = true) or out of band via
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end coverage for hook live-reload through the hook loader: an
|
|
3
|
+
* edited hook file takes effect on the next dispatch, without a daemon
|
|
4
|
+
* restart.
|
|
5
|
+
*
|
|
6
|
+
* The loader keys each hook on its source file's mtime; when the mtime
|
|
7
|
+
* moves it evicts the module from the runtime registry (`evictModule` in
|
|
8
|
+
* `../../plugins/surface-import.ts`) and re-imports, so the fresh content is
|
|
9
|
+
* what dispatches. These tests drive the public collection API against real
|
|
10
|
+
* hook files on disk, which also makes them the regression guard for the
|
|
11
|
+
* Bun behavior the eviction is built on — `require.cache` deletion
|
|
12
|
+
* invalidating dynamic `import()` is intended per
|
|
13
|
+
* github.com/oven-sh/bun/discussions/10162 but not spec-guaranteed, and a
|
|
14
|
+
* Bun upgrade that breaks it must fail here rather than silently degrade
|
|
15
|
+
* hook edits back to restart-required.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
mkdirSync,
|
|
20
|
+
mkdtempSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
unlinkSync,
|
|
23
|
+
utimesSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
29
|
+
|
|
30
|
+
import type { HookEntry } from "../../plugins/types.js";
|
|
31
|
+
import { getWorkspaceHooksDir } from "../../util/platform.js";
|
|
32
|
+
import {
|
|
33
|
+
collectUserHookEntries,
|
|
34
|
+
resetHookCacheForTests,
|
|
35
|
+
} from "../hook-loader.js";
|
|
36
|
+
|
|
37
|
+
const HOOK_NAME = "user-prompt-submit";
|
|
38
|
+
|
|
39
|
+
const root = mkdtempSync(join(tmpdir(), "hook-live-reload-"));
|
|
40
|
+
|
|
41
|
+
afterAll(() => {
|
|
42
|
+
rmSync(root, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
resetHookCacheForTests();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Each write gets an explicitly bumped, strictly increasing mtime: two
|
|
51
|
+
* writes can land inside the same filesystem-timestamp granule, which the
|
|
52
|
+
* loader's mtime check would read as "unchanged". Real edits arrive seconds
|
|
53
|
+
* apart; the tests must not race that granularity.
|
|
54
|
+
*/
|
|
55
|
+
let mtimeSeq = 1_750_000_000;
|
|
56
|
+
function writeHookFile(hooksDir: string, marker: string): string {
|
|
57
|
+
const path = join(hooksDir, `${HOOK_NAME}.ts`);
|
|
58
|
+
writeFileSync(path, `export default () => ${JSON.stringify(marker)};\n`);
|
|
59
|
+
const stamp = new Date(++mtimeSeq * 1000);
|
|
60
|
+
utimesSync(path, stamp, stamp);
|
|
61
|
+
return path;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Fresh plugin fixture: `<root>/<name>/hooks/`, unique name per call. */
|
|
65
|
+
let pluginSeq = 0;
|
|
66
|
+
function makePlugin(): { dir: string; hooksDir: string; name: string } {
|
|
67
|
+
const name = `reload-plugin-${++pluginSeq}`;
|
|
68
|
+
const dir = join(root, name);
|
|
69
|
+
const hooksDir = join(dir, "hooks");
|
|
70
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
71
|
+
return { dir, hooksDir, name };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Collect the hook chain for a plugin, exactly as dispatch does. */
|
|
75
|
+
async function collect(
|
|
76
|
+
pluginDir: string,
|
|
77
|
+
pluginName: string,
|
|
78
|
+
): Promise<HookEntry[]> {
|
|
79
|
+
return collectUserHookEntries(HOOK_NAME, [[pluginDir, pluginName]]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Run the single expected hook in the chain and return its result. */
|
|
83
|
+
async function dispatchOne(
|
|
84
|
+
pluginDir: string,
|
|
85
|
+
pluginName: string,
|
|
86
|
+
): Promise<unknown> {
|
|
87
|
+
const entries = await collect(pluginDir, pluginName);
|
|
88
|
+
expect(entries).toHaveLength(1);
|
|
89
|
+
return (entries[0]!.fn as () => unknown)();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe("hook live-reload", () => {
|
|
93
|
+
test("an edited hook file takes effect on the next dispatch, repeatably", async () => {
|
|
94
|
+
const plugin = makePlugin();
|
|
95
|
+
writeHookFile(plugin.hooksDir, "v1");
|
|
96
|
+
expect(await dispatchOne(plugin.dir, plugin.name)).toBe("v1");
|
|
97
|
+
|
|
98
|
+
// Two edit → dispatch cycles: the first proves reload works at all, the
|
|
99
|
+
// second proves a reloaded hook is itself reloadable (edits keep landing
|
|
100
|
+
// turn after turn, not just once).
|
|
101
|
+
for (const marker of ["v2", "v3"]) {
|
|
102
|
+
writeHookFile(plugin.hooksDir, marker);
|
|
103
|
+
expect(await dispatchOne(plugin.dir, plugin.name)).toBe(marker);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("an unchanged hook file is served from the cache, not re-imported", async () => {
|
|
108
|
+
const plugin = makePlugin();
|
|
109
|
+
writeHookFile(plugin.hooksDir, "stable");
|
|
110
|
+
|
|
111
|
+
const first = await collect(plugin.dir, plugin.name);
|
|
112
|
+
const second = await collect(plugin.dir, plugin.name);
|
|
113
|
+
|
|
114
|
+
// Same function instance means the cache hit — dispatch after dispatch
|
|
115
|
+
// costs a stat, not an import.
|
|
116
|
+
expect(second[0]!.fn).toBe(first[0]!.fn);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("a deleted hook stops dispatching; recreating it serves the new content", async () => {
|
|
120
|
+
const plugin = makePlugin();
|
|
121
|
+
const path = writeHookFile(plugin.hooksDir, "original");
|
|
122
|
+
expect(await dispatchOne(plugin.dir, plugin.name)).toBe("original");
|
|
123
|
+
|
|
124
|
+
unlinkSync(path);
|
|
125
|
+
expect(await collect(plugin.dir, plugin.name)).toHaveLength(0);
|
|
126
|
+
|
|
127
|
+
// The recreated file must dispatch its own content, not the module the
|
|
128
|
+
// registry cached for the deleted original.
|
|
129
|
+
writeHookFile(plugin.hooksDir, "recreated");
|
|
130
|
+
expect(await dispatchOne(plugin.dir, plugin.name)).toBe("recreated");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("standalone workspace hooks reload the same way", async () => {
|
|
134
|
+
const wsHooksDir = getWorkspaceHooksDir();
|
|
135
|
+
mkdirSync(wsHooksDir, { recursive: true });
|
|
136
|
+
const path = writeHookFile(wsHooksDir, "ws-v1");
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const before = await collectUserHookEntries(HOOK_NAME, []);
|
|
140
|
+
expect(before).toHaveLength(1);
|
|
141
|
+
expect(before[0]!.owner.kind).toBe("workspace");
|
|
142
|
+
expect((before[0]!.fn as () => unknown)()).toBe("ws-v1");
|
|
143
|
+
|
|
144
|
+
writeHookFile(wsHooksDir, "ws-v2");
|
|
145
|
+
const after = await collectUserHookEntries(HOOK_NAME, []);
|
|
146
|
+
expect((after[0]!.fn as () => unknown)()).toBe("ws-v2");
|
|
147
|
+
} finally {
|
|
148
|
+
// The workspace hooks dir is shared per-process test state — leave it
|
|
149
|
+
// the way this test found it.
|
|
150
|
+
unlinkSync(path);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
package/src/hooks/hook-loader.ts
CHANGED
|
@@ -37,7 +37,11 @@ import type {
|
|
|
37
37
|
ShutdownContext,
|
|
38
38
|
} from "../plugin-api/types.js";
|
|
39
39
|
import { listSurfaceDir } from "../plugins/external-plugin-loader.js";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
evictModule,
|
|
42
|
+
getMtime,
|
|
43
|
+
importWithTimeout,
|
|
44
|
+
} from "../plugins/surface-import.js";
|
|
41
45
|
import type { HookEntry } from "../plugins/types.js";
|
|
42
46
|
import { getLogger } from "../util/logger.js";
|
|
43
47
|
import { getWorkspaceDir, getWorkspaceHooksDir } from "../util/platform.js";
|
|
@@ -112,6 +116,18 @@ async function resolveCachedHook<TCtx>(
|
|
|
112
116
|
return undefined;
|
|
113
117
|
}
|
|
114
118
|
|
|
119
|
+
// The file is new, recreated, or edited since it was last imported. Evict
|
|
120
|
+
// it from the runtime module registry first: without this, the import
|
|
121
|
+
// below would return the module Bun cached at the old content and the
|
|
122
|
+
// edit would never take effect. No-op for a first-ever load.
|
|
123
|
+
//
|
|
124
|
+
// Note the reload swaps only the exported function for future dispatches.
|
|
125
|
+
// The hook file's top-level code runs again on re-import, and nothing
|
|
126
|
+
// tears down the previous instance — so hook files must keep long-lived
|
|
127
|
+
// side effects (timers, listeners) in the owner's `init`/`shutdown`
|
|
128
|
+
// lifecycle, not at module top level.
|
|
129
|
+
evictModule(filePath);
|
|
130
|
+
|
|
115
131
|
try {
|
|
116
132
|
const hook = await importWithTimeout<HookFunction>(filePath);
|
|
117
133
|
if (hook === undefined || typeof hook !== "function") {
|
|
@@ -142,9 +158,12 @@ async function resolveCachedHook<TCtx>(
|
|
|
142
158
|
* a plugin can shape the threaded context before a workspace-wide hook
|
|
143
159
|
* observes or finalizes it.
|
|
144
160
|
*
|
|
145
|
-
* Added and
|
|
146
|
-
*
|
|
147
|
-
*
|
|
161
|
+
* Added, removed, and edited hook files are all picked up live: discovery is
|
|
162
|
+
* by directory listing, and a changed source mtime evicts the module from
|
|
163
|
+
* the runtime registry before re-import, so the edited hook takes effect on
|
|
164
|
+
* the next dispatch without a daemon restart. Eviction is per-file — an edit
|
|
165
|
+
* to a helper module a hook imports is not detected (the hook file's own
|
|
166
|
+
* mtime is what's watched) and still needs a restart.
|
|
148
167
|
*
|
|
149
168
|
* `effectiveEnabledPlugins` carries the per-chat plugin scope: when non-null, a
|
|
150
169
|
* plugin whose name is not in the set is skipped (its hooks do not run for this
|
|
@@ -168,7 +187,9 @@ export async function collectUserHookEntries<TCtx = unknown>(
|
|
|
168
187
|
const hookFile = listSurfaceDir(join(pluginDir, "hooks")).find(
|
|
169
188
|
(f) => f.name === hookName,
|
|
170
189
|
);
|
|
171
|
-
if (hookFile === undefined)
|
|
190
|
+
if (hookFile === undefined) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
172
193
|
|
|
173
194
|
const hook = await resolveCachedHook<TCtx>(
|
|
174
195
|
pluginName,
|
|
@@ -233,7 +254,14 @@ export async function preImportHooksDir(
|
|
|
233
254
|
for (const file of listSurfaceDir(hooksDir)) {
|
|
234
255
|
const key = hookKey(ownerName, file.name);
|
|
235
256
|
const currentMtime = getMtime(file.path);
|
|
236
|
-
if (currentMtime === 0)
|
|
257
|
+
if (currentMtime === 0) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// A plugin directory can be removed and reinstalled while the daemon
|
|
262
|
+
// runs; evict any module cached from the prior install so the
|
|
263
|
+
// pre-import reflects what's on disk now. No-op at boot.
|
|
264
|
+
evictModule(file.path);
|
|
237
265
|
|
|
238
266
|
try {
|
|
239
267
|
const hook = await importWithTimeout<HookFunction>(file.path);
|
|
@@ -293,7 +321,9 @@ export async function runInitHook(
|
|
|
293
321
|
pluginDir: string | null = null,
|
|
294
322
|
): Promise<void> {
|
|
295
323
|
const initHookEntry = hookCache.get(hookKey(ownerName, HOOKS.INIT));
|
|
296
|
-
if (initHookEntry === undefined)
|
|
324
|
+
if (initHookEntry === undefined) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
297
327
|
|
|
298
328
|
try {
|
|
299
329
|
const initContext: InitContext = {
|
|
@@ -323,7 +353,9 @@ export async function runShutdownHook(
|
|
|
323
353
|
reason: string,
|
|
324
354
|
): Promise<void> {
|
|
325
355
|
const shutdownHookEntry = hookCache.get(hookKey(ownerName, HOOKS.SHUTDOWN));
|
|
326
|
-
if (shutdownHookEntry === undefined)
|
|
356
|
+
if (shutdownHookEntry === undefined) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
327
359
|
|
|
328
360
|
try {
|
|
329
361
|
await shutdownHookEntry.hook(context);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the monitor-side read of in-flight conversations. A minimal
|
|
3
|
+
* conversations table (the columns the query touches) is created in the
|
|
4
|
+
* per-test workspace database via a separate writer connection; the module
|
|
5
|
+
* under test reads it through its own read-only connection.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdirSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { Database } from "bun:sqlite";
|
|
11
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import { getDbPath } from "../../util/platform.js";
|
|
14
|
+
import { readActiveConversations } from "../active-conversations.js";
|
|
15
|
+
|
|
16
|
+
function openWriter(): Database {
|
|
17
|
+
mkdirSync(dirname(getDbPath()), { recursive: true });
|
|
18
|
+
const db = new Database(getDbPath());
|
|
19
|
+
db.run(
|
|
20
|
+
`CREATE TABLE IF NOT EXISTS conversations (
|
|
21
|
+
id TEXT PRIMARY KEY,
|
|
22
|
+
title TEXT,
|
|
23
|
+
origin_channel TEXT,
|
|
24
|
+
origin_interface TEXT,
|
|
25
|
+
processing_started_at INTEGER
|
|
26
|
+
)`,
|
|
27
|
+
);
|
|
28
|
+
return db;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
const db = openWriter();
|
|
33
|
+
db.run("DELETE FROM conversations");
|
|
34
|
+
db.close();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("readActiveConversations", () => {
|
|
38
|
+
test("returns processing conversations, longest-running first", () => {
|
|
39
|
+
const db = openWriter();
|
|
40
|
+
db.run(
|
|
41
|
+
"INSERT INTO conversations (id, title, origin_channel, origin_interface, processing_started_at) VALUES (?, ?, ?, ?, ?)",
|
|
42
|
+
["conv-new", "Newer turn", "slack", "web", 2_000],
|
|
43
|
+
);
|
|
44
|
+
db.run(
|
|
45
|
+
"INSERT INTO conversations (id, title, origin_channel, origin_interface, processing_started_at) VALUES (?, ?, ?, ?, ?)",
|
|
46
|
+
["conv-old", "Memory consolidation", null, null, 1_000],
|
|
47
|
+
);
|
|
48
|
+
db.run(
|
|
49
|
+
"INSERT INTO conversations (id, title, origin_channel, origin_interface, processing_started_at) VALUES (?, ?, ?, ?, ?)",
|
|
50
|
+
["conv-idle", "Finished turn", null, null, null],
|
|
51
|
+
);
|
|
52
|
+
db.close();
|
|
53
|
+
|
|
54
|
+
expect(readActiveConversations()).toEqual([
|
|
55
|
+
{
|
|
56
|
+
conversationId: "conv-old",
|
|
57
|
+
title: "Memory consolidation",
|
|
58
|
+
originChannel: null,
|
|
59
|
+
originInterface: null,
|
|
60
|
+
processingStartedAt: 1_000,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
conversationId: "conv-new",
|
|
64
|
+
title: "Newer turn",
|
|
65
|
+
originChannel: "slack",
|
|
66
|
+
originInterface: "web",
|
|
67
|
+
processingStartedAt: 2_000,
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("truncates long titles", () => {
|
|
73
|
+
const db = openWriter();
|
|
74
|
+
db.run(
|
|
75
|
+
"INSERT INTO conversations (id, title, processing_started_at) VALUES (?, ?, ?)",
|
|
76
|
+
["conv-long", "x".repeat(500), 1_000],
|
|
77
|
+
);
|
|
78
|
+
db.close();
|
|
79
|
+
|
|
80
|
+
const active = readActiveConversations();
|
|
81
|
+
expect(active![0].title).toHaveLength(80);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("returns null when nothing is processing", () => {
|
|
85
|
+
expect(readActiveConversations()).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|