@segmentstream-cloud/ontology 0.13.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 +65 -3
- 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 +7 -2
- package/dist/index.js +5 -1
- package/dist/migrate/apply.js +2 -1
- package/dist/migrate/plan.js +21 -9
- package/dist/migrate/storage.d.ts +12 -0
- package/dist/migrate/storage.js +48 -0
- package/dist/names.d.ts +1 -1
- package/dist/names.js +1 -0
- 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 +10 -0
- package/dist/runtime/agents.js +305 -0
- package/dist/runtime/generated-actions.d.ts +2 -1
- package/dist/runtime/generated-actions.js +18 -13
- package/dist/runtime/service-binding.js +2 -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 +61 -0
- package/dist/service/agent/protocol.js +85 -0
- package/dist/service/agent/server.d.ts +28 -0
- package/dist/service/agent/server.js +257 -0
- package/dist/service/aggregate-platform.d.ts +5 -0
- package/dist/service/aggregate-platform.js +1 -1
- package/dist/service/authentication.d.ts +16 -5
- package/dist/service/authentication.js +51 -6
- package/dist/service/config.d.ts +29 -2
- package/dist/service/config.js +52 -2
- package/dist/service/errors.d.ts +6 -0
- package/dist/service/errors.js +16 -0
- package/dist/service/handlers.d.ts +2 -1
- package/dist/service/handlers.js +2 -2
- package/dist/service/mcp/server.d.ts +6 -0
- package/dist/service/mcp/server.js +15 -10
- package/dist/service/operations.d.ts +6 -2
- package/dist/service/operations.js +13 -5
- package/dist/service/runtime-contract.d.ts +94 -0
- package/dist/service/runtime-identity.d.ts +59 -0
- package/dist/service/runtime-identity.js +102 -0
- package/dist/service/service.d.ts +2 -2
- package/dist/service/service.js +24 -6
- package/package.json +1 -1
package/dist/migrate/plan.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isValueMatched, } from "../manifest.js";
|
|
2
2
|
import { BLOB_TABLES_DDL } from "../storage/tables.js";
|
|
3
3
|
import { SNAKE_CASE_NAME, isSystemProperty, toSnakeCase } from "../names.js";
|
|
4
|
-
import { 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";
|
|
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"]);
|
|
6
6
|
function step(input) {
|
|
7
7
|
return {
|
|
@@ -70,17 +70,29 @@ export function planMigration(input) {
|
|
|
70
70
|
draft.runtimeTables.push(step({
|
|
71
71
|
kind: "createCatalog",
|
|
72
72
|
classification: "additive",
|
|
73
|
-
description: "Create the runtime's role assignment, API key and
|
|
73
|
+
description: "Create the runtime's role assignment, API key, OAuth and agent tables.",
|
|
74
74
|
sql: RUNTIME_TABLES_DDL,
|
|
75
75
|
}));
|
|
76
76
|
}
|
|
77
|
-
else
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
else {
|
|
78
|
+
// A database an earlier SDK migrated lacks the tables added since; each set arrives on its own.
|
|
79
|
+
const lacks = (tables) => tables.some((table) => input.existingTables?.has(table) === false);
|
|
80
|
+
if (lacks(OAUTH_TABLES)) {
|
|
81
|
+
draft.runtimeTables.push(step({
|
|
82
|
+
kind: "createCatalog",
|
|
83
|
+
classification: "additive",
|
|
84
|
+
description: "Create the runtime's OAuth tables, which connect agents to the MCP server.",
|
|
85
|
+
sql: OAUTH_TABLES_DDL,
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
if (lacks(AGENT_TABLES)) {
|
|
89
|
+
draft.runtimeTables.push(step({
|
|
90
|
+
kind: "createCatalog",
|
|
91
|
+
classification: "additive",
|
|
92
|
+
description: "Create the runtime's agent tables, which pair channel accounts with members.",
|
|
93
|
+
sql: AGENT_TABLES_DDL,
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
84
96
|
}
|
|
85
97
|
if (storedTargets.some((entity) => entity.properties.some((property) => property.type === "blob")) &&
|
|
86
98
|
!current?.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))) {
|
|
@@ -164,6 +164,18 @@ export declare const OAUTH_TOKENS_TABLE = "_oauth_tokens";
|
|
|
164
164
|
export declare const OAUTH_TABLES: ReadonlyArray<string>;
|
|
165
165
|
/** The OAuth tables of the MCP server's authorization server. Every statement is idempotent. */
|
|
166
166
|
export declare const OAUTH_TABLES_DDL: ReadonlyArray<string>;
|
|
167
|
+
/** Channel accounts waiting, for ten minutes, for a member to claim them with a short code. */
|
|
168
|
+
export declare const AGENT_PAIRINGS_TABLE = "_agent_pairings";
|
|
169
|
+
/** Channel accounts paired with members: the app's agent acts as the member for their messages. */
|
|
170
|
+
export declare const AGENT_IDENTITIES_TABLE = "_agent_identities";
|
|
171
|
+
/** The agent's tables, in the order they are created. */
|
|
172
|
+
export declare const AGENT_TABLES: ReadonlyArray<string>;
|
|
173
|
+
/**
|
|
174
|
+
* The tables of the app's agent: pairing codes, stored only as SHA-256 hashes, and the identities
|
|
175
|
+
* they create. An account and a member each have at most one live identity per provider. Every
|
|
176
|
+
* statement is idempotent.
|
|
177
|
+
*/
|
|
178
|
+
export declare const AGENT_TABLES_DDL: ReadonlyArray<string>;
|
|
167
179
|
/**
|
|
168
180
|
* The runtime's own tables beside the catalog. They are created with it, after the entity tables
|
|
169
181
|
* because they refer to the built-in user table, and every statement is idempotent.
|
package/dist/migrate/storage.js
CHANGED
|
@@ -524,6 +524,53 @@ export const OAUTH_TABLES_DDL = [
|
|
|
524
524
|
`CREATE INDEX IF NOT EXISTS "${OAUTH_TOKENS_TABLE}_grant_id_idx" ` +
|
|
525
525
|
`ON ${quoteIdentifier(OAUTH_TOKENS_TABLE)} ("grant_id")`,
|
|
526
526
|
];
|
|
527
|
+
/** Channel accounts waiting, for ten minutes, for a member to claim them with a short code. */
|
|
528
|
+
export const AGENT_PAIRINGS_TABLE = "_agent_pairings";
|
|
529
|
+
/** Channel accounts paired with members: the app's agent acts as the member for their messages. */
|
|
530
|
+
export const AGENT_IDENTITIES_TABLE = "_agent_identities";
|
|
531
|
+
/** The agent's tables, in the order they are created. */
|
|
532
|
+
export const AGENT_TABLES = [AGENT_PAIRINGS_TABLE, AGENT_IDENTITIES_TABLE];
|
|
533
|
+
/**
|
|
534
|
+
* The tables of the app's agent: pairing codes, stored only as SHA-256 hashes, and the identities
|
|
535
|
+
* they create. An account and a member each have at most one live identity per provider. Every
|
|
536
|
+
* statement is idempotent.
|
|
537
|
+
*/
|
|
538
|
+
export const AGENT_TABLES_DDL = [
|
|
539
|
+
[
|
|
540
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdentifier(AGENT_PAIRINGS_TABLE)} (`,
|
|
541
|
+
` "id" uuid NOT NULL,`,
|
|
542
|
+
` "code_hash" text NOT NULL,`,
|
|
543
|
+
` "provider" text NOT NULL,`,
|
|
544
|
+
` "external_id" text NOT NULL,`,
|
|
545
|
+
` "display" text,`,
|
|
546
|
+
` "created_at" timestamptz NOT NULL DEFAULT now(),`,
|
|
547
|
+
` "expires_at" timestamptz NOT NULL,`,
|
|
548
|
+
` "consumed_at" timestamptz,`,
|
|
549
|
+
` CONSTRAINT "${AGENT_PAIRINGS_TABLE}_pkey" PRIMARY KEY ("id"),`,
|
|
550
|
+
` CONSTRAINT "${AGENT_PAIRINGS_TABLE}_code_hash_key" UNIQUE ("code_hash")`,
|
|
551
|
+
`)`,
|
|
552
|
+
].join("\n"),
|
|
553
|
+
[
|
|
554
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdentifier(AGENT_IDENTITIES_TABLE)} (`,
|
|
555
|
+
` "id" uuid NOT NULL,`,
|
|
556
|
+
` "provider" text NOT NULL,`,
|
|
557
|
+
` "external_id" text NOT NULL,`,
|
|
558
|
+
` "user_id" uuid NOT NULL,`,
|
|
559
|
+
` "display" text,`,
|
|
560
|
+
` "created_at" timestamptz NOT NULL DEFAULT now(),`,
|
|
561
|
+
` "revoked_at" timestamptz,`,
|
|
562
|
+
` CONSTRAINT "${AGENT_IDENTITIES_TABLE}_pkey" PRIMARY KEY ("id"),`,
|
|
563
|
+
` CONSTRAINT "${AGENT_IDENTITIES_TABLE}_user_id_fkey" FOREIGN KEY ("user_id") ` +
|
|
564
|
+
`REFERENCES ${quoteIdentifier(USER_TABLE)} ("id") ON DELETE CASCADE`,
|
|
565
|
+
`)`,
|
|
566
|
+
].join("\n"),
|
|
567
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "${AGENT_IDENTITIES_TABLE}_account_key" ` +
|
|
568
|
+
`ON ${quoteIdentifier(AGENT_IDENTITIES_TABLE)} ("provider", "external_id") ` +
|
|
569
|
+
`WHERE "revoked_at" IS NULL`,
|
|
570
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "${AGENT_IDENTITIES_TABLE}_member_key" ` +
|
|
571
|
+
`ON ${quoteIdentifier(AGENT_IDENTITIES_TABLE)} ("user_id", "provider") ` +
|
|
572
|
+
`WHERE "revoked_at" IS NULL`,
|
|
573
|
+
];
|
|
527
574
|
/**
|
|
528
575
|
* The runtime's own tables beside the catalog. They are created with it, after the entity tables
|
|
529
576
|
* because they refer to the built-in user table, and every statement is idempotent.
|
|
@@ -556,6 +603,7 @@ export const RUNTIME_TABLES_DDL = [
|
|
|
556
603
|
].join("\n"),
|
|
557
604
|
`CREATE INDEX IF NOT EXISTS "${API_KEYS_TABLE}_user_id_idx" ON ${quoteIdentifier(API_KEYS_TABLE)} ("user_id")`,
|
|
558
605
|
...OAUTH_TABLES_DDL,
|
|
606
|
+
...AGENT_TABLES_DDL,
|
|
559
607
|
];
|
|
560
608
|
export function columnDefinition(column) {
|
|
561
609
|
const parts = [quoteIdentifier(column.name), column.type];
|
package/dist/names.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export declare const RESERVED_MEMBER_NAMES: readonly ["and", "or", "not"];
|
|
|
23
23
|
/** The ontology service's route for aggregates across entities: `/api/ontology/aggregate`. */
|
|
24
24
|
export declare const AGGREGATE_ROUTE = "aggregate";
|
|
25
25
|
/** Routes of the ontology service under `/api/ontology/`, which an entity's routes would shadow. */
|
|
26
|
-
export declare const RESERVED_ENTITY_NAMES: readonly ["aggregate", "graph", "status", "describe", "openapi", "docs", "mcp", "me", "oauth"];
|
|
26
|
+
export declare const RESERVED_ENTITY_NAMES: readonly ["aggregate", "graph", "status", "describe", "openapi", "docs", "mcp", "me", "oauth", "agent"];
|
|
27
27
|
/** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
|
|
28
28
|
export declare const MCP_READ_TOOLS: readonly ["find", "get", "history"];
|
|
29
29
|
/** The MCP tools of the ontology as a whole. No entity tool can take them: they have no underscore. */
|
package/dist/names.js
CHANGED
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));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { SqlClient } from "effect/unstable/sql";
|
|
2
|
+
import type { Ontology } from "../ontology.js";
|
|
3
|
+
import type { AgentStore } from "../service/runtime-contract.js";
|
|
4
|
+
/**
|
|
5
|
+
* The agent store on the runtime's agent tables, as `ontology_app`. Expiry is the database's
|
|
6
|
+
* `now()`; a pairing is consumed with one `UPDATE … WHERE "consumed_at" IS NULL`, so two concurrent
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
export declare function sqlAgentStore(ontology: Ontology, sql: SqlClient.SqlClient): AgentStore;
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { SqlClient } from "effect/unstable/sql";
|
|
4
|
+
import { AgentChat } from "../agent-chat.js";
|
|
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";
|
|
8
|
+
import { resolveMemberById } from "./principals.js";
|
|
9
|
+
import { isRecordId } from "./records.js";
|
|
10
|
+
import { makeOntologyRuntime } from "./sql-runtime.js";
|
|
11
|
+
const PAIRINGS = `"${AGENT_PAIRINGS_TABLE}"`;
|
|
12
|
+
const IDENTITIES = `"${AGENT_IDENTITIES_TABLE}"`;
|
|
13
|
+
const USERS = `"${USER_TABLE}"`;
|
|
14
|
+
/** A timestamp as epoch milliseconds, which every driver returns as a plain number. */
|
|
15
|
+
function epochMs(column) {
|
|
16
|
+
return `(extract(epoch from ${column}) * 1000)::double precision`;
|
|
17
|
+
}
|
|
18
|
+
const PAIRING_COLUMNS = `"id"::text AS "id", "provider", "external_id", "display", ` +
|
|
19
|
+
`${epochMs(`"expires_at"`)} AS "expires_at"`;
|
|
20
|
+
const IDENTITY_COLUMNS = `"id"::text AS "id", "provider", "external_id", "user_id"::text AS "user_id", "display", ` +
|
|
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
|
+
}
|
|
32
|
+
function nullableText(value) {
|
|
33
|
+
return value === null || value === undefined ? null : String(value);
|
|
34
|
+
}
|
|
35
|
+
function pairingOf(row) {
|
|
36
|
+
return {
|
|
37
|
+
id: String(row.id),
|
|
38
|
+
provider: String(row.provider),
|
|
39
|
+
externalId: String(row.external_id),
|
|
40
|
+
display: nullableText(row.display),
|
|
41
|
+
expiresAt: new Date(Number(row.expires_at)),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function identityOf(row) {
|
|
45
|
+
return {
|
|
46
|
+
id: String(row.id),
|
|
47
|
+
provider: String(row.provider),
|
|
48
|
+
externalId: String(row.external_id),
|
|
49
|
+
userId: String(row.user_id),
|
|
50
|
+
display: nullableText(row.display),
|
|
51
|
+
createdAt: new Date(Number(row.created_at)),
|
|
52
|
+
revokedAt: row.revoked_at === null ? null : new Date(Number(row.revoked_at)),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The agent store on the runtime's agent tables, as `ontology_app`. Expiry is the database's
|
|
57
|
+
* `now()`; a pairing is consumed with one `UPDATE … WHERE "consumed_at" IS NULL`, so two concurrent
|
|
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.
|
|
60
|
+
*/
|
|
61
|
+
export function sqlAgentStore(ontology, sql) {
|
|
62
|
+
const rows = (statement, values = []) => sql.unsafe(statement, values).pipe(Effect.orDie);
|
|
63
|
+
const memberPrincipal = (userId) => resolveMemberById(ontology, userId).pipe(Effect.provideService(SqlClient.SqlClient, sql));
|
|
64
|
+
return {
|
|
65
|
+
savePairing: (pairing) => sql
|
|
66
|
+
.withTransaction(Effect.gen(function* () {
|
|
67
|
+
yield* rows(`DELETE FROM ${PAIRINGS} WHERE "expires_at" <= now()`);
|
|
68
|
+
const inserted = yield* rows([
|
|
69
|
+
`INSERT INTO ${PAIRINGS}`,
|
|
70
|
+
` ("id", "code_hash", "provider", "external_id", "display", "expires_at")`,
|
|
71
|
+
`VALUES ($1::uuid, $2, $3, $4, $5, $6::timestamptz)`,
|
|
72
|
+
`ON CONFLICT ("code_hash") DO NOTHING`,
|
|
73
|
+
`RETURNING "id"`,
|
|
74
|
+
].join("\n"), [
|
|
75
|
+
pairing.id,
|
|
76
|
+
pairing.codeHash,
|
|
77
|
+
pairing.provider,
|
|
78
|
+
pairing.externalId,
|
|
79
|
+
pairing.display,
|
|
80
|
+
pairing.expiresAt.toISOString(),
|
|
81
|
+
]);
|
|
82
|
+
return inserted.length > 0;
|
|
83
|
+
}))
|
|
84
|
+
.pipe(Effect.orDie),
|
|
85
|
+
findPairing: (codeHash) => rows([
|
|
86
|
+
`SELECT ${PAIRING_COLUMNS}`,
|
|
87
|
+
`FROM ${PAIRINGS}`,
|
|
88
|
+
`WHERE "code_hash" = $1 AND "consumed_at" IS NULL AND "expires_at" > now()`,
|
|
89
|
+
].join("\n"), [codeHash]).pipe(Effect.map(([row]) => (row === undefined ? undefined : pairingOf(row)))),
|
|
90
|
+
approvePairing: ({ codeHash, userId, identityId }) => sql
|
|
91
|
+
.withTransaction(Effect.gen(function* () {
|
|
92
|
+
const [consumed] = yield* rows([
|
|
93
|
+
`UPDATE ${PAIRINGS} SET "consumed_at" = now()`,
|
|
94
|
+
`WHERE "code_hash" = $1 AND "consumed_at" IS NULL AND "expires_at" > now()`,
|
|
95
|
+
`RETURNING ${PAIRING_COLUMNS}`,
|
|
96
|
+
].join("\n"), [codeHash]);
|
|
97
|
+
if (consumed === undefined) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const pairing = pairingOf(consumed);
|
|
101
|
+
// Two approvals that replace the same account's or the same member's identity run one
|
|
102
|
+
// after the other, so the partial unique indexes never refuse a valid one.
|
|
103
|
+
yield* rows(`SELECT pg_advisory_xact_lock(hashtext($1)), pg_advisory_xact_lock(hashtext($2))`, [
|
|
104
|
+
`account:${pairing.provider}:${pairing.externalId}`,
|
|
105
|
+
`member:${userId}:${pairing.provider}`,
|
|
106
|
+
]);
|
|
107
|
+
yield* rows([
|
|
108
|
+
`UPDATE ${IDENTITIES} SET "revoked_at" = now()`,
|
|
109
|
+
`WHERE "revoked_at" IS NULL AND "provider" = $1`,
|
|
110
|
+
` AND ("external_id" = $2 OR "user_id" = $3::uuid)`,
|
|
111
|
+
].join("\n"), [pairing.provider, pairing.externalId, userId]);
|
|
112
|
+
const [created] = yield* rows([
|
|
113
|
+
`INSERT INTO ${IDENTITIES} ("id", "provider", "external_id", "user_id", "display")`,
|
|
114
|
+
`VALUES ($1::uuid, $2, $3, $4::uuid, $5)`,
|
|
115
|
+
`RETURNING ${IDENTITY_COLUMNS}`,
|
|
116
|
+
].join("\n"), [identityId, pairing.provider, pairing.externalId, userId, pairing.display]);
|
|
117
|
+
return created === undefined ? undefined : identityOf(created);
|
|
118
|
+
}))
|
|
119
|
+
.pipe(Effect.orDie),
|
|
120
|
+
findIdentity: ({ provider, externalId }) => rows([
|
|
121
|
+
`SELECT ${IDENTITY_COLUMNS}`,
|
|
122
|
+
`FROM ${IDENTITIES}`,
|
|
123
|
+
`WHERE "provider" = $1 AND "external_id" = $2`,
|
|
124
|
+
`ORDER BY "revoked_at" IS NOT NULL, "revoked_at" DESC`,
|
|
125
|
+
`LIMIT 1`,
|
|
126
|
+
].join("\n"), [provider, externalId]).pipe(Effect.map(([row]) => (row === undefined ? undefined : identityOf(row)))),
|
|
127
|
+
listIdentities: (userId) => isRecordId(userId)
|
|
128
|
+
? rows([
|
|
129
|
+
`SELECT ${IDENTITY_COLUMNS}`,
|
|
130
|
+
`FROM ${IDENTITIES}`,
|
|
131
|
+
`WHERE "user_id" = $1::uuid AND "revoked_at" IS NULL`,
|
|
132
|
+
`ORDER BY "created_at", "id"`,
|
|
133
|
+
].join("\n"), [userId]).pipe(Effect.map((found) => found.map(identityOf)))
|
|
134
|
+
: Effect.succeed([]),
|
|
135
|
+
revokeIdentity: ({ id, userId }) => isRecordId(id) && isRecordId(userId)
|
|
136
|
+
? rows([
|
|
137
|
+
`UPDATE ${IDENTITIES} SET "revoked_at" = now()`,
|
|
138
|
+
`WHERE "id" = $1::uuid AND "user_id" = $2::uuid AND "revoked_at" IS NULL`,
|
|
139
|
+
`RETURNING "id"`,
|
|
140
|
+
].join("\n"), [id, userId]).pipe(Effect.map((revoked) => revoked.length > 0))
|
|
141
|
+
: Effect.succeed(false),
|
|
142
|
+
member: (userId) => Effect.gen(function* () {
|
|
143
|
+
if (!isRecordId(userId)) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
const principal = yield* memberPrincipal(userId);
|
|
147
|
+
if (principal === undefined) {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
const [user] = yield* rows(`SELECT "name" FROM ${USERS} WHERE "id" = $1::uuid`, [userId]);
|
|
151
|
+
return { name: String(user?.name ?? ""), principal };
|
|
152
|
+
}),
|
|
153
|
+
actingMember: (userId) => Effect.gen(function* () {
|
|
154
|
+
if (!isRecordId(userId)) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
const [paired] = yield* rows(`SELECT 1 FROM ${IDENTITIES} WHERE "user_id" = $1::uuid AND "revoked_at" IS NULL LIMIT 1`, [userId]);
|
|
158
|
+
return paired === undefined ? undefined : yield* memberPrincipal(userId);
|
|
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),
|
|
304
|
+
};
|
|
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. */
|