@segmentstream-cloud/ontology 0.14.0 → 0.15.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 +35 -10
- package/dist/agent-chat.d.ts +75 -0
- package/dist/agent-chat.js +176 -0
- package/dist/analyze.d.ts +6 -2
- package/dist/analyze.js +29 -18
- package/dist/authoring.d.ts +6 -3
- package/dist/authoring.js +6 -3
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/permissions.d.ts +11 -2
- package/dist/permissions.js +15 -3
- package/dist/replication/baseline.js +1 -1
- package/dist/replication/model.js +2 -2
- package/dist/replication/schema.js +1 -1
- package/dist/runtime/agents.d.ts +1 -0
- package/dist/runtime/agents.js +160 -0
- package/dist/runtime/generated-actions.d.ts +2 -1
- package/dist/runtime/generated-actions.js +18 -13
- package/dist/service/agent/channel.d.ts +16 -0
- package/dist/service/agent/channel.js +88 -0
- package/dist/service/agent/protocol.d.ts +14 -0
- package/dist/service/agent/protocol.js +26 -0
- package/dist/service/agent/server.d.ts +2 -1
- package/dist/service/agent/server.js +24 -2
- package/dist/service/aggregate-platform.d.ts +5 -0
- package/dist/service/aggregate-platform.js +1 -1
- package/dist/service/config.d.ts +10 -0
- package/dist/service/config.js +8 -0
- package/dist/service/handlers.d.ts +2 -1
- package/dist/service/handlers.js +2 -2
- package/dist/service/mcp/server.d.ts +3 -0
- package/dist/service/mcp/server.js +1 -1
- package/dist/service/operations.d.ts +6 -2
- package/dist/service/operations.js +13 -5
- package/dist/service/runtime-contract.d.ts +26 -0
- package/dist/service/service.js +7 -1
- package/package.json +1 -1
package/dist/permissions.js
CHANGED
|
@@ -3,12 +3,18 @@ import { GENERATED_UPSERT_PREFIX, toSnakeCase } from "./names.js";
|
|
|
3
3
|
* Permissions are `<entity>:<verb>` strings. The verbs are `read`, the generated `create`, `update`,
|
|
4
4
|
* `delete`, `link` and `unlink`, and the snake_case name of an authored action. A role may use `*` for
|
|
5
5
|
* the entity (`*:read`), the verb (`lead:*`) or both (`*:*`); what an action or a read requires never
|
|
6
|
-
* holds a wildcard.
|
|
6
|
+
* holds a wildcard. A wildcard entity does not cover the entities in {@link EXPLICIT_GRANT_ENTITIES}.
|
|
7
7
|
*/
|
|
8
8
|
/** Any entity or any verb, in a role's permissions. */
|
|
9
9
|
export const WILDCARD = "*";
|
|
10
10
|
/** The verb of every read: lists, queries, records, history, traversals and `include`. */
|
|
11
11
|
export const READ_VERB = "read";
|
|
12
|
+
/**
|
|
13
|
+
* Entities only a permission that names them covers: `*:read` or `*:*` grant nothing on them. The
|
|
14
|
+
* built-in agent chat is in every ontology, and member's default `*:read` would otherwise show anyone
|
|
15
|
+
* who signs in the workspace's chats and who is in them; `agent_chat:read` or `agent_chat:*` does.
|
|
16
|
+
*/
|
|
17
|
+
export const EXPLICIT_GRANT_ENTITIES = new Set(["agent_chat"]);
|
|
12
18
|
/** What the built-in `member` role holds when `defineOntology` does not say: reading everything. */
|
|
13
19
|
export const DEFAULT_MEMBER_PERMISSIONS = [`${WILDCARD}:${READ_VERB}`];
|
|
14
20
|
/** `<entity>:<verb>` with lowercase snake_case entities and lowercase snake_case verbs, or `*` for either. */
|
|
@@ -25,14 +31,20 @@ export function parsePermission(value) {
|
|
|
25
31
|
export function permissionOf(entity, verb) {
|
|
26
32
|
return `${entity}:${verb}`;
|
|
27
33
|
}
|
|
28
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Whether a granted permission, which may hold wildcards, covers the required one. A wildcard entity
|
|
36
|
+
* covers every entity but those in {@link EXPLICIT_GRANT_ENTITIES}.
|
|
37
|
+
*/
|
|
29
38
|
export function grants(granted, required) {
|
|
30
39
|
const held = parsePermission(granted);
|
|
31
40
|
const wanted = parsePermission(required);
|
|
32
41
|
if (held === undefined || wanted === undefined) {
|
|
33
42
|
return false;
|
|
34
43
|
}
|
|
35
|
-
|
|
44
|
+
const entity = toSnakeCase(wanted.entity);
|
|
45
|
+
return ((held.entity === WILDCARD
|
|
46
|
+
? !EXPLICIT_GRANT_ENTITIES.has(entity)
|
|
47
|
+
: toSnakeCase(held.entity) === entity) &&
|
|
36
48
|
(held.verb === WILDCARD || toSnakeCase(held.verb) === toSnakeCase(wanted.verb)));
|
|
37
49
|
}
|
|
38
50
|
/**
|
|
@@ -23,7 +23,7 @@ export const createReplicationBaseline = (sql, input) => sql.withTransaction(Eff
|
|
|
23
23
|
yield* Effect.sync(() => validateReplicationSelection(manifest, input.selection));
|
|
24
24
|
// Drain older SDK transactions that started before the compatibility fence was enabled.
|
|
25
25
|
const tables = manifest.entities
|
|
26
|
-
.filter((e) => e.source === undefined && e.
|
|
26
|
+
.filter((e) => e.source === undefined && !e.builtIn)
|
|
27
27
|
.flatMap((e) => [
|
|
28
28
|
tableName(e.name),
|
|
29
29
|
...e.relationships
|
|
@@ -69,7 +69,7 @@ export function validateReplicationSelection(manifest, selection) {
|
|
|
69
69
|
};
|
|
70
70
|
for (const item of selection) {
|
|
71
71
|
const entity = manifest.entities.find((e) => e.id === item.entityId);
|
|
72
|
-
if (!entity || entity.
|
|
72
|
+
if (!entity || entity.builtIn || entity.source !== undefined) {
|
|
73
73
|
throw new Error(`Only stored business entities can be replicated: ${item.entityId}.`);
|
|
74
74
|
}
|
|
75
75
|
addView(entity.name);
|
|
@@ -82,7 +82,7 @@ export function validateReplicationSelection(manifest, selection) {
|
|
|
82
82
|
const target = manifest.entities.find((e) => e.name === relationship?.to);
|
|
83
83
|
if (relationship?.cardinality !== "many" ||
|
|
84
84
|
!target ||
|
|
85
|
-
target.
|
|
85
|
+
target.builtIn ||
|
|
86
86
|
!selection.some((s) => s.entityId === target.id)) {
|
|
87
87
|
throw new Error(`Replication of ${entity.name}.${name} requires both business entities.`);
|
|
88
88
|
}
|
|
@@ -55,7 +55,7 @@ export function snapshotExpression(entity, alias) {
|
|
|
55
55
|
/** History is the durable source. Triggers also reject old writers while capture is enabled. */
|
|
56
56
|
export function replicationEntityDdl(manifest) {
|
|
57
57
|
return manifest.entities
|
|
58
|
-
.filter((e) => e.source === undefined && e.
|
|
58
|
+
.filter((e) => e.source === undefined && !e.builtIn)
|
|
59
59
|
.flatMap((entity) => {
|
|
60
60
|
const history = qi(historyTableName(entity.name));
|
|
61
61
|
const table = qi(tableName(entity.name));
|
package/dist/runtime/agents.d.ts
CHANGED
|
@@ -5,5 +5,6 @@ import type { AgentStore } from "../service/runtime-contract.js";
|
|
|
5
5
|
* The agent store on the runtime's agent tables, as `ontology_app`. Expiry is the database's
|
|
6
6
|
* `now()`; a pairing is consumed with one `UPDATE … WHERE "consumed_at" IS NULL`, so two concurrent
|
|
7
7
|
* approvals cannot both succeed. Ids that are not UUIDs find nothing rather than fail the cast.
|
|
8
|
+
* Chats the agent sees are `agent_chat` records, written as the platform with history.
|
|
8
9
|
*/
|
|
9
10
|
export declare function sqlAgentStore(ontology: Ontology, sql: SqlClient.SqlClient): AgentStore;
|
package/dist/runtime/agents.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { Effect } from "effect";
|
|
2
3
|
import { SqlClient } from "effect/unstable/sql";
|
|
4
|
+
import { AgentChat } from "../agent-chat.js";
|
|
3
5
|
import { AGENT_IDENTITIES_TABLE, AGENT_PAIRINGS_TABLE, USER_TABLE } from "../migrate/storage.js";
|
|
6
|
+
import { MAX_QUERY_LIMIT } from "../query.js";
|
|
7
|
+
import { beginReplicationWrite } from "../replication/transaction.js";
|
|
4
8
|
import { resolveMemberById } from "./principals.js";
|
|
5
9
|
import { isRecordId } from "./records.js";
|
|
10
|
+
import { makeOntologyRuntime } from "./sql-runtime.js";
|
|
6
11
|
const PAIRINGS = `"${AGENT_PAIRINGS_TABLE}"`;
|
|
7
12
|
const IDENTITIES = `"${AGENT_IDENTITIES_TABLE}"`;
|
|
8
13
|
const USERS = `"${USER_TABLE}"`;
|
|
@@ -14,6 +19,16 @@ const PAIRING_COLUMNS = `"id"::text AS "id", "provider", "external_id", "display
|
|
|
14
19
|
`${epochMs(`"expires_at"`)} AS "expires_at"`;
|
|
15
20
|
const IDENTITY_COLUMNS = `"id"::text AS "id", "provider", "external_id", "user_id"::text AS "user_id", "display", ` +
|
|
16
21
|
`${epochMs(`"created_at"`)} AS "created_at", ${epochMs(`"revoked_at"`)} AS "revoked_at"`;
|
|
22
|
+
/**
|
|
23
|
+
* How often a chat's `last_message_at` is recorded: each record is a history row, and a busy chat
|
|
24
|
+
* would otherwise add one per message.
|
|
25
|
+
*/
|
|
26
|
+
const CHAT_MESSAGE_RECORD_INTERVAL_MS = 15 * 60 * 1000;
|
|
27
|
+
/** Whether a chat's latest message is recorded: it has none yet, or none this interval. */
|
|
28
|
+
function messageDue(lastMessageAt, now) {
|
|
29
|
+
const last = typeof lastMessageAt === "string" ? Date.parse(lastMessageAt) : Number.NaN;
|
|
30
|
+
return Number.isNaN(last) || now.getTime() - last >= CHAT_MESSAGE_RECORD_INTERVAL_MS;
|
|
31
|
+
}
|
|
17
32
|
function nullableText(value) {
|
|
18
33
|
return value === null || value === undefined ? null : String(value);
|
|
19
34
|
}
|
|
@@ -41,6 +56,7 @@ function identityOf(row) {
|
|
|
41
56
|
* The agent store on the runtime's agent tables, as `ontology_app`. Expiry is the database's
|
|
42
57
|
* `now()`; a pairing is consumed with one `UPDATE … WHERE "consumed_at" IS NULL`, so two concurrent
|
|
43
58
|
* approvals cannot both succeed. Ids that are not UUIDs find nothing rather than fail the cast.
|
|
59
|
+
* Chats the agent sees are `agent_chat` records, written as the platform with history.
|
|
44
60
|
*/
|
|
45
61
|
export function sqlAgentStore(ontology, sql) {
|
|
46
62
|
const rows = (statement, values = []) => sql.unsafe(statement, values).pipe(Effect.orDie);
|
|
@@ -141,5 +157,149 @@ export function sqlAgentStore(ontology, sql) {
|
|
|
141
157
|
const [paired] = yield* rows(`SELECT 1 FROM ${IDENTITIES} WHERE "user_id" = $1::uuid AND "revoked_at" IS NULL LIMIT 1`, [userId]);
|
|
142
158
|
return paired === undefined ? undefined : yield* memberPrincipal(userId);
|
|
143
159
|
}),
|
|
160
|
+
seeChat: ({ provider, externalId, title, memberId, leftMemberId, agentLeft, migratedFrom }) => sql
|
|
161
|
+
.withTransaction(Effect.gen(function* () {
|
|
162
|
+
yield* beginReplicationWrite(sql);
|
|
163
|
+
const previous = migratedFrom === undefined || migratedFrom === externalId ? undefined : migratedFrom;
|
|
164
|
+
// Calls for one chat run one after the other: the first creates it, the rest find it. A
|
|
165
|
+
// migration locks both chats, in one order, so that two calls never wait on each other.
|
|
166
|
+
for (const id of [externalId, ...(previous === undefined ? [] : [previous])].sort()) {
|
|
167
|
+
yield* rows(`SELECT pg_advisory_xact_lock(hashtext($1))`, [`chat:${provider}:${id}`]);
|
|
168
|
+
}
|
|
169
|
+
const runtime = makeOntologyRuntime(ontology, sql, {
|
|
170
|
+
action: `${AgentChat.name}.seen`,
|
|
171
|
+
actionId: randomUUID(),
|
|
172
|
+
principalId: null,
|
|
173
|
+
});
|
|
174
|
+
const now = new Date();
|
|
175
|
+
const chatOf = (id) => runtime
|
|
176
|
+
.find({
|
|
177
|
+
entity: AgentChat.name,
|
|
178
|
+
options: { where: { provider, external_id: id }, limit: 1, forUpdate: true },
|
|
179
|
+
})
|
|
180
|
+
.pipe(Effect.map(([chat]) => chat));
|
|
181
|
+
const membersOf = (id) => runtime
|
|
182
|
+
.related({
|
|
183
|
+
entity: AgentChat.name,
|
|
184
|
+
id,
|
|
185
|
+
relationship: "members",
|
|
186
|
+
query: { limit: MAX_QUERY_LIMIT },
|
|
187
|
+
})
|
|
188
|
+
.pipe(Effect.map((page) => page.items.map((member) => String(member.id))));
|
|
189
|
+
// Unlinks every member of the chat, linking each to `to` first when given. Each page
|
|
190
|
+
// unlinked is gone, so the first page is read until it is empty.
|
|
191
|
+
const releaseMembers = (id, to) => Effect.gen(function* () {
|
|
192
|
+
for (;;) {
|
|
193
|
+
const members = yield* membersOf(id);
|
|
194
|
+
if (members.length === 0) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
for (const targetId of members) {
|
|
198
|
+
if (to !== undefined) {
|
|
199
|
+
yield* runtime.link({
|
|
200
|
+
entity: AgentChat.name,
|
|
201
|
+
relationship: "members",
|
|
202
|
+
id: to,
|
|
203
|
+
targetId,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
yield* runtime.unlink({
|
|
207
|
+
entity: AgentChat.name,
|
|
208
|
+
relationship: "members",
|
|
209
|
+
id,
|
|
210
|
+
targetId,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
let found = yield* chatOf(externalId);
|
|
216
|
+
if (previous !== undefined) {
|
|
217
|
+
const old = yield* chatOf(previous);
|
|
218
|
+
if (old !== undefined && found === undefined) {
|
|
219
|
+
// The group became a supergroup: the chat keeps its record under its new id.
|
|
220
|
+
found = yield* runtime.update({
|
|
221
|
+
entity: AgentChat.name,
|
|
222
|
+
id: String(old.id),
|
|
223
|
+
values: { external_id: externalId },
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
else if (old !== undefined && found !== undefined) {
|
|
227
|
+
// Both were recorded: the new one keeps what members said and gains the members,
|
|
228
|
+
// and the old one is deleted.
|
|
229
|
+
const kept = {};
|
|
230
|
+
for (const key of ["purpose", "when_to_use"]) {
|
|
231
|
+
if (nullableText(found[key]) === null && nullableText(old[key]) !== null) {
|
|
232
|
+
kept[key] = old[key];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (Object.keys(kept).length > 0) {
|
|
236
|
+
found = yield* runtime.update({
|
|
237
|
+
entity: AgentChat.name,
|
|
238
|
+
id: String(found.id),
|
|
239
|
+
values: kept,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
yield* releaseMembers(String(old.id), String(found.id));
|
|
243
|
+
yield* runtime.delete({ entity: AgentChat.name, id: String(old.id) });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const changes = {
|
|
247
|
+
...(found?.title === title ? {} : { title }),
|
|
248
|
+
...(messageDue(found?.last_message_at, now)
|
|
249
|
+
? { last_message_at: now.toISOString() }
|
|
250
|
+
: {}),
|
|
251
|
+
};
|
|
252
|
+
const chat = found === undefined
|
|
253
|
+
? yield* runtime.create({
|
|
254
|
+
entity: AgentChat.name,
|
|
255
|
+
values: { provider, external_id: externalId, ...changes },
|
|
256
|
+
})
|
|
257
|
+
: Object.keys(changes).length === 0
|
|
258
|
+
? found
|
|
259
|
+
: yield* runtime.update({
|
|
260
|
+
entity: AgentChat.name,
|
|
261
|
+
id: String(found.id),
|
|
262
|
+
values: changes,
|
|
263
|
+
});
|
|
264
|
+
if (memberId !== undefined && isRecordId(memberId)) {
|
|
265
|
+
const [member] = yield* rows(`SELECT 1 FROM ${USERS} WHERE "id" = $1::uuid AND "kind"::text = 'member' AND "active"`, [memberId]);
|
|
266
|
+
// A link already there changes nothing and records no history.
|
|
267
|
+
if (member !== undefined) {
|
|
268
|
+
yield* runtime.link({
|
|
269
|
+
entity: AgentChat.name,
|
|
270
|
+
relationship: "members",
|
|
271
|
+
id: String(chat.id),
|
|
272
|
+
targetId: memberId,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (leftMemberId !== undefined && isRecordId(leftMemberId)) {
|
|
277
|
+
const [user] = yield* rows(`SELECT 1 FROM ${USERS} WHERE "id" = $1::uuid`, [
|
|
278
|
+
leftMemberId,
|
|
279
|
+
]);
|
|
280
|
+
// Whatever their state now; a member who is not linked changes nothing and records no
|
|
281
|
+
// history.
|
|
282
|
+
if (user !== undefined) {
|
|
283
|
+
yield* runtime.unlink({
|
|
284
|
+
entity: AgentChat.name,
|
|
285
|
+
relationship: "members",
|
|
286
|
+
id: String(chat.id),
|
|
287
|
+
targetId: leftMemberId,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (agentLeft === true) {
|
|
292
|
+
// The agent left: no member is in the chat with it any more. The chat stays; members
|
|
293
|
+
// link again when they write after the agent is back.
|
|
294
|
+
yield* releaseMembers(String(chat.id));
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
id: String(chat.id),
|
|
298
|
+
title: String(chat.title),
|
|
299
|
+
purpose: nullableText(chat.purpose),
|
|
300
|
+
whenToUse: nullableText(chat.when_to_use),
|
|
301
|
+
};
|
|
302
|
+
}))
|
|
303
|
+
.pipe(Effect.orDie),
|
|
144
304
|
};
|
|
145
305
|
}
|
|
@@ -12,7 +12,8 @@ export declare function upsertActionName(property: string): string;
|
|
|
12
12
|
* are ordinary actions and run through the same pipeline. Their `permission` is unset: each requires
|
|
13
13
|
* the permission named after it (`lead:create`), and `upsert_by_<property>` both `create` and `update`,
|
|
14
14
|
* as `generatedActionPermissions` says. The built-in User has none: members are created by sign-in
|
|
15
|
-
* and services by the app or the CLI.
|
|
15
|
+
* and services by the app or the CLI. The built-in agent chat has only `update`, for its unmanaged
|
|
16
|
+
* properties: the agent records chats and their members.
|
|
16
17
|
*/
|
|
17
18
|
export declare function generatedActions(ontology: Ontology, entityName: string): GeneratedActions;
|
|
18
19
|
/** An action's input as JSON Schema (draft 2020-12), as the manifest describes authored actions. */
|
|
@@ -64,7 +64,7 @@ function valueFields(entity, { mode }) {
|
|
|
64
64
|
? target
|
|
65
65
|
: Schema.optionalKey(link.optional ? Schema.NullOr(target) : target);
|
|
66
66
|
}
|
|
67
|
-
else if (link.via === "join" && !link.inverse) {
|
|
67
|
+
else if (link.via === "join" && !link.inverse && !entity.resolved.builtIn) {
|
|
68
68
|
fields[link.name] = Schema.optionalKey(Schema.Array(reference(link, `The id of a ${link.target} record.`)).annotate({
|
|
69
69
|
description: `${relationshipDescription(entity, link)} The list replaces the current links.`,
|
|
70
70
|
}));
|
|
@@ -87,7 +87,8 @@ function valuesOf(input, omit) {
|
|
|
87
87
|
* are ordinary actions and run through the same pipeline. Their `permission` is unset: each requires
|
|
88
88
|
* the permission named after it (`lead:create`), and `upsert_by_<property>` both `create` and `update`,
|
|
89
89
|
* as `generatedActionPermissions` says. The built-in User has none: members are created by sign-in
|
|
90
|
-
* and services by the app or the CLI.
|
|
90
|
+
* and services by the app or the CLI. The built-in agent chat has only `update`, for its unmanaged
|
|
91
|
+
* properties: the agent records chats and their members.
|
|
91
92
|
*/
|
|
92
93
|
export function generatedActions(ontology, entityName) {
|
|
93
94
|
if (ontology.manifest.entities.find((e) => e.name === entityName)?.source !== undefined) {
|
|
@@ -101,7 +102,7 @@ export function generatedActions(ontology, entityName) {
|
|
|
101
102
|
}
|
|
102
103
|
function buildGeneratedActions(ontology, entityName) {
|
|
103
104
|
const entity = entityModel(ontologyModel(ontology), entityName);
|
|
104
|
-
if (entity.resolved.builtIn) {
|
|
105
|
+
if (entity.resolved.builtIn && entity.resolved.permissions.update === undefined) {
|
|
105
106
|
return {};
|
|
106
107
|
}
|
|
107
108
|
const label = entity.label.toLowerCase();
|
|
@@ -109,22 +110,26 @@ function buildGeneratedActions(ontology, entityName) {
|
|
|
109
110
|
const managed = entity.fields
|
|
110
111
|
.filter((field) => field.property?.definition.managed === true)
|
|
111
112
|
.map((field) => field.name);
|
|
113
|
+
const update = action({
|
|
114
|
+
description: `Change ${article(label)}'s properties and relationships; omitted values stay as they are.` +
|
|
115
|
+
(managed.length > 0 ? ` ${managed.join(", ")} change only through other actions.` : ""),
|
|
116
|
+
input: Schema.Struct({ id: id("change"), ...valueFields(entity, { mode: "update" }) }),
|
|
117
|
+
handler: ({ input }) => Effect.flatMap(OntologyWrites, (runtime) => runtime.update({
|
|
118
|
+
entity: entityName,
|
|
119
|
+
id: input.id,
|
|
120
|
+
values: valuesOf(input, ["id"]),
|
|
121
|
+
})),
|
|
122
|
+
});
|
|
123
|
+
if (entity.resolved.builtIn) {
|
|
124
|
+
return { update };
|
|
125
|
+
}
|
|
112
126
|
const actions = {
|
|
113
127
|
create: action({
|
|
114
128
|
description: `Create ${article(label)}.`,
|
|
115
129
|
input: Schema.Struct(valueFields(entity, { mode: "create" })),
|
|
116
130
|
handler: ({ input }) => Effect.flatMap(OntologyWrites, (runtime) => runtime.create({ entity: entityName, values: input })),
|
|
117
131
|
}),
|
|
118
|
-
update
|
|
119
|
-
description: `Change ${article(label)}'s properties and relationships; omitted values stay as they are.` +
|
|
120
|
-
(managed.length > 0 ? ` ${managed.join(", ")} change only through other actions.` : ""),
|
|
121
|
-
input: Schema.Struct({ id: id("change"), ...valueFields(entity, { mode: "update" }) }),
|
|
122
|
-
handler: ({ input }) => Effect.flatMap(OntologyWrites, (runtime) => runtime.update({
|
|
123
|
-
entity: entityName,
|
|
124
|
-
id: input.id,
|
|
125
|
-
values: valuesOf(input, ["id"]),
|
|
126
|
-
})),
|
|
127
|
-
}),
|
|
132
|
+
update,
|
|
128
133
|
delete: action({
|
|
129
134
|
description: `Delete ${article(label)}. Records that refer to it are deleted, cleared or protect it, ` +
|
|
130
135
|
"as each relationship's onDelete says.",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AgentChannel } from "../../agent-chat.js";
|
|
2
|
+
import type { AgentStore } from "../runtime-contract.js";
|
|
3
|
+
/** The agent's route for messages the ontology asks it to deliver, on the production deployment. */
|
|
4
|
+
export declare const AGENT_MESSAGES_PATH = "/eve/v1/telegram/messages";
|
|
5
|
+
/** The agent of one action request: its token and the agent tables. */
|
|
6
|
+
export type AgentChannelFactory = (input: {
|
|
7
|
+
/** The deployment's Vercel OIDC token, from the request or the environment. */
|
|
8
|
+
readonly identityToken: string | undefined;
|
|
9
|
+
readonly store: AgentStore | undefined;
|
|
10
|
+
}) => AgentChannel["Service"];
|
|
11
|
+
/**
|
|
12
|
+
* The agent of a deployment that has one, at `https://<VERCEL_PROJECT_PRODUCTION_URL>`. Each message
|
|
13
|
+
* goes to {@link AGENT_MESSAGES_PATH} with the deployment's Vercel OIDC token, which the agent
|
|
14
|
+
* verifies as its own project's. A member's private chat is their live Telegram identity.
|
|
15
|
+
*/
|
|
16
|
+
export declare function deployedAgentChannel(agentUrl: string, fetchImpl?: typeof fetch): AgentChannelFactory;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Effect, Option, Schema } from "effect";
|
|
2
|
+
import { TELEGRAM } from "../../agent-chat.js";
|
|
3
|
+
import { ActionRejected } from "../../errors.js";
|
|
4
|
+
import { describeError } from "../aggregate-platform.js";
|
|
5
|
+
/** The agent's route for messages the ontology asks it to deliver, on the production deployment. */
|
|
6
|
+
export const AGENT_MESSAGES_PATH = "/eve/v1/telegram/messages";
|
|
7
|
+
/** How long the agent may take to accept a message; it answers once the message is queued. */
|
|
8
|
+
const DELIVERY_TIMEOUT_MS = 30_000;
|
|
9
|
+
/** The agent's error body: `{ "error": "channels_disabled" }` and the like. */
|
|
10
|
+
const AgentError = Schema.Struct({
|
|
11
|
+
error: Schema.optionalKey(Schema.String),
|
|
12
|
+
detail: Schema.optionalKey(Schema.String),
|
|
13
|
+
});
|
|
14
|
+
const rejected = (message) => new ActionRejected({ message });
|
|
15
|
+
const UNREACHABLE = "The agent could not be reached, so the message was not delivered. Try again.";
|
|
16
|
+
/**
|
|
17
|
+
* A delivery that got no answer (DNS, TLS, a timeout, a dropped body): the caught error is logged
|
|
18
|
+
* for the operator, with the token redacted, and the caller is told only that the agent was
|
|
19
|
+
* unreachable.
|
|
20
|
+
*/
|
|
21
|
+
function deliveryFailed(error, token) {
|
|
22
|
+
const diagnostic = error instanceof DOMException && error.name === "TimeoutError" ? "timeout" : "unreachable";
|
|
23
|
+
const redact = (text) => text.split(token).join("[redacted]");
|
|
24
|
+
return Effect.logWarning("ontology.agent.delivery_failed").pipe(Effect.annotateLogs({ diagnostic, error: describeError(error, redact) }), Effect.andThen(Effect.fail(rejected(UNREACHABLE))));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* What the caller is told when the agent does not accept the message, by its status and body. The
|
|
28
|
+
* status and the agent's error code are logged; the body is not.
|
|
29
|
+
*/
|
|
30
|
+
function refusal(status, text) {
|
|
31
|
+
const body = Option.getOrElse(Schema.decodeUnknownOption(Schema.fromJsonString(AgentError))(text), () => ({}));
|
|
32
|
+
return Effect.logWarning("ontology.agent.delivery_refused").pipe(Effect.annotateLogs({ status, diagnostic: body.error ?? "refused" }), Effect.andThen(Effect.fail(refusalMessage(status, body))));
|
|
33
|
+
}
|
|
34
|
+
/** The caller's message for a refusal, by the agent's status and decoded error body. */
|
|
35
|
+
function refusalMessage(status, body) {
|
|
36
|
+
switch (status) {
|
|
37
|
+
case 400:
|
|
38
|
+
return rejected(`The agent refused the message as invalid${body.detail ? `: ${body.detail}` : "."}`);
|
|
39
|
+
case 401:
|
|
40
|
+
case 403:
|
|
41
|
+
return rejected("The agent refused the deployment's token, so the message was not delivered.");
|
|
42
|
+
case 404:
|
|
43
|
+
return rejected("The deployed agent does not take messages from the ontology. Redeploy the agent with an " +
|
|
44
|
+
"SDK that does.");
|
|
45
|
+
case 503:
|
|
46
|
+
return body.error === "channels_disabled"
|
|
47
|
+
? rejected("The agent's channels run only in the production deployment, so it cannot deliver " +
|
|
48
|
+
"the message from here.")
|
|
49
|
+
: rejected("The agent is temporarily unavailable, so the message was not delivered. Try again.");
|
|
50
|
+
default:
|
|
51
|
+
return rejected(UNREACHABLE);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The agent of a deployment that has one, at `https://<VERCEL_PROJECT_PRODUCTION_URL>`. Each message
|
|
56
|
+
* goes to {@link AGENT_MESSAGES_PATH} with the deployment's Vercel OIDC token, which the agent
|
|
57
|
+
* verifies as its own project's. A member's private chat is their live Telegram identity.
|
|
58
|
+
*/
|
|
59
|
+
export function deployedAgentChannel(agentUrl, fetchImpl = fetch) {
|
|
60
|
+
const url = `${agentUrl}${AGENT_MESSAGES_PATH}`;
|
|
61
|
+
return ({ identityToken, store }) => ({
|
|
62
|
+
privateChat: (userId) => store === undefined
|
|
63
|
+
? Effect.succeed(undefined)
|
|
64
|
+
: store
|
|
65
|
+
.listIdentities(userId)
|
|
66
|
+
.pipe(Effect.map((identities) => identities.find((identity) => identity.provider === TELEGRAM)?.externalId)),
|
|
67
|
+
send: (message) => identityToken === undefined || identityToken === ""
|
|
68
|
+
? Effect.fail(rejected("The request carries no Vercel OIDC token, which the agent requires. Messages are " +
|
|
69
|
+
"sent from the production deployment."))
|
|
70
|
+
: Effect.tryPromise({
|
|
71
|
+
try: async (signal) => {
|
|
72
|
+
const response = await fetchImpl(url, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: {
|
|
75
|
+
authorization: `Bearer ${identityToken}`,
|
|
76
|
+
"content-type": "application/json",
|
|
77
|
+
accept: "application/json",
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify(message),
|
|
80
|
+
redirect: "error",
|
|
81
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(DELIVERY_TIMEOUT_MS)]),
|
|
82
|
+
});
|
|
83
|
+
return { ok: response.ok, status: response.status, text: await response.text() };
|
|
84
|
+
},
|
|
85
|
+
catch: (error) => error,
|
|
86
|
+
}).pipe(Effect.catch((error) => deliveryFailed(error, identityToken)), Effect.flatMap((answer) => answer.ok ? Effect.void : refusal(answer.status, answer.text))),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -26,6 +26,20 @@ export declare const PairingRequest: Schema.Struct<{
|
|
|
26
26
|
readonly display: Schema.optionalKey<Schema.NullOr<Schema.String>>;
|
|
27
27
|
}>;
|
|
28
28
|
export type PairingRequest = typeof PairingRequest.Type;
|
|
29
|
+
/**
|
|
30
|
+
* `POST /api/ontology/agent/chats/seen`: a group chat the agent saw a message in, and who wrote, or
|
|
31
|
+
* the paired member who left it, or the agent itself leaving it, or the group it was before it
|
|
32
|
+
* became a supergroup.
|
|
33
|
+
*/
|
|
34
|
+
export declare const ChatSeenRequest: Schema.Struct<{
|
|
35
|
+
readonly provider: Schema.String;
|
|
36
|
+
readonly externalId: Schema.String;
|
|
37
|
+
readonly title: Schema.String;
|
|
38
|
+
readonly memberId: Schema.optionalKey<Schema.String>;
|
|
39
|
+
readonly leftMemberId: Schema.optionalKey<Schema.String>;
|
|
40
|
+
readonly agentLeft: Schema.optionalKey<Schema.Literal<true>>;
|
|
41
|
+
readonly migratedFrom: Schema.optionalKey<Schema.String>;
|
|
42
|
+
}>;
|
|
29
43
|
/** An identity on the wire, as the member's connect page and settings show it. */
|
|
30
44
|
export declare function identityForWire(identity: AgentIdentity): {
|
|
31
45
|
id: string;
|
|
@@ -40,6 +40,32 @@ export const PairingRequest = Schema.Struct({
|
|
|
40
40
|
description: "How the channel names the account, shown to the member who approves.",
|
|
41
41
|
})),
|
|
42
42
|
});
|
|
43
|
+
/** A chat's id on the channel. */
|
|
44
|
+
const ChatId = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(MAX_EXTERNAL_ID_LENGTH));
|
|
45
|
+
/**
|
|
46
|
+
* `POST /api/ontology/agent/chats/seen`: a group chat the agent saw a message in, and who wrote, or
|
|
47
|
+
* the paired member who left it, or the agent itself leaving it, or the group it was before it
|
|
48
|
+
* became a supergroup.
|
|
49
|
+
*/
|
|
50
|
+
export const ChatSeenRequest = Schema.Struct({
|
|
51
|
+
provider: Provider,
|
|
52
|
+
externalId: ChatId.annotate({ description: "The chat's id on the channel." }),
|
|
53
|
+
title: Schema.String.check(Schema.isMaxLength(MAX_DISPLAY_LENGTH)).annotate({
|
|
54
|
+
description: "The chat's name on the channel.",
|
|
55
|
+
}),
|
|
56
|
+
memberId: Schema.optionalKey(Schema.String.annotate({
|
|
57
|
+
description: "The user id of the member who wrote, when the sender is paired.",
|
|
58
|
+
})),
|
|
59
|
+
leftMemberId: Schema.optionalKey(Schema.String.annotate({
|
|
60
|
+
description: "The user id of a paired member who left the chat, to unlink from its members.",
|
|
61
|
+
})),
|
|
62
|
+
agentLeft: Schema.optionalKey(Schema.Literal(true).annotate({
|
|
63
|
+
description: "The agent itself left the chat or was removed: every member is unlinked.",
|
|
64
|
+
})),
|
|
65
|
+
migratedFrom: Schema.optionalKey(ChatId.annotate({
|
|
66
|
+
description: "The chat's id before it became a supergroup: its record moves to externalId.",
|
|
67
|
+
})),
|
|
68
|
+
});
|
|
43
69
|
/** An identity on the wire, as the member's connect page and settings show it. */
|
|
44
70
|
export function identityForWire(identity) {
|
|
45
71
|
return {
|
|
@@ -19,7 +19,8 @@ export interface AgentServerOptions {
|
|
|
19
19
|
readonly exchangeSession: SessionExchange | undefined;
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
|
-
* The routes that pair channel accounts with members for the app's own agent
|
|
22
|
+
* The routes that pair channel accounts with members for the app's own agent, and record the group
|
|
23
|
+
* chats it sees. Runtime routes take
|
|
23
24
|
* only the deployment's runtime token. Member routes take a member's Neon Auth session or token, and
|
|
24
25
|
* refuse API keys and the runtime, even acting as a member: only the member pairs or unpairs.
|
|
25
26
|
* Without the agent tables, it serves nothing.
|
|
@@ -10,11 +10,12 @@ import { secretHash } from "../oauth/protocol.js";
|
|
|
10
10
|
import { clientAddress, fixedWindowLimiter, } from "../rate-limit.js";
|
|
11
11
|
import { OntologyBackend } from "../runtime-contract.js";
|
|
12
12
|
import { logRuntimeRejection } from "../runtime-identity.js";
|
|
13
|
-
import { PAIRING_TTL_MS, PairingRequest, canonicalPairingCode, identityForWire, memberForWire, newPairingCode, } from "./protocol.js";
|
|
13
|
+
import { ChatSeenRequest, PAIRING_TTL_MS, PairingRequest, canonicalPairingCode, identityForWire, memberForWire, newPairingCode, } from "./protocol.js";
|
|
14
14
|
/** The agent's endpoints, under the ontology service's path. */
|
|
15
15
|
export const AGENT_PATH = `${BASE_PATH}/agent`;
|
|
16
16
|
const PAIRINGS_PATH = `${AGENT_PATH}/pairings`;
|
|
17
17
|
const IDENTITIES_PATH = `${AGENT_PATH}/identities`;
|
|
18
|
+
const CHATS_SEEN_PATH = `${AGENT_PATH}/chats/seen`;
|
|
18
19
|
/**
|
|
19
20
|
* Pairings one client address may start per minute; each one is a row in the database. The agent's
|
|
20
21
|
* functions share a few egress addresses, so the limit leaves room for a busy channel.
|
|
@@ -153,6 +154,25 @@ function resolveIdentity(store) {
|
|
|
153
154
|
return json(memberForWire(member));
|
|
154
155
|
});
|
|
155
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* `POST /api/ontology/agent/chats/seen` (runtime): the agent saw a message in a group chat. The chat
|
|
159
|
+
* is recorded, or its title kept current, a paired writer joins its members and a paired member who
|
|
160
|
+
* left is unlinked from them, or all of them when the agent itself left; the answer says what the
|
|
161
|
+
* chat is for, so that the agent knows it.
|
|
162
|
+
*/
|
|
163
|
+
function seeChat(store) {
|
|
164
|
+
return (request) => Effect.gen(function* () {
|
|
165
|
+
const requestId = yield* RequestId;
|
|
166
|
+
const decoded = Schema.decodeUnknownResult(ChatSeenRequest)(yield* jsonBody(request), {
|
|
167
|
+
errors: "all",
|
|
168
|
+
onExcessProperty: "error",
|
|
169
|
+
});
|
|
170
|
+
if (Result.isFailure(decoded)) {
|
|
171
|
+
return invalidBody(requestId, schemaIssues(decoded.failure));
|
|
172
|
+
}
|
|
173
|
+
return json(yield* store.seeChat(decoded.success));
|
|
174
|
+
});
|
|
175
|
+
}
|
|
156
176
|
/** `GET /api/ontology/agent/identities` (member): the member's own live identities. */
|
|
157
177
|
function listIdentities(store, member) {
|
|
158
178
|
return Effect.gen(function* () {
|
|
@@ -174,7 +194,8 @@ function revokeIdentity(store, member) {
|
|
|
174
194
|
});
|
|
175
195
|
}
|
|
176
196
|
/**
|
|
177
|
-
* The routes that pair channel accounts with members for the app's own agent
|
|
197
|
+
* The routes that pair channel accounts with members for the app's own agent, and record the group
|
|
198
|
+
* chats it sees. Runtime routes take
|
|
178
199
|
* only the deployment's runtime token. Member routes take a member's Neon Auth session or token, and
|
|
179
200
|
* refuse API keys and the runtime, even acting as a member: only the member pairs or unpairs.
|
|
180
201
|
* Without the agent tables, it serves nothing.
|
|
@@ -230,6 +251,7 @@ export function agentLayer(options) {
|
|
|
230
251
|
yield* router.add("POST", `${PAIRINGS_PATH}/:code/approve`, asMember((_, member) => approvePairing(store, member)));
|
|
231
252
|
yield* router.add("GET", IDENTITIES_PATH, asMember((_, member) => listIdentities(store, member)));
|
|
232
253
|
yield* router.add("GET", `${IDENTITIES_PATH}/:provider/:external_id`, asRuntime(() => resolveIdentity(store)));
|
|
254
|
+
yield* router.add("POST", CHATS_SEEN_PATH, asRuntime(seeChat(store)));
|
|
233
255
|
yield* router.add("DELETE", `${IDENTITIES_PATH}/:id`, asMember((_, member) => revokeIdentity(store, member)));
|
|
234
256
|
}));
|
|
235
257
|
}
|
|
@@ -47,6 +47,11 @@ export interface AggregateEnvironment {
|
|
|
47
47
|
readonly appCredential?: Redacted.Redacted<string>;
|
|
48
48
|
readonly manifestHash: string;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* A failed call for the operator: name, message and stack, then each cause, with every occurrence
|
|
52
|
+
* of the credential replaced.
|
|
53
|
+
*/
|
|
54
|
+
export declare function describeError(error: unknown, redact: (text: string) => string, depth?: number): string;
|
|
50
55
|
/**
|
|
51
56
|
* SegmentStream's `POST /api/v1/workspaces/{id}/aggregate`, and its per-entity
|
|
52
57
|
* `…/entities/{entity}/aggregate`, called with the workspace app credential. `undefined` when the deployment has none, or when SegmentStream's origin is plain
|
|
@@ -58,7 +58,7 @@ function securePlatform(origin) {
|
|
|
58
58
|
* A failed call for the operator: name, message and stack, then each cause, with every occurrence
|
|
59
59
|
* of the credential replaced.
|
|
60
60
|
*/
|
|
61
|
-
function describeError(error, redact, depth = 0) {
|
|
61
|
+
export function describeError(error, redact, depth = 0) {
|
|
62
62
|
if (!(error instanceof Error)) {
|
|
63
63
|
return redact(String(error));
|
|
64
64
|
}
|
package/dist/service/config.d.ts
CHANGED
|
@@ -37,6 +37,11 @@ export declare const RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES: {
|
|
|
37
37
|
readonly audience: "SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE";
|
|
38
38
|
readonly projectId: "VERCEL_PROJECT_ID";
|
|
39
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* The project's production domain, which Vercel sets: the agent of a deployment with runtime identity
|
|
42
|
+
* takes the messages of `agent_chat.send_message` there.
|
|
43
|
+
*/
|
|
44
|
+
export declare const AGENT_HOST_VARIABLE = "VERCEL_PROJECT_PRODUCTION_URL";
|
|
40
45
|
/** Where the workspace's Neon Auth issues and publishes the keys of its users' tokens. */
|
|
41
46
|
export interface NeonAuthEnvironment {
|
|
42
47
|
/** The Auth URL, such as `https://ep-….neon.tech/ontology/auth`. Its origin is the issuer. */
|
|
@@ -68,6 +73,11 @@ export interface OntologyEnvironment {
|
|
|
68
73
|
* authenticate, acting as the member `X-SegmentStream-Act-As` names.
|
|
69
74
|
*/
|
|
70
75
|
readonly runtimeIdentity?: RuntimeIdentityConfig;
|
|
76
|
+
/**
|
|
77
|
+
* The agent's origin, `https://<VERCEL_PROJECT_PRODUCTION_URL>`, set with `runtimeIdentity`:
|
|
78
|
+
* `agent_chat.send_message` hands messages to the agent there.
|
|
79
|
+
*/
|
|
80
|
+
readonly agentUrl?: string;
|
|
71
81
|
/**
|
|
72
82
|
* The local grant `segmentstream exec --connection …` passed, in development. Without it, actions
|
|
73
83
|
* call connections through SegmentStream with the deployment's Vercel OIDC token.
|