@agentlip/hub 0.1.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.
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Linkifier plugin derived pipeline for @agentlip/hub
3
+ *
4
+ * Implements bd-16d.4.5 (linkifier plugins → enrichments + message.enriched).
5
+ *
6
+ * Core workflow:
7
+ * 1. Read message state and capture snapshot
8
+ * 2. Execute enabled linkifier plugins via runPlugin()
9
+ * 3. Commit results with staleness guard:
10
+ * - Insert enrichment rows into DB
11
+ * - Emit message.enriched events
12
+ * - Guarded by derived staleness helper (bd-16d.4.7)
13
+ *
14
+ * Safety:
15
+ * - Staleness protection prevents committing results from stale content
16
+ * - Plugins are isolated in Workers with timeout enforcement
17
+ * - Circuit breaker skips repeatedly failing plugins
18
+ * - Plugin failures do not abort pipeline (continue to next plugin)
19
+ *
20
+ * Usage Example:
21
+ * ```typescript
22
+ * import { runLinkifierPluginsForMessage } from "@agentlip/hub/src/linkifierDerived";
23
+ *
24
+ * const eventIds = await runLinkifierPluginsForMessage({
25
+ * db,
26
+ * workspaceRoot: "/path/to/workspace",
27
+ * workspaceConfig: config,
28
+ * messageId: "msg_123",
29
+ * });
30
+ *
31
+ * console.log(`Emitted ${eventIds.length} message.enriched events`);
32
+ * ```
33
+ */
34
+
35
+ import type { Database } from "bun:sqlite";
36
+ import { randomUUID } from "node:crypto";
37
+ import type { WorkspaceConfig } from "./config";
38
+ import { validatePluginModulePath } from "./config";
39
+ import { runPlugin } from "./pluginRuntime";
40
+ import type { Enrichment, MessageInput } from "./pluginRuntime";
41
+ import { withMessageStalenessGuard, captureSnapshot } from "./derivedStaleness";
42
+ import type { CurrentMessageState } from "./derivedStaleness";
43
+ import { insertEvent } from "@agentlip/kernel";
44
+
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+ // Types
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ export interface RunLinkifierPluginsOptions {
50
+ /** Database instance */
51
+ db: Database;
52
+
53
+ /** Workspace root directory (absolute) */
54
+ workspaceRoot: string;
55
+
56
+ /** Workspace configuration (plugins, defaults, etc.) */
57
+ workspaceConfig: WorkspaceConfig;
58
+
59
+ /** Message ID to process */
60
+ messageId: string;
61
+
62
+ /**
63
+ * Callback to receive event IDs as they are emitted.
64
+ * Useful for WS broadcast or testing.
65
+ */
66
+ onEventIds?: (eventIds: number[]) => void;
67
+ }
68
+
69
+ /**
70
+ * Result of plugin execution for a single plugin
71
+ */
72
+ interface PluginExecutionResult {
73
+ /** Plugin name */
74
+ pluginName: string;
75
+
76
+ /** Success flag */
77
+ ok: boolean;
78
+
79
+ /** Event IDs emitted (if successful) */
80
+ eventIds?: number[];
81
+
82
+ /** Error message (if failed) */
83
+ error?: string;
84
+ }
85
+
86
+ // ─────────────────────────────────────────────────────────────────────────────
87
+ // Main Entry Point
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Execute linkifier plugins for a message and commit enrichments with staleness protection.
92
+ *
93
+ * Algorithm:
94
+ * 1. Read message row from DB
95
+ * 2. Capture message snapshot (id, content_raw, version)
96
+ * 3. For each enabled linkifier plugin:
97
+ * a. Resolve plugin module path (relative to workspaceRoot)
98
+ * b. Execute plugin via runPlugin() (Worker-isolated, timeout enforced)
99
+ * c. If plugin succeeds and returns enrichments:
100
+ * - Commit in transaction with staleness guard:
101
+ * - Verify message unchanged (content_raw, version, not deleted)
102
+ * - Insert enrichment rows
103
+ * - Emit message.enriched event
104
+ * d. If plugin fails: log and continue (do not abort pipeline)
105
+ * 4. Return all emitted event IDs
106
+ *
107
+ * Staleness protection:
108
+ * - If message changed during plugin execution, discard results (no DB commit, no event)
109
+ * - See bd-16d.4.7 (derivedStaleness.ts) for staleness guard implementation
110
+ *
111
+ * @param options - Pipeline options
112
+ * @returns Array of event IDs emitted across all plugins
113
+ */
114
+ export async function runLinkifierPluginsForMessage(
115
+ options: RunLinkifierPluginsOptions
116
+ ): Promise<number[]> {
117
+ const { db, workspaceRoot, workspaceConfig, messageId, onEventIds } = options;
118
+
119
+ // 1. Read message and capture snapshot
120
+ const message = getMessageById(db, messageId);
121
+ if (!message) {
122
+ console.warn(`[linkifierDerived] Message ${messageId} not found, skipping`);
123
+ return [];
124
+ }
125
+
126
+ // Skip deleted messages
127
+ if (message.deleted_at !== null) {
128
+ console.log(`[linkifierDerived] Message ${messageId} is deleted, skipping`);
129
+ return [];
130
+ }
131
+
132
+ const snapshot = captureSnapshot(message);
133
+
134
+ // 2. Get enabled linkifier plugins
135
+ const linkifierPlugins = (workspaceConfig.plugins ?? []).filter(
136
+ (p) => p.type === "linkifier" && p.enabled
137
+ );
138
+
139
+ if (linkifierPlugins.length === 0) {
140
+ console.log(`[linkifierDerived] No enabled linkifier plugins, skipping`);
141
+ return [];
142
+ }
143
+
144
+ console.log(
145
+ `[linkifierDerived] Processing message ${messageId} with ${linkifierPlugins.length} linkifier plugins`
146
+ );
147
+
148
+ // 3. Execute plugins and collect results
149
+ const results: PluginExecutionResult[] = [];
150
+ const allEventIds: number[] = [];
151
+
152
+ for (const plugin of linkifierPlugins) {
153
+ const result = await executePluginForMessage({
154
+ db,
155
+ workspaceRoot,
156
+ workspaceConfig,
157
+ message,
158
+ snapshot,
159
+ plugin,
160
+ });
161
+
162
+ results.push(result);
163
+
164
+ if (result.ok && result.eventIds) {
165
+ allEventIds.push(...result.eventIds);
166
+ }
167
+ }
168
+
169
+ // 4. Notify callback if provided
170
+ if (onEventIds && allEventIds.length > 0) {
171
+ onEventIds(allEventIds);
172
+ }
173
+
174
+ // Log summary
175
+ const successCount = results.filter((r) => r.ok).length;
176
+ const failureCount = results.filter((r) => !r.ok).length;
177
+
178
+ console.log(
179
+ `[linkifierDerived] Completed: ${successCount} success, ${failureCount} failed, ${allEventIds.length} events emitted`
180
+ );
181
+
182
+ return allEventIds;
183
+ }
184
+
185
+ // ─────────────────────────────────────────────────────────────────────────────
186
+ // Plugin Execution
187
+ // ─────────────────────────────────────────────────────────────────────────────
188
+
189
+ interface ExecutePluginOptions {
190
+ db: Database;
191
+ workspaceRoot: string;
192
+ workspaceConfig: WorkspaceConfig;
193
+ message: CurrentMessageState;
194
+ snapshot: ReturnType<typeof captureSnapshot>;
195
+ plugin: NonNullable<WorkspaceConfig["plugins"]>[number];
196
+ }
197
+
198
+ /**
199
+ * Execute a single linkifier plugin and commit results with staleness guard.
200
+ */
201
+ async function executePluginForMessage(
202
+ options: ExecutePluginOptions
203
+ ): Promise<PluginExecutionResult> {
204
+ const { db, workspaceRoot, workspaceConfig, message, snapshot, plugin } = options;
205
+
206
+ // Resolve plugin module path
207
+ let modulePath: string;
208
+ try {
209
+ if (plugin.module) {
210
+ modulePath = validatePluginModulePath(plugin.module, workspaceRoot);
211
+ } else {
212
+ // Built-in plugin - skip for now (v1 requires explicit module path)
213
+ console.warn(
214
+ `[linkifierDerived] Plugin '${plugin.name}' has no module path, skipping`
215
+ );
216
+ return {
217
+ pluginName: plugin.name,
218
+ ok: false,
219
+ error: "No module path specified (built-in plugins not implemented)",
220
+ };
221
+ }
222
+ } catch (err: any) {
223
+ console.error(
224
+ `[linkifierDerived] Plugin '${plugin.name}' module path validation failed: ${err.message}`
225
+ );
226
+ return {
227
+ pluginName: plugin.name,
228
+ ok: false,
229
+ error: `Module path validation failed: ${err.message}`,
230
+ };
231
+ }
232
+
233
+ // Execute plugin
234
+ const pluginInput: MessageInput = {
235
+ id: message.id,
236
+ content_raw: message.content_raw,
237
+ sender: message.sender,
238
+ topic_id: message.topic_id,
239
+ channel_id: message.channel_id,
240
+ created_at: message.created_at,
241
+ };
242
+
243
+ const timeoutMs = workspaceConfig.pluginDefaults?.timeout ?? 5000;
244
+
245
+ console.log(`[linkifierDerived] Executing plugin '${plugin.name}' for message ${message.id}`);
246
+
247
+ const pluginResult = await runPlugin<Enrichment[]>({
248
+ type: "linkifier",
249
+ modulePath,
250
+ input: {
251
+ message: pluginInput,
252
+ config: plugin.config ?? {},
253
+ },
254
+ timeoutMs,
255
+ pluginName: plugin.name,
256
+ });
257
+
258
+ // Handle plugin failure
259
+ if (!pluginResult.ok) {
260
+ console.warn(
261
+ `[linkifierDerived] Plugin '${plugin.name}' failed: ${pluginResult.error} (${pluginResult.code})`
262
+ );
263
+ return {
264
+ pluginName: plugin.name,
265
+ ok: false,
266
+ error: `${pluginResult.code}: ${pluginResult.error}`,
267
+ };
268
+ }
269
+
270
+ // Handle empty output (success, but nothing to commit)
271
+ if (!pluginResult.data || pluginResult.data.length === 0) {
272
+ console.log(`[linkifierDerived] Plugin '${plugin.name}' returned no enrichments`);
273
+ return {
274
+ pluginName: plugin.name,
275
+ ok: true,
276
+ eventIds: [],
277
+ };
278
+ }
279
+
280
+ console.log(
281
+ `[linkifierDerived] Plugin '${plugin.name}' returned ${pluginResult.data.length} enrichments`
282
+ );
283
+
284
+ // Commit with staleness guard
285
+ let guardResult: any;
286
+ try {
287
+ guardResult = withMessageStalenessGuard(db, snapshot, (current) => {
288
+ const enrichmentIds: string[] = [];
289
+ const eventIds: number[] = [];
290
+
291
+ // Insert enrichment rows
292
+ for (const enrichment of pluginResult.data) {
293
+ const enrichmentId = insertEnrichment(db, {
294
+ messageId: current.id,
295
+ kind: enrichment.kind,
296
+ spanStart: enrichment.span.start,
297
+ spanEnd: enrichment.span.end,
298
+ dataJson: enrichment.data,
299
+ });
300
+
301
+ enrichmentIds.push(enrichmentId);
302
+ }
303
+
304
+ // Emit single message.enriched event for all enrichments from this plugin
305
+ const eventId = insertEvent({
306
+ db,
307
+ name: "message.enriched",
308
+ scopes: {
309
+ channel_id: current.channel_id,
310
+ topic_id: current.topic_id,
311
+ },
312
+ entity: {
313
+ type: "message",
314
+ id: current.id,
315
+ },
316
+ data: {
317
+ message_id: current.id,
318
+ plugin_name: plugin.name,
319
+ enrichments: pluginResult.data,
320
+ enrichment_ids: enrichmentIds,
321
+ },
322
+ });
323
+
324
+ eventIds.push(eventId);
325
+
326
+ return { enrichmentIds, eventIds };
327
+ });
328
+ } catch (err: any) {
329
+ console.warn(
330
+ `[linkifierDerived] Failed to commit enrichments from plugin '${plugin.name}': ${err?.message ?? String(err)}`
331
+ );
332
+ return {
333
+ pluginName: plugin.name,
334
+ ok: false,
335
+ error: `COMMIT_FAILED: ${err?.message ?? String(err)}`,
336
+ };
337
+ }
338
+
339
+ // Handle staleness
340
+ if (!guardResult.ok) {
341
+ console.warn(
342
+ `[linkifierDerived] Discarded stale enrichments from plugin '${plugin.name}': ${guardResult.reason} - ${guardResult.detail}`
343
+ );
344
+ return {
345
+ pluginName: plugin.name,
346
+ ok: false,
347
+ error: `Staleness detected: ${guardResult.reason}`,
348
+ };
349
+ }
350
+
351
+ console.log(
352
+ `[linkifierDerived] Plugin '${plugin.name}' committed ${guardResult.value.enrichmentIds.length} enrichments, emitted event ${guardResult.value.eventIds[0]}`
353
+ );
354
+
355
+ return {
356
+ pluginName: plugin.name,
357
+ ok: true,
358
+ eventIds: guardResult.value.eventIds,
359
+ };
360
+ }
361
+
362
+ // ─────────────────────────────────────────────────────────────────────────────
363
+ // Database Helpers
364
+ // ─────────────────────────────────────────────────────────────────────────────
365
+
366
+ /**
367
+ * Get a message by ID, returning all fields needed for plugin input + staleness verification.
368
+ */
369
+ function getMessageById(db: Database, messageId: string): CurrentMessageState | null {
370
+ const row = db
371
+ .query<CurrentMessageState, [string]>(`
372
+ SELECT id, topic_id, channel_id, sender, content_raw, version,
373
+ created_at, edited_at, deleted_at, deleted_by
374
+ FROM messages
375
+ WHERE id = ?
376
+ `)
377
+ .get(messageId);
378
+
379
+ return row ?? null;
380
+ }
381
+
382
+ /**
383
+ * Insert an enrichment row into the database.
384
+ */
385
+ function insertEnrichment(
386
+ db: Database,
387
+ options: {
388
+ messageId: string;
389
+ kind: string;
390
+ spanStart: number;
391
+ spanEnd: number;
392
+ dataJson: Record<string, unknown>;
393
+ }
394
+ ): string {
395
+ const { messageId, kind, spanStart, spanEnd, dataJson } = options;
396
+
397
+ const enrichmentId = randomUUID();
398
+ const createdAt = new Date().toISOString();
399
+ const dataJsonStr = JSON.stringify(dataJson);
400
+
401
+ db.prepare(`
402
+ INSERT INTO enrichments (id, message_id, kind, span_start, span_end, data_json, created_at)
403
+ VALUES (?, ?, ?, ?, ?, ?, ?)
404
+ `).run(enrichmentId, messageId, kind, spanStart, spanEnd, dataJsonStr, createdAt);
405
+
406
+ return enrichmentId;
407
+ }
package/src/lock.ts ADDED
@@ -0,0 +1,172 @@
1
+ import { mkdir, writeFile, readFile, unlink, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ServerJsonData } from "./serverJson.js";
4
+ import { readServerJson } from "./serverJson.js";
5
+
6
+ export interface HealthCheckFn {
7
+ (serverJson: ServerJsonData): Promise<boolean>;
8
+ }
9
+
10
+ /**
11
+ * Acquire writer lock for the workspace.
12
+ *
13
+ * Lock path: .agentlip/locks/writer.lock
14
+ *
15
+ * If lock exists:
16
+ * - Reads server.json to get port/instance info
17
+ * - Calls healthCheck(serverJson) to verify hub is alive
18
+ * - If alive: throws error (lock held by live hub)
19
+ * - If stale: removes lock and retries
20
+ *
21
+ * @param healthCheck - Async function that validates hub liveness via /health
22
+ */
23
+ export async function acquireWriterLock({
24
+ workspaceRoot,
25
+ healthCheck,
26
+ }: {
27
+ workspaceRoot: string;
28
+ healthCheck: HealthCheckFn;
29
+ }): Promise<void> {
30
+ const locksDir = join(workspaceRoot, ".agentlip", "locks");
31
+ const lockPath = join(locksDir, "writer.lock");
32
+
33
+ // Ensure locks directory exists
34
+ await mkdir(locksDir, { recursive: true });
35
+
36
+ const maxRetries = 3;
37
+ let attempt = 0;
38
+
39
+ while (attempt < maxRetries) {
40
+ attempt++;
41
+
42
+ try {
43
+ // Try to create lock file exclusively
44
+ await writeFile(lockPath, `${process.pid}\n${new Date().toISOString()}`, {
45
+ flag: "wx", // exclusive create; fails if exists
46
+ });
47
+
48
+ // Success!
49
+ return;
50
+ } catch (error: any) {
51
+ if (error.code !== "EEXIST") {
52
+ // Unexpected error
53
+ throw error;
54
+ }
55
+
56
+ // Lock exists; check if stale
57
+ const isStale = await isLockStale({ workspaceRoot, healthCheck });
58
+
59
+ if (!isStale) {
60
+ throw new Error(
61
+ "Writer lock already held by live hub. Cannot start another hub instance."
62
+ );
63
+ }
64
+
65
+ // Lock is stale; remove it and retry
66
+ console.warn(`Removing stale lock at ${lockPath}`);
67
+ try {
68
+ await unlink(lockPath);
69
+ } catch (unlinkError: any) {
70
+ if (unlinkError.code !== "ENOENT") {
71
+ throw unlinkError;
72
+ }
73
+ // Already removed (race); retry
74
+ }
75
+
76
+ // Retry acquisition
77
+ if (attempt < maxRetries) {
78
+ // Brief delay to avoid tight loop in case of races
79
+ await new Promise((resolve) => setTimeout(resolve, 100));
80
+ }
81
+ }
82
+ }
83
+
84
+ throw new Error(
85
+ `Failed to acquire writer lock after ${maxRetries} attempts. ` +
86
+ `Lock may be held by another process or filesystem issues.`
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Check if existing lock is stale.
92
+ *
93
+ * Strategy:
94
+ * 1. Read server.json to get hub instance info (port, pid, instance_id)
95
+ * 2. Call healthCheck(serverJson) to validate via /health endpoint
96
+ * 3. If healthCheck returns true: lock is live (not stale)
97
+ * 4. If healthCheck returns false or throws: lock is stale
98
+ *
99
+ * Additional heuristics (future):
100
+ * - Check if PID from server.json is still running (platform-specific)
101
+ * - Check lock file age (stale if >1 hour?)
102
+ */
103
+ async function isLockStale({
104
+ workspaceRoot,
105
+ healthCheck,
106
+ }: {
107
+ workspaceRoot: string;
108
+ healthCheck: HealthCheckFn;
109
+ }): Promise<boolean> {
110
+ try {
111
+ // Try to read server.json
112
+ const serverJson = await readServerJson({ workspaceRoot });
113
+
114
+ if (!serverJson) {
115
+ // No server.json; lock is definitely stale
116
+ return true;
117
+ }
118
+
119
+ // Check if hub is responsive via health check
120
+ const isAlive = await healthCheck(serverJson);
121
+
122
+ // If alive, lock is not stale
123
+ return !isAlive;
124
+ } catch (error) {
125
+ // Any error reading server.json or health check means stale
126
+ console.warn(`Lock staleness check error: ${error}`);
127
+ return true;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Release writer lock.
133
+ * No-op if lock doesn't exist.
134
+ */
135
+ export async function releaseWriterLock({
136
+ workspaceRoot,
137
+ }: {
138
+ workspaceRoot: string;
139
+ }): Promise<void> {
140
+ const lockPath = join(workspaceRoot, ".agentlip", "locks", "writer.lock");
141
+
142
+ try {
143
+ await unlink(lockPath);
144
+ } catch (error: any) {
145
+ if (error.code === "ENOENT") {
146
+ // Already gone
147
+ return;
148
+ }
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Read the current writer lock file content (for debugging).
155
+ * Returns null if lock doesn't exist.
156
+ */
157
+ export async function readLockInfo({
158
+ workspaceRoot,
159
+ }: {
160
+ workspaceRoot: string;
161
+ }): Promise<string | null> {
162
+ const lockPath = join(workspaceRoot, ".agentlip", "locks", "writer.lock");
163
+
164
+ try {
165
+ return await readFile(lockPath, "utf-8");
166
+ } catch (error: any) {
167
+ if (error.code === "ENOENT") {
168
+ return null;
169
+ }
170
+ throw error;
171
+ }
172
+ }