@hotmeshio/long-tail 0.2.2 → 0.2.4
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/build/lib/db/migrate.js +31 -19
- package/build/services/cron/index.js +33 -23
- package/build/services/iam/bots.js +18 -7
- package/build/start/workers.js +24 -17
- package/package.json +2 -2
package/build/lib/db/migrate.js
CHANGED
|
@@ -43,27 +43,39 @@ const logger_1 = require("../logger");
|
|
|
43
43
|
const SCHEMAS_DIR = path.join(__dirname, 'schemas');
|
|
44
44
|
async function migrate() {
|
|
45
45
|
const pool = (0, index_1.getPool)();
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
46
|
+
// Advisory lock prevents concurrent containers from racing on migrations.
|
|
47
|
+
// Uses a dedicated client so the lock is held for the entire sequence.
|
|
48
|
+
const client = await pool.connect();
|
|
49
|
+
try {
|
|
50
|
+
await client.query('SELECT pg_advisory_lock(8675309)');
|
|
51
|
+
// ensure migration tracking table
|
|
52
|
+
await client.query(`
|
|
53
|
+
CREATE TABLE IF NOT EXISTS lt_migrations (
|
|
54
|
+
id SERIAL PRIMARY KEY,
|
|
55
|
+
name TEXT NOT NULL UNIQUE,
|
|
56
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
57
|
+
);
|
|
58
|
+
`);
|
|
59
|
+
// find and sort migration files
|
|
60
|
+
const files = fs.readdirSync(SCHEMAS_DIR)
|
|
61
|
+
.filter(f => f.endsWith('.sql'))
|
|
62
|
+
.sort();
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
const { rows } = await client.query('SELECT 1 FROM lt_migrations WHERE name = $1', [file]);
|
|
65
|
+
if (rows.length === 0) {
|
|
66
|
+
const sql = fs.readFileSync(path.join(SCHEMAS_DIR, file), 'utf-8');
|
|
67
|
+
await client.query(sql);
|
|
68
|
+
await client.query('INSERT INTO lt_migrations (name) VALUES ($1)', [file]);
|
|
69
|
+
logger_1.loggerRegistry.info(`[migrate] applied: ${file}`);
|
|
70
|
+
}
|
|
65
71
|
}
|
|
66
72
|
}
|
|
73
|
+
finally {
|
|
74
|
+
// Advisory lock released when client is released (session-scoped),
|
|
75
|
+
// but release explicitly for clarity
|
|
76
|
+
await client.query('SELECT pg_advisory_unlock(8675309)').catch(() => { });
|
|
77
|
+
client.release();
|
|
78
|
+
}
|
|
67
79
|
}
|
|
68
80
|
// run directly: npx ts-node lib/db/migrate.ts
|
|
69
81
|
if (require.main === module) {
|
|
@@ -148,7 +148,8 @@ class LTCronRegistry {
|
|
|
148
148
|
callback: async () => {
|
|
149
149
|
try {
|
|
150
150
|
const client = new hotmesh_1.Durable.Client({ connection });
|
|
151
|
-
const
|
|
151
|
+
const { guid } = hotmesh_1.Virtual.getContext();
|
|
152
|
+
const workflowId = `${workflowType}-cron-${guid}`;
|
|
152
153
|
logger_1.loggerRegistry.info(`[lt-cron] invoking ${workflowType} (${workflowId})`);
|
|
153
154
|
await client.workflow.start({
|
|
154
155
|
args: [defaultEnvelope],
|
|
@@ -302,27 +303,8 @@ class LTCronRegistry {
|
|
|
302
303
|
const topic = `lt.cron.agent.${agent.id}.${idx}`;
|
|
303
304
|
const cronId = `lt-cron-agent-${agent.id}-${idx}`;
|
|
304
305
|
const executeAs = schedule.execute_as || agent.user_id || undefined;
|
|
305
|
-
let principal;
|
|
306
|
-
if (executeAs) {
|
|
307
|
-
try {
|
|
308
|
-
principal = await (0, principal_1.resolvePrincipal)(executeAs);
|
|
309
|
-
}
|
|
310
|
-
catch { /* use system */ }
|
|
311
|
-
}
|
|
312
|
-
if (!principal) {
|
|
313
|
-
principal = this.systemPrincipal ?? undefined;
|
|
314
|
-
}
|
|
315
|
-
const envelope = {
|
|
316
|
-
data: schedule.envelope ?? {},
|
|
317
|
-
metadata: { source: 'agent-cron', agentId: agent.id, agentName: agent.name, certified: true },
|
|
318
|
-
lt: {
|
|
319
|
-
userId: principal?.id ?? 'lt-system',
|
|
320
|
-
principal,
|
|
321
|
-
scopes: ['workflow:invoke'],
|
|
322
|
-
},
|
|
323
|
-
};
|
|
324
306
|
const isPipeline = schedule.reaction_type === 'pipeline' && schedule.pipeline_id;
|
|
325
|
-
// Resolve
|
|
307
|
+
// Resolve task queue at arm time (static — doesn't change between ticks)
|
|
326
308
|
let taskQueue;
|
|
327
309
|
if (!isPipeline) {
|
|
328
310
|
const wfConfig = await configService.getWorkflowConfig(schedule.workflow_type);
|
|
@@ -334,6 +316,27 @@ class LTCronRegistry {
|
|
|
334
316
|
connection,
|
|
335
317
|
callback: async () => {
|
|
336
318
|
try {
|
|
319
|
+
// Resolve principal at fire time so users seeded after startup are found
|
|
320
|
+
let principal;
|
|
321
|
+
if (executeAs) {
|
|
322
|
+
try {
|
|
323
|
+
principal = await (0, principal_1.resolvePrincipal)(executeAs);
|
|
324
|
+
}
|
|
325
|
+
catch { /* use system */ }
|
|
326
|
+
}
|
|
327
|
+
if (!principal) {
|
|
328
|
+
principal = this.systemPrincipal ?? undefined;
|
|
329
|
+
}
|
|
330
|
+
logger_1.loggerRegistry.info(`[lt-cron] agent ${agent.name} principal: ${principal?.id ?? 'NONE'} (executeAs=${executeAs ?? 'unset'})`);
|
|
331
|
+
const envelope = {
|
|
332
|
+
data: schedule.envelope ?? {},
|
|
333
|
+
metadata: { source: 'agent-cron', agentId: agent.id, agentName: agent.name, certified: true },
|
|
334
|
+
lt: {
|
|
335
|
+
userId: principal?.id ?? 'lt-system',
|
|
336
|
+
principal,
|
|
337
|
+
scopes: ['workflow:invoke'],
|
|
338
|
+
},
|
|
339
|
+
};
|
|
337
340
|
if (isPipeline) {
|
|
338
341
|
const { invokeYamlWorkflow } = await Promise.resolve().then(() => __importStar(require('../yaml-workflow/invoke')));
|
|
339
342
|
const { getYamlWorkflow } = await Promise.resolve().then(() => __importStar(require('../yaml-workflow/db')));
|
|
@@ -348,7 +351,8 @@ class LTCronRegistry {
|
|
|
348
351
|
}
|
|
349
352
|
else {
|
|
350
353
|
const client = new hotmesh_1.Durable.Client({ connection });
|
|
351
|
-
const
|
|
354
|
+
const { guid } = hotmesh_1.Virtual.getContext();
|
|
355
|
+
const workflowId = `agent-cron-${agent.id}-${idx}-${guid}`;
|
|
352
356
|
logger_1.loggerRegistry.info(`[lt-cron] agent invoking ${schedule.workflow_type} on ${taskQueue} (${workflowId})`);
|
|
353
357
|
await client.workflow.start({
|
|
354
358
|
args: [envelope],
|
|
@@ -362,7 +366,13 @@ class LTCronRegistry {
|
|
|
362
366
|
}
|
|
363
367
|
}
|
|
364
368
|
catch (err) {
|
|
365
|
-
|
|
369
|
+
const msg = err?.message ?? '';
|
|
370
|
+
if (msg.includes('Duplicate job')) {
|
|
371
|
+
// Expected — deterministic ID dedup when cron fires multiple consumers
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
logger_1.loggerRegistry.error(`[lt-cron] agent ${agent.name}/${targetLabel} failed: ${msg}`);
|
|
375
|
+
}
|
|
366
376
|
}
|
|
367
377
|
},
|
|
368
378
|
args: [],
|
|
@@ -132,13 +132,24 @@ async function ensureSystemBot() {
|
|
|
132
132
|
const { rows } = await pool.query(sql_1.GET_USER_BY_EXTERNAL_ID, [SYSTEM_BOT_NAME]);
|
|
133
133
|
if (rows.length > 0)
|
|
134
134
|
return rows[0].id;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
135
|
+
try {
|
|
136
|
+
const bot = await createBot({
|
|
137
|
+
name: SYSTEM_BOT_NAME,
|
|
138
|
+
display_name: 'System',
|
|
139
|
+
description: 'System bot for cron and system-initiated workflows',
|
|
140
|
+
roles: [{ role: 'system', type: 'superadmin' }],
|
|
141
|
+
});
|
|
142
|
+
return bot.id;
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
// Concurrent container already created it — re-read
|
|
146
|
+
if (err.code === '23505') {
|
|
147
|
+
const { rows: retry } = await pool.query(sql_1.GET_USER_BY_EXTERNAL_ID, [SYSTEM_BOT_NAME]);
|
|
148
|
+
if (retry.length > 0)
|
|
149
|
+
return retry[0].id;
|
|
150
|
+
}
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
142
153
|
}
|
|
143
154
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
144
155
|
function toBotRecord(user, description, createdBy) {
|
package/build/start/workers.js
CHANGED
|
@@ -103,6 +103,10 @@ async function startWorkers(startConfig, workers, builtinMcpServerFactories) {
|
|
|
103
103
|
logger_1.loggerRegistry.info('[long-tail] running migrations...');
|
|
104
104
|
await (0, migrate_1.migrate)();
|
|
105
105
|
const connection = buildConnection();
|
|
106
|
+
// Readonly mode: all user-provided workers are observers — skip crons, triggers, and agent seeding.
|
|
107
|
+
// System workers (mcpQuery, etc.) are always added by collectWorkers, so check the original config.
|
|
108
|
+
const userWorkers = startConfig.workers ?? [];
|
|
109
|
+
const isReadonly = userWorkers.length > 0 && userWorkers.every((w) => w.connection?.readonly);
|
|
106
110
|
if (workers.length) {
|
|
107
111
|
// Connect telemetry before HotMesh starts
|
|
108
112
|
if (telemetry_1.telemetryRegistry.hasAdapter) {
|
|
@@ -165,13 +169,15 @@ async function startWorkers(startConfig, workers, builtinMcpServerFactories) {
|
|
|
165
169
|
}
|
|
166
170
|
ltConfig.invalidate();
|
|
167
171
|
}
|
|
168
|
-
// Start maintenance cron
|
|
169
|
-
if (maintenance_1.maintenanceRegistry.hasConfig) {
|
|
172
|
+
// Start maintenance cron (skip in readonly/API mode)
|
|
173
|
+
if (maintenance_1.maintenanceRegistry.hasConfig && !isReadonly) {
|
|
170
174
|
await maintenance_1.maintenanceRegistry.connect();
|
|
171
175
|
logger_1.loggerRegistry.info('[long-tail] maintenance cron started');
|
|
172
176
|
}
|
|
173
|
-
// Start workflow cron schedules
|
|
174
|
-
|
|
177
|
+
// Start workflow cron schedules (skip in readonly/API mode)
|
|
178
|
+
if (!isReadonly) {
|
|
179
|
+
await cron_1.cronRegistry.connect();
|
|
180
|
+
}
|
|
175
181
|
// Connect MCP adapter
|
|
176
182
|
if (mcp_1.mcpRegistry.hasAdapter) {
|
|
177
183
|
await mcp_1.mcpRegistry.connect();
|
|
@@ -281,19 +287,20 @@ async function startWorkers(startConfig, workers, builtinMcpServerFactories) {
|
|
|
281
287
|
await events_1.eventRegistry.connect();
|
|
282
288
|
logger_1.loggerRegistry.info('[long-tail] event adapters connected');
|
|
283
289
|
}
|
|
284
|
-
// Arm agent event subscriptions (
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
290
|
+
// Arm agent event subscriptions and crons (skip in readonly/API mode)
|
|
291
|
+
if (!isReadonly) {
|
|
292
|
+
try {
|
|
293
|
+
await agentTriggerRegistry.connect(callbackAdapter);
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
logger_1.loggerRegistry.warn(`[long-tail] agent trigger registry: ${err.message}`);
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
await cron_1.cronRegistry.connectAgentCrons();
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
logger_1.loggerRegistry.warn(`[long-tail] agent cron schedules: ${err.message}`);
|
|
303
|
+
}
|
|
297
304
|
}
|
|
298
305
|
// Ensure system bot account exists for cron/system-initiated workflows
|
|
299
306
|
const { ensureSystemBot } = await Promise.resolve().then(() => __importStar(require('../services/iam/bots')));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hotmeshio/long-tail",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Long Tail Workflows — Durable AI workflows with human-in-the-loop escalation. Powered by PostgreSQL.",
|
|
5
5
|
"main": "./build/index.js",
|
|
6
6
|
"types": "./build/index.d.ts",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"@anthropic-ai/sdk": "^0.92.0",
|
|
71
71
|
"@aws-sdk/client-s3": "^3.1017.0",
|
|
72
72
|
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
|
73
|
-
"@hotmeshio/hotmesh": "^0.16.
|
|
73
|
+
"@hotmeshio/hotmesh": "^0.16.1",
|
|
74
74
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
75
75
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.215.0",
|
|
76
76
|
"@opentelemetry/resources": "^2.5.1",
|