@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.
- package/README.md +126 -0
- package/package.json +36 -0
- package/src/agentlipd.ts +309 -0
- package/src/apiV1.ts +1468 -0
- package/src/authMiddleware.ts +134 -0
- package/src/authToken.ts +32 -0
- package/src/bodyParser.ts +272 -0
- package/src/config.ts +273 -0
- package/src/derivedStaleness.ts +255 -0
- package/src/extractorDerived.ts +374 -0
- package/src/index.ts +878 -0
- package/src/linkifierDerived.ts +407 -0
- package/src/lock.ts +172 -0
- package/src/pluginRuntime.ts +402 -0
- package/src/pluginWorker.ts +296 -0
- package/src/rateLimiter.ts +286 -0
- package/src/serverJson.ts +138 -0
- package/src/ui.ts +843 -0
- package/src/wsEndpoint.ts +481 -0
|
@@ -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
|
+
}
|