@hotmeshio/long-tail 0.5.1 → 0.5.3
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/adapters/express.d.ts +1 -0
- package/build/adapters/express.js +4 -0
- package/build/lib/events/system-events.d.ts +19 -0
- package/build/lib/events/system-events.js +62 -0
- package/build/modules/ltconfig.d.ts +8 -0
- package/build/modules/ltconfig.js +10 -0
- package/build/services/escalation/bulk.d.ts +2 -1
- package/build/services/escalation/bulk.js +20 -19
- package/build/services/escalation/client.d.ts +22 -0
- package/build/services/escalation/client.js +141 -0
- package/build/services/escalation/crud.d.ts +29 -21
- package/build/services/escalation/crud.js +175 -140
- package/build/services/escalation/index.d.ts +1 -0
- package/build/services/escalation/index.js +3 -0
- package/build/services/escalation/map.d.ts +15 -0
- package/build/services/escalation/map.js +63 -0
- package/build/services/escalation/queries.js +64 -149
- package/build/services/escalation/sql.d.ts +13 -32
- package/build/services/escalation/sql.js +36 -176
- package/build/services/export/post-process.js +23 -4
- package/build/services/interceptor/activities/config.js +5 -1
- package/build/services/interceptor/index.d.ts +3 -0
- package/build/services/interceptor/index.js +7 -21
- package/build/services/mcp/db-server/schemas.d.ts +1 -1
- package/build/services/yaml-workflow/deployer.js +4 -0
- package/build/services/yaml-workflow/workers/register.js +3 -0
- package/build/start/index.js +2 -1
- package/build/start/workers.js +12 -0
- package/build/system/mcp-servers/admin/schemas.d.ts +1 -1
- package/build/system/mcp-servers/db-query/schemas.d.ts +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
|
@@ -59,6 +59,7 @@ export declare class LTExpressAdapter {
|
|
|
59
59
|
/**
|
|
60
60
|
* Return a self-contained Express Router that serves:
|
|
61
61
|
* - `/api/*` — Long Tail API routes (auth, tasks, escalations, etc.)
|
|
62
|
+
* - `/mcp` — MCP streamable-HTTP transport (Claude Desktop, Cursor, agents)
|
|
62
63
|
* - `/health` — health check
|
|
63
64
|
* - Static dashboard assets
|
|
64
65
|
* - SPA fallback with injected `<base href>` and `window.__LT_BASE__`
|
|
@@ -41,6 +41,7 @@ const fs_1 = require("fs");
|
|
|
41
41
|
const path_1 = __importDefault(require("path"));
|
|
42
42
|
const express_1 = __importStar(require("express"));
|
|
43
43
|
const routes_1 = __importDefault(require("../routes"));
|
|
44
|
+
const mcp_endpoint_1 = __importDefault(require("../routes/mcp-endpoint"));
|
|
44
45
|
const events_1 = require("../lib/events");
|
|
45
46
|
const socketio_1 = require("../lib/events/socketio");
|
|
46
47
|
const nats_1 = require("../lib/events/nats");
|
|
@@ -134,6 +135,7 @@ class LTExpressAdapter {
|
|
|
134
135
|
/**
|
|
135
136
|
* Return a self-contained Express Router that serves:
|
|
136
137
|
* - `/api/*` — Long Tail API routes (auth, tasks, escalations, etc.)
|
|
138
|
+
* - `/mcp` — MCP streamable-HTTP transport (Claude Desktop, Cursor, agents)
|
|
137
139
|
* - `/health` — health check
|
|
138
140
|
* - Static dashboard assets
|
|
139
141
|
* - SPA fallback with injected `<base href>` and `window.__LT_BASE__`
|
|
@@ -147,6 +149,8 @@ class LTExpressAdapter {
|
|
|
147
149
|
});
|
|
148
150
|
// API routes — internal routes handle their own JWT auth
|
|
149
151
|
router.use('/api', routes_1.default);
|
|
152
|
+
// MCP streamable-HTTP transport — same endpoint as standalone server
|
|
153
|
+
router.use('/mcp', mcp_endpoint_1.default);
|
|
150
154
|
// Dashboard static assets
|
|
151
155
|
const dashboardDist = this.resolveDashboardDist();
|
|
152
156
|
if (dashboardDist) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Types } from '@hotmeshio/hotmesh';
|
|
2
|
+
import type { LTEvent } from '../../types';
|
|
3
|
+
type SystemEvent = Types.SystemEvent;
|
|
4
|
+
/**
|
|
5
|
+
* Translate a HotMesh `SystemEvent` into long-tail's `LTEvent`. Escalation
|
|
6
|
+
* events carry the full committed row in `data`, from which we lift the routing
|
|
7
|
+
* fields; engine/worker lifecycle events pass through with their canonical type.
|
|
8
|
+
*/
|
|
9
|
+
export declare function mapSystemEvent(event: SystemEvent): LTEvent;
|
|
10
|
+
/**
|
|
11
|
+
* The `EventsConfig.publish` hook long-tail wires into every worker/engine it
|
|
12
|
+
* constructs. Fire-and-forget — never throws back into the SDK's committed call.
|
|
13
|
+
*/
|
|
14
|
+
export declare function onSystemEvent(event: SystemEvent): void;
|
|
15
|
+
/** The EventsConfig long-tail passes to Durable.Client / Worker.create / HotMesh.init. */
|
|
16
|
+
export declare const systemEventsConfig: {
|
|
17
|
+
publish: typeof onSystemEvent;
|
|
18
|
+
};
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.systemEventsConfig = void 0;
|
|
4
|
+
exports.mapSystemEvent = mapSystemEvent;
|
|
5
|
+
exports.onSystemEvent = onSystemEvent;
|
|
6
|
+
const index_1 = require("./index");
|
|
7
|
+
/** Verb → long-tail event status, matching the manual publishEscalationEvent convention. */
|
|
8
|
+
const ESCALATION_STATUS_BY_VERB = {
|
|
9
|
+
created: 'pending',
|
|
10
|
+
claimed: 'claimed',
|
|
11
|
+
released: 'released',
|
|
12
|
+
reassigned: 'pending',
|
|
13
|
+
resolved: 'resolved',
|
|
14
|
+
cancelled: 'cancelled',
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Translate a HotMesh `SystemEvent` into long-tail's `LTEvent`. Escalation
|
|
18
|
+
* events carry the full committed row in `data`, from which we lift the routing
|
|
19
|
+
* fields; engine/worker lifecycle events pass through with their canonical type.
|
|
20
|
+
*/
|
|
21
|
+
function mapSystemEvent(event) {
|
|
22
|
+
const segments = event.type.split('.');
|
|
23
|
+
const domain = segments[1];
|
|
24
|
+
if (domain === 'escalation') {
|
|
25
|
+
const row = (event.data ?? {});
|
|
26
|
+
const verb = segments[3] ?? '';
|
|
27
|
+
return {
|
|
28
|
+
type: event.type,
|
|
29
|
+
source: 'sdk',
|
|
30
|
+
workflowId: row.workflow_id || event.workflow_id || '',
|
|
31
|
+
workflowName: row.workflow_type || '',
|
|
32
|
+
taskQueue: row.task_queue || '',
|
|
33
|
+
escalationId: row.id || segments[2],
|
|
34
|
+
originId: row.origin_id || event.origin_id || undefined,
|
|
35
|
+
status: ESCALATION_STATUS_BY_VERB[verb] ?? verb,
|
|
36
|
+
data: row,
|
|
37
|
+
timestamp: event.ts,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// Engine / worker lifecycle (system.engine.*, system.worker.*) — additive
|
|
41
|
+
// observability; pass through with the canonical type and metadata payload.
|
|
42
|
+
return {
|
|
43
|
+
type: event.type,
|
|
44
|
+
source: 'sdk',
|
|
45
|
+
workflowId: event.workflow_id || '',
|
|
46
|
+
workflowName: '',
|
|
47
|
+
taskQueue: event.data?.taskQueue || '',
|
|
48
|
+
data: event.data,
|
|
49
|
+
timestamp: event.ts,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The `EventsConfig.publish` hook long-tail wires into every worker/engine it
|
|
54
|
+
* constructs. Fire-and-forget — never throws back into the SDK's committed call.
|
|
55
|
+
*/
|
|
56
|
+
function onSystemEvent(event) {
|
|
57
|
+
if (!index_1.eventRegistry.hasAdapters)
|
|
58
|
+
return;
|
|
59
|
+
void index_1.eventRegistry.publish(mapSystemEvent(event)).catch(() => { });
|
|
60
|
+
}
|
|
61
|
+
/** The EventsConfig long-tail passes to Durable.Client / Worker.create / HotMesh.init. */
|
|
62
|
+
exports.systemEventsConfig = { publish: onSystemEvent };
|
|
@@ -23,6 +23,14 @@ declare class LTConfigCache {
|
|
|
23
23
|
* return null so the interceptor skips them.
|
|
24
24
|
*/
|
|
25
25
|
getResolvedConfig(name: string): Promise<LTResolvedConfig | null>;
|
|
26
|
+
/**
|
|
27
|
+
* Get the config for any workflow REGISTERED in lt_config_workflows (a row
|
|
28
|
+
* exists), regardless of certification (roles/consumes). The interceptor
|
|
29
|
+
* uses this to decide whether to apply task tracking, escalation handling,
|
|
30
|
+
* and orchestrator context — every registered workflow gets the full
|
|
31
|
+
* treatment; only unregistered ad-hoc durable workflows are skipped.
|
|
32
|
+
*/
|
|
33
|
+
getRegisteredConfig(name: string): Promise<LTResolvedConfig | null>;
|
|
26
34
|
}
|
|
27
35
|
export declare const ltConfig: LTConfigCache;
|
|
28
36
|
export {};
|
|
@@ -116,5 +116,15 @@ class LTConfigCache {
|
|
|
116
116
|
const isCertified = (config.roles?.length ?? 0) > 0 || (config.consumes?.length ?? 0) > 0;
|
|
117
117
|
return isCertified ? config : null;
|
|
118
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Get the config for any workflow REGISTERED in lt_config_workflows (a row
|
|
121
|
+
* exists), regardless of certification (roles/consumes). The interceptor
|
|
122
|
+
* uses this to decide whether to apply task tracking, escalation handling,
|
|
123
|
+
* and orchestrator context — every registered workflow gets the full
|
|
124
|
+
* treatment; only unregistered ad-hoc durable workflows are skipped.
|
|
125
|
+
*/
|
|
126
|
+
async getRegisteredConfig(name) {
|
|
127
|
+
return (await this.get(name)) ?? null;
|
|
128
|
+
}
|
|
119
129
|
}
|
|
120
130
|
exports.ltConfig = new LTConfigCache();
|
|
@@ -22,6 +22,7 @@ export declare function bulkAssignEscalations(ids: string[], targetUserId: strin
|
|
|
22
22
|
export declare function bulkEscalateToRole(ids: string[], targetRole: string): Promise<number>;
|
|
23
23
|
/**
|
|
24
24
|
* Bulk resolve escalations for AI triage.
|
|
25
|
-
* Returns full records so the caller can start triage workflows.
|
|
25
|
+
* Returns full records so the caller can start triage workflows. No signal is
|
|
26
|
+
* delivered — the triage workflow takes over handling.
|
|
26
27
|
*/
|
|
27
28
|
export declare function bulkResolveForTriage(ids: string[], hint?: string): Promise<LTEscalationRecord[]>;
|
|
@@ -4,8 +4,8 @@ exports.bulkClaimEscalations = bulkClaimEscalations;
|
|
|
4
4
|
exports.bulkAssignEscalations = bulkAssignEscalations;
|
|
5
5
|
exports.bulkEscalateToRole = bulkEscalateToRole;
|
|
6
6
|
exports.bulkResolveForTriage = bulkResolveForTriage;
|
|
7
|
-
const
|
|
8
|
-
const
|
|
7
|
+
const client_1 = require("./client");
|
|
8
|
+
const map_1 = require("./map");
|
|
9
9
|
/**
|
|
10
10
|
* Bulk claim escalations for a user.
|
|
11
11
|
* Items already claimed by another active user are skipped.
|
|
@@ -13,10 +13,8 @@ const sql_1 = require("./sql");
|
|
|
13
13
|
async function bulkClaimEscalations(ids, userId, durationMinutes = 30) {
|
|
14
14
|
if (ids.length === 0)
|
|
15
15
|
return { claimed: 0, skipped: 0 };
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
const claimed = rowCount ?? 0;
|
|
19
|
-
return { claimed, skipped: ids.length - claimed };
|
|
16
|
+
const client = await (0, client_1.escalations)();
|
|
17
|
+
return client.claimMany({ ids, assignee: userId, durationMinutes });
|
|
20
18
|
}
|
|
21
19
|
/**
|
|
22
20
|
* Bulk assign escalations to a specific user (admin action).
|
|
@@ -25,10 +23,13 @@ async function bulkClaimEscalations(ids, userId, durationMinutes = 30) {
|
|
|
25
23
|
async function bulkAssignEscalations(ids, targetUserId, durationMinutes = 30) {
|
|
26
24
|
if (ids.length === 0)
|
|
27
25
|
return { assigned: 0, skipped: 0 };
|
|
28
|
-
const
|
|
29
|
-
const {
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
const client = await (0, client_1.escalations)();
|
|
27
|
+
const { claimed, skipped } = await client.claimMany({
|
|
28
|
+
ids,
|
|
29
|
+
assignee: targetUserId,
|
|
30
|
+
durationMinutes,
|
|
31
|
+
});
|
|
32
|
+
return { assigned: claimed, skipped };
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
34
35
|
* Bulk reassign escalations to a different role.
|
|
@@ -37,21 +38,21 @@ async function bulkAssignEscalations(ids, targetUserId, durationMinutes = 30) {
|
|
|
37
38
|
async function bulkEscalateToRole(ids, targetRole) {
|
|
38
39
|
if (ids.length === 0)
|
|
39
40
|
return 0;
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
return rowCount ?? 0;
|
|
41
|
+
const client = await (0, client_1.escalations)();
|
|
42
|
+
return client.escalateManyToRole({ ids, targetRole });
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
45
|
* Bulk resolve escalations for AI triage.
|
|
46
|
-
* Returns full records so the caller can start triage workflows.
|
|
46
|
+
* Returns full records so the caller can start triage workflows. No signal is
|
|
47
|
+
* delivered — the triage workflow takes over handling.
|
|
47
48
|
*/
|
|
48
49
|
async function bulkResolveForTriage(ids, hint) {
|
|
49
50
|
if (ids.length === 0)
|
|
50
51
|
return [];
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
52
|
+
const client = await (0, client_1.escalations)();
|
|
53
|
+
const resolved = await client.resolveMany({
|
|
54
|
+
ids,
|
|
55
|
+
resolverPayload: { _lt: { needsTriage: true, ...(hint ? { hint } : {}) } },
|
|
54
56
|
});
|
|
55
|
-
|
|
56
|
-
return rows;
|
|
57
|
+
return (0, map_1.toEscalationRecords)(resolved);
|
|
57
58
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The escalation client, with the `lt_escalations` compatibility view ensured
|
|
3
|
+
* exactly once per process. Every service function awaits this so the view is
|
|
4
|
+
* present before the first read/write in any context (app, route test, or the
|
|
5
|
+
* service-only test that runs `migrate()` without starting workers).
|
|
6
|
+
*/
|
|
7
|
+
export declare function escalations(): Promise<import("@hotmeshio/hotmesh/build/services/escalations").EscalationClientService>;
|
|
8
|
+
/**
|
|
9
|
+
* Replace the legacy `lt_escalations` table with a view over
|
|
10
|
+
* `public.hmsh_escalations`. Idempotent and memoized per process.
|
|
11
|
+
*
|
|
12
|
+
* - Migrates any legacy rows into `hmsh_escalations` (no-op on a fresh DB), then
|
|
13
|
+
* RENAMES the legacy table to `lt_escalations_legacy` (a recoverable backup —
|
|
14
|
+
* never dropped here) so the view can take the `lt_escalations` name.
|
|
15
|
+
* - Read-path consumers (role, agent, mcp, overview) and frozen test cleanup
|
|
16
|
+
* (`DELETE FROM lt_escalations`) continue to work unchanged against the view.
|
|
17
|
+
* - The one-time conversion is serialized across concurrent containers with a
|
|
18
|
+
* dedicated Postgres advisory lock, so a multi-container deploy is safe.
|
|
19
|
+
*
|
|
20
|
+
* Safe to call eagerly at startup and lazily on first escalation use.
|
|
21
|
+
*/
|
|
22
|
+
export declare function ensureEscalationCompatView(): Promise<void>;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.escalations = escalations;
|
|
4
|
+
exports.ensureEscalationCompatView = ensureEscalationCompatView;
|
|
5
|
+
const hotmesh_1 = require("@hotmeshio/hotmesh");
|
|
6
|
+
const db_1 = require("../../lib/db");
|
|
7
|
+
const logger_1 = require("../../lib/logger");
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Escalation client — long-tail's service layer talks to the shared
|
|
10
|
+
// `public.hmsh_escalations` table exclusively through `client.escalations.*`
|
|
11
|
+
// (HotMesh 0.22.3). The escalation client is created off a `Durable.Client`,
|
|
12
|
+
// which injects `getHotMeshClient` so the escalation engine pool is shared
|
|
13
|
+
// with the rest of the app and torn down by `Durable.shutdown()`.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
let durableClient = null;
|
|
16
|
+
/** The raw `client.escalations` surface over `public.hmsh_escalations`. */
|
|
17
|
+
function rawEscalations() {
|
|
18
|
+
if (!durableClient) {
|
|
19
|
+
durableClient = new hotmesh_1.Durable.Client({ connection: (0, db_1.getConnection)() });
|
|
20
|
+
}
|
|
21
|
+
return durableClient.escalations;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The escalation client, with the `lt_escalations` compatibility view ensured
|
|
25
|
+
* exactly once per process. Every service function awaits this so the view is
|
|
26
|
+
* present before the first read/write in any context (app, route test, or the
|
|
27
|
+
* service-only test that runs `migrate()` without starting workers).
|
|
28
|
+
*/
|
|
29
|
+
async function escalations() {
|
|
30
|
+
await ensureEscalationCompatView();
|
|
31
|
+
return rawEscalations();
|
|
32
|
+
}
|
|
33
|
+
let viewReady = null;
|
|
34
|
+
/**
|
|
35
|
+
* Replace the legacy `lt_escalations` table with a view over
|
|
36
|
+
* `public.hmsh_escalations`. Idempotent and memoized per process.
|
|
37
|
+
*
|
|
38
|
+
* - Migrates any legacy rows into `hmsh_escalations` (no-op on a fresh DB), then
|
|
39
|
+
* RENAMES the legacy table to `lt_escalations_legacy` (a recoverable backup —
|
|
40
|
+
* never dropped here) so the view can take the `lt_escalations` name.
|
|
41
|
+
* - Read-path consumers (role, agent, mcp, overview) and frozen test cleanup
|
|
42
|
+
* (`DELETE FROM lt_escalations`) continue to work unchanged against the view.
|
|
43
|
+
* - The one-time conversion is serialized across concurrent containers with a
|
|
44
|
+
* dedicated Postgres advisory lock, so a multi-container deploy is safe.
|
|
45
|
+
*
|
|
46
|
+
* Safe to call eagerly at startup and lazily on first escalation use.
|
|
47
|
+
*/
|
|
48
|
+
function ensureEscalationCompatView() {
|
|
49
|
+
if (!viewReady)
|
|
50
|
+
viewReady = installEscalationCompatView();
|
|
51
|
+
return viewReady;
|
|
52
|
+
}
|
|
53
|
+
// Dedicated advisory-lock id for the compat-view conversion. Distinct from
|
|
54
|
+
// migrate()'s lock (8675309) because this step runs after HotMesh engine init,
|
|
55
|
+
// outside the migrate() sequence.
|
|
56
|
+
const COMPAT_VIEW_LOCK_ID = 8675310;
|
|
57
|
+
async function installEscalationCompatView() {
|
|
58
|
+
// Force HotMesh engine init so `public.hmsh_escalations` exists before the
|
|
59
|
+
// view binds to it (kvtables are deployed on first engine use).
|
|
60
|
+
await rawEscalations().get('00000000-0000-0000-0000-000000000000');
|
|
61
|
+
// Serialize the conversion across concurrent containers on a dedicated
|
|
62
|
+
// connection. Only one process performs the migrate+rename; the rest acquire
|
|
63
|
+
// the lock afterward, see the view already in place, and no-op (the DO block
|
|
64
|
+
// is guarded and CREATE OR REPLACE VIEW is idempotent).
|
|
65
|
+
const pool = (0, db_1.getPool)();
|
|
66
|
+
const client = await pool.connect();
|
|
67
|
+
try {
|
|
68
|
+
await client.query('SELECT pg_advisory_lock($1)', [COMPAT_VIEW_LOCK_ID]);
|
|
69
|
+
await client.query(MIGRATE_AND_RENAME_LEGACY_TABLE);
|
|
70
|
+
await client.query(CREATE_COMPAT_VIEW);
|
|
71
|
+
logger_1.loggerRegistry.info('[escalation] lt_escalations compatibility view ensured');
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await client.query('SELECT pg_advisory_unlock($1)', [COMPAT_VIEW_LOCK_ID]).catch(() => { });
|
|
75
|
+
client.release();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Migrate legacy `lt_escalations` rows into `hmsh_escalations` (idempotent),
|
|
79
|
+
// then preserve the original table as `lt_escalations_legacy` rather than
|
|
80
|
+
// dropping it — the rows survive untouched for verification and rollback; a
|
|
81
|
+
// later explicit migration can drop the backup once the cut is confirmed. Runs
|
|
82
|
+
// only while `lt_escalations` is still a real table; once it is a view this
|
|
83
|
+
// block is skipped. Payload/envelope TEXT columns are cast to JSONB defensively
|
|
84
|
+
// so a malformed value can never abort the upgrade.
|
|
85
|
+
const MIGRATE_AND_RENAME_LEGACY_TABLE = `
|
|
86
|
+
DO $$
|
|
87
|
+
BEGIN
|
|
88
|
+
IF EXISTS (
|
|
89
|
+
SELECT 1 FROM pg_class c
|
|
90
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
91
|
+
WHERE c.relname = 'lt_escalations' AND c.relkind = 'r' AND n.nspname = 'public'
|
|
92
|
+
) THEN
|
|
93
|
+
CREATE OR REPLACE FUNCTION pg_temp.lt_try_jsonb(t text) RETURNS jsonb AS $fn$
|
|
94
|
+
BEGIN
|
|
95
|
+
IF t IS NULL OR t = '' THEN RETURN NULL; END IF;
|
|
96
|
+
RETURN t::jsonb;
|
|
97
|
+
EXCEPTION WHEN others THEN
|
|
98
|
+
RETURN to_jsonb(t);
|
|
99
|
+
END;
|
|
100
|
+
$fn$ LANGUAGE plpgsql IMMUTABLE;
|
|
101
|
+
|
|
102
|
+
INSERT INTO public.hmsh_escalations
|
|
103
|
+
(id, namespace, app_id, type, subtype, description, status, priority,
|
|
104
|
+
task_id, origin_id, parent_id, workflow_id, task_queue, workflow_type,
|
|
105
|
+
role, assigned_to, assigned_until, claim_expires_at, resolved_at, claimed_at,
|
|
106
|
+
created_by, envelope, metadata, escalation_payload, resolver_payload,
|
|
107
|
+
trace_id, span_id, created_at, updated_at)
|
|
108
|
+
SELECT
|
|
109
|
+
id, 'hmsh', 'hmsh', type, subtype, description, status, priority,
|
|
110
|
+
task_id::text, origin_id, parent_id, workflow_id, task_queue, workflow_type,
|
|
111
|
+
role, assigned_to, assigned_until, assigned_until, resolved_at, claimed_at,
|
|
112
|
+
created_by::text,
|
|
113
|
+
pg_temp.lt_try_jsonb(envelope),
|
|
114
|
+
metadata,
|
|
115
|
+
pg_temp.lt_try_jsonb(escalation_payload),
|
|
116
|
+
pg_temp.lt_try_jsonb(resolver_payload),
|
|
117
|
+
trace_id, span_id, created_at, updated_at
|
|
118
|
+
FROM public.lt_escalations
|
|
119
|
+
ON CONFLICT (id) DO NOTHING;
|
|
120
|
+
|
|
121
|
+
-- Preserve the originals as a recoverable backup (rows already migrated).
|
|
122
|
+
-- If a backup already exists from a prior conversion, the current table is
|
|
123
|
+
-- redundant and is dropped instead of clobbering the backup.
|
|
124
|
+
IF EXISTS (
|
|
125
|
+
SELECT 1 FROM pg_class c
|
|
126
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
127
|
+
WHERE c.relname = 'lt_escalations_legacy' AND n.nspname = 'public'
|
|
128
|
+
) THEN
|
|
129
|
+
DROP TABLE public.lt_escalations CASCADE;
|
|
130
|
+
ELSE
|
|
131
|
+
ALTER TABLE public.lt_escalations RENAME TO lt_escalations_legacy;
|
|
132
|
+
END IF;
|
|
133
|
+
END IF;
|
|
134
|
+
END $$;`;
|
|
135
|
+
// `available` mirrors the legacy isEffectivelyClaimed/isAvailable heuristic so
|
|
136
|
+
// existing `SELECT *` consumers are unaffected; the column is additive.
|
|
137
|
+
const CREATE_COMPAT_VIEW = `
|
|
138
|
+
CREATE OR REPLACE VIEW public.lt_escalations AS
|
|
139
|
+
SELECT *,
|
|
140
|
+
(assigned_to IS NULL OR assigned_until IS NULL OR assigned_until <= NOW()) AS available
|
|
141
|
+
FROM public.hmsh_escalations;`;
|
|
@@ -2,20 +2,21 @@ import type { LTEscalationRecord } from '../../types';
|
|
|
2
2
|
import type { CreateEscalationInput, ClaimResult } from './types';
|
|
3
3
|
export declare function createEscalation(input: CreateEscalationInput): Promise<LTEscalationRecord>;
|
|
4
4
|
/**
|
|
5
|
-
* Atomic claim
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Conditions:
|
|
9
|
-
* - status = 'pending' (not resolved/cancelled)
|
|
10
|
-
* - Either: unassigned, expired claim, or same user (extension)
|
|
11
|
-
*
|
|
12
|
-
* Uses a CTE to capture the previous state so callers can detect extensions.
|
|
5
|
+
* Atomic claim. Implicit model — status stays 'pending'; "claimed" is
|
|
6
|
+
* assigned_to + assigned_until > NOW(). `isExtension` is true when the same
|
|
7
|
+
* user re-claims (extends expiry). Returns null when the row is not claimable.
|
|
13
8
|
*/
|
|
14
9
|
export declare function claimEscalation(id: string, userId: string, durationMinutes?: number): Promise<ClaimResult | null>;
|
|
10
|
+
/**
|
|
11
|
+
* Mark an escalation resolved. Signal delivery is owned by the resolution
|
|
12
|
+
* orchestrator (api/escalations/resolve.ts); service-created rows have no
|
|
13
|
+
* signal_key, so this never delivers a signal itself. Returns null when the
|
|
14
|
+
* row is missing or already terminal.
|
|
15
|
+
*/
|
|
15
16
|
export declare function resolveEscalation(id: string, resolverPayload: Record<string, any>): Promise<LTEscalationRecord | null>;
|
|
16
17
|
/**
|
|
17
|
-
* Bulk update priority for a set of escalations.
|
|
18
|
-
*
|
|
18
|
+
* Bulk update priority for a set of escalations. Only pending escalations are
|
|
19
|
+
* updated.
|
|
19
20
|
*/
|
|
20
21
|
export declare function updateEscalationsPriority(ids: string[], priority: 1 | 2 | 3 | 4): Promise<number>;
|
|
21
22
|
/**
|
|
@@ -28,6 +29,12 @@ export declare function getEscalationRoles(ids: string[]): Promise<string[]>;
|
|
|
28
29
|
* Only the assigned user (or superadmin via route) may release.
|
|
29
30
|
*/
|
|
30
31
|
export declare function releaseEscalation(id: string, userId: string): Promise<LTEscalationRecord | null>;
|
|
32
|
+
/**
|
|
33
|
+
* Sweep expired claims back to the available pool, returning the count cleared.
|
|
34
|
+
* Availability is already query-time in the implicit model, but long-tail's
|
|
35
|
+
* public contract clears `assigned_to` and returns a count, so this runs as a
|
|
36
|
+
* single direct UPDATE on the shared table (the SDK's releaseExpired is a no-op).
|
|
37
|
+
*/
|
|
31
38
|
export declare function releaseExpiredClaims(): Promise<number>;
|
|
32
39
|
/**
|
|
33
40
|
* Reassign an escalation to a different role.
|
|
@@ -50,13 +57,12 @@ export declare function findByMetadata(key: string, value: string, status?: stri
|
|
|
50
57
|
total: number;
|
|
51
58
|
}>;
|
|
52
59
|
/**
|
|
53
|
-
* Atomic claim by metadata with inline RBAC.
|
|
54
|
-
* The
|
|
55
|
-
*
|
|
56
|
-
*
|
|
60
|
+
* Atomic claim by metadata with inline RBAC and optional metadata merge.
|
|
61
|
+
* The SDK enforces the role filter in SQL — callers without an allowed role
|
|
62
|
+
* match zero rows. Returns `{ escalation, isExtension, candidatesExist }` or
|
|
63
|
+
* null when nothing was claimed.
|
|
57
64
|
*
|
|
58
|
-
* @param allowedRoles — roles the caller can claim (null = no filter / global
|
|
59
|
-
* @returns `{ escalation, isExtension, candidatesExist }` or null
|
|
65
|
+
* @param allowedRoles — roles the caller can claim (null = no filter / global)
|
|
60
66
|
*/
|
|
61
67
|
export declare function claimByMetadata(key: string, value: string, userId: string, durationMinutes?: number, metadata?: Record<string, any>, allowedRoles?: string[] | null): Promise<(ClaimResult & {
|
|
62
68
|
candidatesExist: number;
|
|
@@ -74,11 +80,13 @@ export interface ResolveByMetadataResult {
|
|
|
74
80
|
taskQueue?: string;
|
|
75
81
|
}
|
|
76
82
|
/**
|
|
77
|
-
* Atomic resolve by metadata with signal guard.
|
|
83
|
+
* Atomic resolve by metadata with signal guard, in a single CTE.
|
|
78
84
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
85
|
+
* Signal-backed rows (those carrying `metadata.signal_id`) are NOT resolved
|
|
86
|
+
* here — long-tail signals the paused workflow and the workflow interceptor
|
|
87
|
+
* resolves durably. If the workflow is gone the signal fails and the row stays
|
|
88
|
+
* pending, which is the contract the route suite pins. This guard is long-tail
|
|
89
|
+
* business logic over the shared table, so it runs as one atomic statement on
|
|
90
|
+
* `hmsh_escalations` rather than through the generic SDK resolve.
|
|
83
91
|
*/
|
|
84
92
|
export declare function resolveByMetadataAtomic(key: string, value: string, userId: string, resolverPayload: Record<string, any>, metadata?: Record<string, any>, allowedRoles?: string[] | null): Promise<ResolveByMetadataResult>;
|