@segmentstream-cloud/ontology 0.14.0 → 0.16.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 +74 -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/migrate/apply.js +2 -0
- package/dist/migrate/plan.js +14 -4
- 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/runtime/service-binding.js +3 -0
- package/dist/service/agent/attachments.d.ts +37 -0
- package/dist/service/agent/attachments.js +225 -0
- 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 +27 -4
- package/dist/service/mcp/tools.d.ts +6 -0
- package/dist/service/mcp/tools.js +33 -1
- package/dist/service/operations.d.ts +6 -2
- package/dist/service/operations.js +13 -5
- package/dist/service/runtime-contract.d.ts +29 -0
- package/dist/service/service.js +18 -2
- package/dist/storage/attachments.d.ts +117 -0
- package/dist/storage/attachments.js +536 -0
- package/dist/storage/maintenance.d.ts +4 -1
- package/dist/storage/maintenance.js +7 -2
- package/dist/storage/neon.js +14 -0
- package/dist/storage/provider.d.ts +2 -0
- package/dist/storage/tables.d.ts +8 -0
- package/dist/storage/tables.js +32 -0
- package/package.json +1 -1
package/dist/migrate/plan.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isValueMatched, } from "../manifest.js";
|
|
2
|
-
import { BLOB_TABLES_DDL } from "../storage/tables.js";
|
|
2
|
+
import { ATTACHMENTS_TABLE, ATTACHMENT_TABLES_DDL, BLOB_TABLES_DDL } from "../storage/tables.js";
|
|
3
3
|
import { SNAKE_CASE_NAME, isSystemProperty, toSnakeCase } from "../names.js";
|
|
4
4
|
import { AGENT_TABLES, AGENT_TABLES_DDL, CATALOG_TABLE_DDL, MAX_IDENTIFIER_LENGTH, OAUTH_TABLES, OAUTH_TABLES_DDL, USER_TABLE, addConstraintStatement, columnDefinition, constraintDefinition, createEnumStatement, createIndexStatement, createTableStatement, defaultExpression, entityStorage, enumTypeName, fieldColumnName, joinTable, propertyCheck, blobForeignKey, propertyColumn, propertyEnum, quoteIdentifier, quoteLiteral, relationshipColumn, RUNTIME_TABLES_DDL, relationshipForeignKey, SYSTEM_COLUMNS, tableName, } from "./storage.js";
|
|
5
5
|
const GLOBAL_KINDS = new Set(["table", "type", "index"]);
|
|
@@ -94,13 +94,23 @@ export function planMigration(input) {
|
|
|
94
94
|
}));
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
-
|
|
97
|
+
const storesFiles = storedTargets.some((entity) => entity.properties.some((property) => property.type === "blob"));
|
|
98
|
+
if (storesFiles &&
|
|
98
99
|
!current?.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))) {
|
|
99
100
|
draft.runtimeTables.push(step({
|
|
100
101
|
kind: "createCatalog",
|
|
101
102
|
classification: "additive",
|
|
102
|
-
description: "Create the ontology's file revision and
|
|
103
|
-
sql: BLOB_TABLES_DDL,
|
|
103
|
+
description: "Create the ontology's file revision, upload and agent attachment tables.",
|
|
104
|
+
sql: [...BLOB_TABLES_DDL, ...ATTACHMENT_TABLES_DDL],
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
else if (storesFiles && input.existingTables?.has(ATTACHMENTS_TABLE) === false) {
|
|
108
|
+
// File storage an earlier SDK created lacks the agent's attachments, added since.
|
|
109
|
+
draft.runtimeTables.push(step({
|
|
110
|
+
kind: "createCatalog",
|
|
111
|
+
classification: "additive",
|
|
112
|
+
description: "Create the agent's temporary attachment table beside the file tables.",
|
|
113
|
+
sql: ATTACHMENT_TABLES_DDL,
|
|
104
114
|
}));
|
|
105
115
|
}
|
|
106
116
|
planDrops({ pairs, removedEntities, addedEntities, allowDrops, draft });
|
package/dist/permissions.d.ts
CHANGED
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
* Permissions are `<entity>:<verb>` strings. The verbs are `read`, the generated `create`, `update`,
|
|
3
3
|
* `delete`, `link` and `unlink`, and the snake_case name of an authored action. A role may use `*` for
|
|
4
4
|
* the entity (`*:read`), the verb (`lead:*`) or both (`*:*`); what an action or a read requires never
|
|
5
|
-
* holds a wildcard.
|
|
5
|
+
* holds a wildcard. A wildcard entity does not cover the entities in {@link EXPLICIT_GRANT_ENTITIES}.
|
|
6
6
|
*/
|
|
7
7
|
/** Any entity or any verb, in a role's permissions. */
|
|
8
8
|
export declare const WILDCARD = "*";
|
|
9
9
|
/** The verb of every read: lists, queries, records, history, traversals and `include`. */
|
|
10
10
|
export declare const READ_VERB = "read";
|
|
11
|
+
/**
|
|
12
|
+
* Entities only a permission that names them covers: `*:read` or `*:*` grant nothing on them. The
|
|
13
|
+
* built-in agent chat is in every ontology, and member's default `*:read` would otherwise show anyone
|
|
14
|
+
* who signs in the workspace's chats and who is in them; `agent_chat:read` or `agent_chat:*` does.
|
|
15
|
+
*/
|
|
16
|
+
export declare const EXPLICIT_GRANT_ENTITIES: ReadonlySet<string>;
|
|
11
17
|
/** What the built-in `member` role holds when `defineOntology` does not say: reading everything. */
|
|
12
18
|
export declare const DEFAULT_MEMBER_PERMISSIONS: ReadonlyArray<string>;
|
|
13
19
|
export interface ParsedPermission {
|
|
@@ -20,7 +26,10 @@ export interface ParsedPermission {
|
|
|
20
26
|
export declare function parsePermission(value: unknown): ParsedPermission | undefined;
|
|
21
27
|
/** `lead:changeStatus`. */
|
|
22
28
|
export declare function permissionOf(entity: string, verb: string): string;
|
|
23
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Whether a granted permission, which may hold wildcards, covers the required one. A wildcard entity
|
|
31
|
+
* covers every entity but those in {@link EXPLICIT_GRANT_ENTITIES}.
|
|
32
|
+
*/
|
|
24
33
|
export declare function grants(granted: string, required: string): boolean;
|
|
25
34
|
/** Anything that holds permissions: a principal, or the reader of a catalog. */
|
|
26
35
|
export interface PermissionHolder {
|
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.",
|
|
@@ -3,6 +3,7 @@ import { Effect, Layer, Redacted } from "effect";
|
|
|
3
3
|
import { SqlClient } from "effect/unstable/sql";
|
|
4
4
|
import { neonBlobProvider } from "../storage/neon.js";
|
|
5
5
|
import { maintainBlobStorage } from "../storage/maintenance.js";
|
|
6
|
+
import { attachmentRuntime } from "../storage/attachments.js";
|
|
6
7
|
import { blobRuntime } from "../storage/sessions.js";
|
|
7
8
|
import { readCatalog } from "../migrate/apply.js";
|
|
8
9
|
import { NotInvited, OntologyBackend, } from "../service/runtime-contract.js";
|
|
@@ -33,8 +34,10 @@ export function backendLayer(ontology, blobStorage) {
|
|
|
33
34
|
? yield* Effect.acquireRelease(Effect.sync(() => neonBlobProvider(binding)), (provider) => Effect.sync(() => provider.close?.()))
|
|
34
35
|
: undefined;
|
|
35
36
|
const blobs = provider ? blobRuntime(ontology, sql, provider) : undefined;
|
|
37
|
+
const attachments = provider ? attachmentRuntime(ontology, sql, provider) : undefined;
|
|
36
38
|
return OntologyBackend.of({
|
|
37
39
|
...(blobs ? { blobs } : {}),
|
|
40
|
+
...(attachments ? { attachments } : {}),
|
|
38
41
|
...(provider ? { maintainBlobs: maintainBlobStorage(sql, provider) } : {}),
|
|
39
42
|
runAction: (input) => withSql(runAction(ontology, {
|
|
40
43
|
entity: input.entity,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { HttpRouter } from "effect/unstable/http";
|
|
3
|
+
import { type TokenVerifier } from "../authentication.js";
|
|
4
|
+
import type { AfterCommitHook } from "../config.js";
|
|
5
|
+
import type { ConnectionTransportFactory } from "../connections.js";
|
|
6
|
+
import { RequestId } from "../errors.js";
|
|
7
|
+
import type { ApiPlan } from "../plan.js";
|
|
8
|
+
import type { ReplicationKick } from "../replication.js";
|
|
9
|
+
import { OntologyBackend } from "../runtime-contract.js";
|
|
10
|
+
import { type RuntimeTokenVerifier } from "../runtime-identity.js";
|
|
11
|
+
import type { AgentChannelFactory } from "./channel.js";
|
|
12
|
+
/**
|
|
13
|
+
* The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
|
|
14
|
+
* deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
|
|
15
|
+
* while they are active and paired. The chat and the agent session come from the agent's runtime in
|
|
16
|
+
* `X-SegmentStream-Agent-Conversation` and `X-SegmentStream-Agent-Session`, never from the model's
|
|
17
|
+
* tool arguments; every operation is confined to that member, chat and session.
|
|
18
|
+
*/
|
|
19
|
+
export declare const ATTACHMENTS_PATH = "/api/ontology/agent/attachments";
|
|
20
|
+
/** The chat an attachment belongs to, such as `telegram:12345`. */
|
|
21
|
+
export declare const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
|
|
22
|
+
/** The agent session the request comes from. */
|
|
23
|
+
export declare const SESSION_HEADER = "x-segmentstream-agent-session";
|
|
24
|
+
export interface AttachmentRoutesOptions {
|
|
25
|
+
readonly plan: ApiPlan;
|
|
26
|
+
readonly hooks: ReadonlyMap<string, AfterCommitHook>;
|
|
27
|
+
readonly connections: ConnectionTransportFactory;
|
|
28
|
+
readonly replicate: ReplicationKick;
|
|
29
|
+
readonly agentChannel?: AgentChannelFactory | undefined;
|
|
30
|
+
readonly verifyToken: TokenVerifier;
|
|
31
|
+
readonly verifyRuntime: RuntimeTokenVerifier | undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The attachment routes. Without file storage or the agent tables, or while runtime authentication
|
|
35
|
+
* is disabled, it serves nothing.
|
|
36
|
+
*/
|
|
37
|
+
export declare function attachmentsLayer(options: AttachmentRoutesOptions): Layer.Layer<never, never, OntologyBackend | HttpRouter.HttpRouter | HttpRouter.Request<"Requires", RequestId>>;
|