@hotmeshio/long-tail 0.21.1 → 0.22.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/build/api/escalations/accumulate.d.ts +5 -2
- package/build/api/escalations/accumulate.js +31 -7
- package/build/lib/db/index.d.ts +8 -3
- package/build/lib/db/index.js +24 -2
- package/build/services/escalation/facets.js +4 -1
- package/build/system/mcp-servers/admin/schemas.d.ts +2 -2
- package/docs/accumulator.md +4 -2
- package/docs/api/http/escalations.md +1 -1
- package/docs/api/sdk/escalations.md +1 -1
- package/docs/scan-codes.md +3 -1
- package/package.json +2 -2
|
@@ -46,8 +46,11 @@ export declare function accumulateItemBySignalKey(input: {
|
|
|
46
46
|
} & Omit<AccumulateItemInput, 'assertClaim'>, auth: LTApiAuth): Promise<LTApiResult>;
|
|
47
47
|
/**
|
|
48
48
|
* Add selecting the container by metadata facet, the faceted sibling of
|
|
49
|
-
* {@link accumulateItem}.
|
|
50
|
-
*
|
|
49
|
+
* {@link accumulateItem}. The facet names a container, so only pending rows
|
|
50
|
+
* carrying the accumulator declaration are candidates: a release or
|
|
51
|
+
* remediation row that shares the facet is never picked, and when no open
|
|
52
|
+
* container shares it the answer is 404. RBAC folds into the SDK's atomic
|
|
53
|
+
* facet selection as a flat role filter. Schema enforcement is two-phase when enforcing
|
|
51
54
|
* roles exist and a payload is present: the row is picked first, validated
|
|
52
55
|
* against ITS schema, then written by asserted id; a row that went terminal
|
|
53
56
|
* between phases surfaces as a 409.
|
|
@@ -65,6 +65,25 @@ function isAccumulator(escalation) {
|
|
|
65
65
|
function isBatch(escalation) {
|
|
66
66
|
return Array.isArray(escalation.metadata?.[escalation_1.ESCALATION_BATCH_KEYS.PENDING]);
|
|
67
67
|
}
|
|
68
|
+
const NO_ACCUMULATOR_FOR_FACET = 'No pending accumulator found for this metadata';
|
|
69
|
+
/**
|
|
70
|
+
* The container a facet names: the highest priority pending row that carries
|
|
71
|
+
* the facet AND the accumulator declaration, within the given roles (null =
|
|
72
|
+
* unscoped). A release or remediation row sharing the facet is never a
|
|
73
|
+
* candidate, so a by-facet add lands on the open container or nowhere.
|
|
74
|
+
*/
|
|
75
|
+
async function pickAccumulatorByFacet(key, value, roles) {
|
|
76
|
+
if (roles !== null && roles.length === 0)
|
|
77
|
+
return null;
|
|
78
|
+
const found = await escalationService.searchByFacets({
|
|
79
|
+
roles: roles ?? undefined,
|
|
80
|
+
facets: { [key]: value },
|
|
81
|
+
status: 'pending',
|
|
82
|
+
exists: [escalation_1.ESCALATION_ACCUMULATE_KEYS.COUNT],
|
|
83
|
+
limit: 1,
|
|
84
|
+
});
|
|
85
|
+
return found.escalations[0] ?? null;
|
|
86
|
+
}
|
|
68
87
|
function reciprocalSelectorCount(r) {
|
|
69
88
|
return (r.id ? 1 : 0) + (r.signalKey ? 1 : 0) + (r.key !== undefined ? 1 : 0);
|
|
70
89
|
}
|
|
@@ -89,8 +108,7 @@ async function gateReciprocal(input, auth) {
|
|
|
89
108
|
}
|
|
90
109
|
else {
|
|
91
110
|
const writeScope = await (0, helpers_1.getEscalationWriteScope)(auth.userId);
|
|
92
|
-
|
|
93
|
-
row = found.escalations[0] ?? null;
|
|
111
|
+
row = await pickAccumulatorByFacet(input.key, String(input.value), writeScope.global ? null : writeScope.allRoles);
|
|
94
112
|
}
|
|
95
113
|
if (!row)
|
|
96
114
|
return { error: { status: 404, error: 'Reciprocal escalation not found' } };
|
|
@@ -220,8 +238,11 @@ async function accumulateItemBySignalKey(input, auth) {
|
|
|
220
238
|
}
|
|
221
239
|
/**
|
|
222
240
|
* Add selecting the container by metadata facet, the faceted sibling of
|
|
223
|
-
* {@link accumulateItem}.
|
|
224
|
-
*
|
|
241
|
+
* {@link accumulateItem}. The facet names a container, so only pending rows
|
|
242
|
+
* carrying the accumulator declaration are candidates: a release or
|
|
243
|
+
* remediation row that shares the facet is never picked, and when no open
|
|
244
|
+
* container shares it the answer is 404. RBAC folds into the SDK's atomic
|
|
245
|
+
* facet selection as a flat role filter. Schema enforcement is two-phase when enforcing
|
|
225
246
|
* roles exist and a payload is present: the row is picked first, validated
|
|
226
247
|
* against ITS schema, then written by asserted id; a row that went terminal
|
|
227
248
|
* between phases surfaces as a 409.
|
|
@@ -251,12 +272,13 @@ async function accumulateItemByMetadata(input, auth) {
|
|
|
251
272
|
roles: allowedRoles ?? undefined,
|
|
252
273
|
reciprocal: reciprocal.reciprocal,
|
|
253
274
|
});
|
|
275
|
+
if (result.outcome === 'not-found')
|
|
276
|
+
return { status: 404, error: NO_ACCUMULATOR_FOR_FACET };
|
|
254
277
|
return accumulateOutcomeResult(result, itemKey, result.escalation?.workflow_id ?? null);
|
|
255
278
|
}
|
|
256
|
-
const
|
|
257
|
-
const row = found.escalations[0];
|
|
279
|
+
const row = await pickAccumulatorByFacet(key, value, allowedRoles);
|
|
258
280
|
if (!row)
|
|
259
|
-
return { status: 404, error:
|
|
281
|
+
return { status: 404, error: NO_ACCUMULATOR_FOR_FACET };
|
|
260
282
|
if (!isAccumulator(row))
|
|
261
283
|
return { status: 400, error: 'Escalation is not an accumulator' };
|
|
262
284
|
const payload = await gatePayload(row, input.payload, auth.userId);
|
|
@@ -358,6 +380,8 @@ async function removeItemByMetadata(input, auth) {
|
|
|
358
380
|
const result = await escalationService.removeAccumulatedItemByMetadata(key, value, {
|
|
359
381
|
itemKey, actor: auth.userId, roles: allowedRoles ?? undefined, reciprocal: reciprocal.reciprocal,
|
|
360
382
|
});
|
|
383
|
+
if (result.outcome === 'not-found')
|
|
384
|
+
return { status: 404, error: NO_ACCUMULATOR_FOR_FACET };
|
|
361
385
|
return removeOutcomeResult(result, itemKey);
|
|
362
386
|
}
|
|
363
387
|
catch (err) {
|
package/build/lib/db/index.d.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { Client, Pool } from 'pg';
|
|
1
|
+
import { Client, Pool, type ClientConfig } from 'pg';
|
|
2
|
+
/** `pg.Client` that carries an 'error' listener from construction. */
|
|
3
|
+
declare class GuardedClient extends Client {
|
|
4
|
+
constructor(config?: string | ClientConfig);
|
|
5
|
+
}
|
|
2
6
|
export declare function getPool(): Pool;
|
|
3
7
|
export declare function closePool(): Promise<void>;
|
|
4
8
|
/**
|
|
5
|
-
* HotMesh connection descriptor: `{ class:
|
|
9
|
+
* HotMesh connection descriptor: `{ class: GuardedClient, options: postgres_options }`.
|
|
6
10
|
* Use this everywhere HotMesh / Durable APIs need a connection config
|
|
7
11
|
* instead of importing `pg` and `postgres_options` directly.
|
|
8
12
|
*/
|
|
9
13
|
export declare function getConnection(): {
|
|
10
|
-
class: typeof
|
|
14
|
+
class: typeof GuardedClient;
|
|
11
15
|
options: Record<string, unknown>;
|
|
12
16
|
};
|
|
17
|
+
export {};
|
package/build/lib/db/index.js
CHANGED
|
@@ -5,10 +5,29 @@ exports.closePool = closePool;
|
|
|
5
5
|
exports.getConnection = getConnection;
|
|
6
6
|
const pg_1 = require("pg");
|
|
7
7
|
const config_1 = require("../../modules/config");
|
|
8
|
+
const logger_1 = require("../logger");
|
|
9
|
+
/** A new connection that cannot be established within this window fails instead of hanging. */
|
|
10
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
8
11
|
let pool = null;
|
|
12
|
+
/** A lost socket fails the pending query; its 'error' event must never end the process. */
|
|
13
|
+
function onConnectionError(error) {
|
|
14
|
+
logger_1.loggerRegistry.debug(`[lt-db] connection error: ${error.message}`);
|
|
15
|
+
}
|
|
16
|
+
function onIdleConnectionLost(error) {
|
|
17
|
+
logger_1.loggerRegistry.warn(`[lt-db] pooled connection lost: ${error.message}`);
|
|
18
|
+
}
|
|
19
|
+
/** `pg.Client` that carries an 'error' listener from construction. */
|
|
20
|
+
class GuardedClient extends pg_1.Client {
|
|
21
|
+
constructor(config) {
|
|
22
|
+
super(config);
|
|
23
|
+
this.on('error', onConnectionError);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
9
26
|
function getPool() {
|
|
10
27
|
if (!pool) {
|
|
11
28
|
pool = new pg_1.Pool({
|
|
29
|
+
connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
|
|
30
|
+
keepAlive: true,
|
|
12
31
|
...config_1.postgres_options,
|
|
13
32
|
// Long-tail's shared tables live in public. Postgres's default
|
|
14
33
|
// search_path is `"$user", public`: if any schema shares the connecting
|
|
@@ -20,6 +39,9 @@ function getPool() {
|
|
|
20
39
|
// the engine fully qualifies its app schemas.
|
|
21
40
|
options: '-c search_path=public',
|
|
22
41
|
});
|
|
42
|
+
pool.on('error', onIdleConnectionLost);
|
|
43
|
+
// pg-pool drops its own listener while a client is checked out
|
|
44
|
+
pool.on('connect', (client) => client.on('error', onConnectionError));
|
|
23
45
|
}
|
|
24
46
|
return pool;
|
|
25
47
|
}
|
|
@@ -30,10 +52,10 @@ async function closePool() {
|
|
|
30
52
|
}
|
|
31
53
|
}
|
|
32
54
|
/**
|
|
33
|
-
* HotMesh connection descriptor: `{ class:
|
|
55
|
+
* HotMesh connection descriptor: `{ class: GuardedClient, options: postgres_options }`.
|
|
34
56
|
* Use this everywhere HotMesh / Durable APIs need a connection config
|
|
35
57
|
* instead of importing `pg` and `postgres_options` directly.
|
|
36
58
|
*/
|
|
37
59
|
function getConnection() {
|
|
38
|
-
return { class:
|
|
60
|
+
return { class: GuardedClient, options: config_1.postgres_options };
|
|
39
61
|
}
|
|
@@ -36,7 +36,10 @@ function ensureFacetReady() {
|
|
|
36
36
|
ready = (async () => {
|
|
37
37
|
await (0, client_1.ensureEscalationCompatView)(); // hmsh_escalations + lt_escalations view
|
|
38
38
|
await (0, db_1.getPool)().query(facet_sql_1.ENSURE_ORIGIN_INDEX);
|
|
39
|
-
})()
|
|
39
|
+
})().catch((err) => {
|
|
40
|
+
ready = null;
|
|
41
|
+
throw err;
|
|
42
|
+
});
|
|
40
43
|
}
|
|
41
44
|
return ready;
|
|
42
45
|
}
|
|
@@ -704,13 +704,13 @@ export declare const patchUserPropertiesSchema: z.ZodObject<{
|
|
|
704
704
|
rename: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
705
705
|
}, "strip", z.ZodTypeAny, {
|
|
706
706
|
user_id: string;
|
|
707
|
-
set?: Record<string, any> | undefined;
|
|
708
707
|
remove?: string[] | undefined;
|
|
708
|
+
set?: Record<string, any> | undefined;
|
|
709
709
|
rename?: Record<string, string> | undefined;
|
|
710
710
|
}, {
|
|
711
711
|
user_id: string;
|
|
712
|
-
set?: Record<string, any> | undefined;
|
|
713
712
|
remove?: string[] | undefined;
|
|
713
|
+
set?: Record<string, any> | undefined;
|
|
714
714
|
rename?: Record<string, string> | undefined;
|
|
715
715
|
}>;
|
|
716
716
|
export declare const listRolesSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
|
package/docs/accumulator.md
CHANGED
|
@@ -67,6 +67,8 @@ A batch wait may add `partialOnTimeout: true`; it stores `envelope.batch_partial
|
|
|
67
67
|
| Read the collection | `GET /api/escalations/:id/items`, `GET /api/escalations/items-by-signal-key` | `getItems`, `getItemsBySignalKey` | `get_escalation_items` (admin; `id` or `signalKey`) |
|
|
68
68
|
| Scan | the `accumulate` verb; item mode answers `no_open_container` when the container closed; templates read `{claim.<facet>}` and `{item.<facet>}` | | |
|
|
69
69
|
|
|
70
|
+
A facet selector (`accumulateByMetadata`, `removeItemByMetadata`, a reciprocal named by `key` + `value`, the scan verb's item mode) considers only pending rows that carry the accumulator declaration, ordered by priority then age. A release or remediation row sharing the container's facet is never picked; with no open container the answer is `not-found`, and the scan answers `no_open_container`.
|
|
71
|
+
|
|
70
72
|
An add takes `itemKey` (1 to 128 characters), an optional `payload` (validated against the container role's versioned form when present), an optional `metadata` patch (reserved keys rejected), `assertClaim` (by id), and an optional `reciprocal`.
|
|
71
73
|
|
|
72
74
|
### Outcomes
|
|
@@ -78,7 +80,7 @@ An add takes `itemKey` (1 to 128 characters), an optional `payload` (validated a
|
|
|
78
80
|
| `duplicate-item` | 409 | the key is already held (`unique`), row untouched |
|
|
79
81
|
| `full` | 409 | `max` held and the key is new (`resolveAtMax: false`, or a race with the completing add) |
|
|
80
82
|
| `not-accumulator` | 400 | the row carries no `accumulate` declaration |
|
|
81
|
-
| `not-found` | 404 | unknown row,
|
|
83
|
+
| `not-found` | 404 | unknown row, outside the caller's scope on the ingress forms, or (by facet) no pending accumulator shares the facet; rows without the declaration are never candidates |
|
|
82
84
|
| `already-resolved`, `already-expired`, `already-cancelled` | 409 | the row is terminal |
|
|
83
85
|
| `claimed-by-other`, `claim-expired` | 409 | the `assertClaim` assertion failed |
|
|
84
86
|
| `reciprocal-not-found` | 404 | the reciprocal selector matched nothing the caller may act on |
|
|
@@ -110,4 +112,4 @@ Every accepted add and removal publishes `escalation.updated` for the container
|
|
|
110
112
|
|
|
111
113
|
## Versions
|
|
112
114
|
|
|
113
|
-
HotMesh 0.29.0 (`accumulate`, `partialOnTimeout`, timeout delivery, reciprocal statement) and long-tail 0.21.1 (root export of `conditionalAccumulator`, `no_open_container`, claim and item template tokens, items by signal key).
|
|
115
|
+
HotMesh 0.29.0 (`accumulate`, `partialOnTimeout`, timeout delivery, reciprocal statement) and long-tail 0.21.1 (root export of `conditionalAccumulator`, `no_open_container`, claim and item template tokens, items by signal key); HotMesh 0.29.1 and long-tail 0.21.2 (facet selectors pick pending accumulators only).
|
|
@@ -1166,7 +1166,7 @@ Add ONE item to an open accumulator escalation (a `conditionalAccumulator` wait
|
|
|
1166
1166
|
| `assertClaim` | `boolean` | By id only. Require the caller's own live claim, asserted atomically |
|
|
1167
1167
|
| `reciprocal` | `object` | A second accumulator row written in the same statement, both or neither. Exactly one of `id`, `signalKey`, or `key` + `value`; optional `payload` stored on its entry |
|
|
1168
1168
|
|
|
1169
|
-
**By signal key:** `{ signalKey, itemKey, payload?, metadata?, reciprocal? }`. **By metadata:** `{ key, value, itemKey, payload?, metadata?, restrictRoles?, reciprocal? }` selects the highest priority pending accumulator whose metadata contains the key/value, within the caller's write scope intersected with `restrictRoles`.
|
|
1169
|
+
**By signal key:** `{ signalKey, itemKey, payload?, metadata?, reciprocal? }`. **By metadata:** `{ key, value, itemKey, payload?, metadata?, restrictRoles?, reciprocal? }` selects the highest priority pending accumulator whose metadata contains the key/value, within the caller's write scope intersected with `restrictRoles`. Only rows carrying the accumulator declaration are candidates: a release or remediation row that shares the facet is never picked, and when no open container shares it the answer is 404 `No pending accumulator found for this metadata`. The same rule selects a reciprocal named by `key` + `value`.
|
|
1170
1170
|
|
|
1171
1171
|
**Response 200 (interim):**
|
|
1172
1172
|
|
|
@@ -887,7 +887,7 @@ await lt.escalations.accumulateByMetadata({ key: 'binKey', value: binKey, itemKe
|
|
|
887
887
|
| `metadata` | `object` | No | Merge patch for the container metadata, same statement; reserved `accumulate_*` keys rejected |
|
|
888
888
|
| `assertClaim` | `boolean` | No | By id only: require the caller's own live claim |
|
|
889
889
|
| `reciprocal` | `object` | No | `{ id \| signalKey \| key + value, payload? }`: a second accumulator row written in the same statement, both or neither |
|
|
890
|
-
| `restrictRoles` | `string[]` | No | By metadata only: expected queues, intersected with the caller's write scope |
|
|
890
|
+
| `restrictRoles` | `string[]` | No | By metadata only: expected queues, intersected with the caller's write scope. The facet selects among pending accumulator rows only; other rows sharing it are never candidates, and none open answers 404 |
|
|
891
891
|
|
|
892
892
|
**Returns:** `LTApiResult<{ outcome: 'accepted' \| 'completed', count, remaining, escalationId, signaled?, workflowId?, reciprocal? }>`. `remaining` is `null` on an unbounded accumulator. 409 on `Item already held`, `Accumulator is full`, claim blocks, terminal rows, and reciprocal blocks; 400 on a non-accumulator; 404 non-disclosure.
|
|
893
893
|
|
package/docs/scan-codes.md
CHANGED
|
@@ -118,7 +118,9 @@ through the scheme facet, reads that facet from the row (a bag row carrying
|
|
|
118
118
|
`binKey: "B-7"`), and adds the target to the pending accumulator whose
|
|
119
119
|
metadata carries the same value, writing the item's row as the reciprocal in
|
|
120
120
|
the same statement (`reciprocal: false` skips it; `containerRoles` names the
|
|
121
|
-
container queues).
|
|
121
|
+
container queues). The container pick considers only pending rows that carry
|
|
122
|
+
the accumulator declaration, so a release row sharing the facet during a
|
|
123
|
+
pack-out is never the target. Without it the scanned target is the CONTAINER and the step
|
|
122
124
|
adds `params.itemKey` (a template) to it. An item with no row or no container
|
|
123
125
|
facet falls through to the next step; a container already holding the item
|
|
124
126
|
reports a conflict. When the item's row exists but no pending container
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hotmeshio/long-tail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
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",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"@anthropic-ai/sdk": "^0.92.0",
|
|
76
76
|
"@aws-sdk/client-s3": "^3.1017.0",
|
|
77
77
|
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
|
78
|
-
"@hotmeshio/hotmesh": "^0.
|
|
78
|
+
"@hotmeshio/hotmesh": "^0.30.0",
|
|
79
79
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
80
80
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.215.0",
|
|
81
81
|
"@opentelemetry/resources": "^2.5.1",
|