@segmentstream-cloud/ontology 0.15.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 +39 -0
- package/dist/migrate/apply.js +2 -0
- package/dist/migrate/plan.js +14 -4
- 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/mcp/server.js +26 -3
- package/dist/service/mcp/tools.d.ts +6 -0
- package/dist/service/mcp/tools.js +33 -1
- package/dist/service/runtime-contract.d.ts +3 -0
- package/dist/service/service.js +11 -1
- 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/README.md
CHANGED
|
@@ -311,6 +311,45 @@ pair, or when the agent refuses, the action is rejected with the reason. Both ac
|
|
|
311
311
|
`agent_chat:send_message`), which no role holds until the workspace grants it, and reading
|
|
312
312
|
chats needs `agent_chat:read` by name: `*:read` and `*:*` do not cover `agent_chat`.
|
|
313
313
|
|
|
314
|
+
### Agent attachments
|
|
315
|
+
|
|
316
|
+
A file a member sends the agent is a temporary attachment: platform metadata in the runtime
|
|
317
|
+
table `_agent_attachments`, never an ontology record, with its bytes stored once as a revision in
|
|
318
|
+
the ontology's file storage, beside uploads. It belongs to the member, the chat it arrived in and
|
|
319
|
+
the agent session that first reads it, holds its SHA-256, size, type and name, and expires 24
|
|
320
|
+
hours after receipt (`received` → `ready` → `promoted`, `discarded` or `expired`). Files are up
|
|
321
|
+
to 20 MB, what Telegram lets a bot download; a member holds at most 50 live attachments. The
|
|
322
|
+
table exists on deployments with file storage; the migration adds it beside the file tables, also
|
|
323
|
+
to a database an earlier SDK migrated.
|
|
324
|
+
|
|
325
|
+
Only the agent calls these routes, with its runtime token, `X-SegmentStream-Act-As`, the chat in
|
|
326
|
+
`X-SegmentStream-Agent-Conversation` (`telegram:<chat id>`) and, inside a turn, the session in
|
|
327
|
+
`X-SegmentStream-Agent-Session`. Another member's attachment, another chat's or another
|
|
328
|
+
session's answers 404; an expired or discarded one answers 409; a deactivated member's 403.
|
|
329
|
+
|
|
330
|
+
| Route | Does |
|
|
331
|
+
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
332
|
+
| `POST /api/ontology/agent/attachments` | `{ source, source_ref, filename, content_type, size, sha256 }` → `{ attachment, upload? }`, a PUT grant |
|
|
333
|
+
| `POST /api/ontology/agent/attachments/{id}/complete` | Verifies the stored bytes' size and SHA-256 → `{ attachment }` in `ready` |
|
|
334
|
+
| `GET /api/ontology/agent/attachments` | `{ attachments }`: the caller's readable attachments in this chat and session |
|
|
335
|
+
| `GET /api/ontology/agent/attachments/{id}` | `{ attachment, download }`: a one-minute GET grant |
|
|
336
|
+
| `POST /api/ontology/agent/attachments/{id}/promote` | `{ entity, action, input?, field?, record_id? }` → `{ attachment, deduplicated, result }` |
|
|
337
|
+
| `POST /api/ontology/agent/attachments/{id}/discard` | Revokes it at once → `{ attachment }` |
|
|
338
|
+
|
|
339
|
+
Promotion keeps a file by running an action that takes an upload, such as an authored
|
|
340
|
+
`document.upload` with `uploads.for(Document, "content")`, as the member and with their
|
|
341
|
+
permissions. The service mints a completed upload ticket on the attachment's existing revision,
|
|
342
|
+
fills the action's upload field (and `sha256`, when the input has one and the caller left it
|
|
343
|
+
out), and runs the action with an idempotency key derived from the attachment, action and
|
|
344
|
+
input, and the ticket's create intent; the action consumes the ticket as it would a browser
|
|
345
|
+
upload, without copying the bytes. The target property's `accept` and `maxBytes` apply. A file
|
|
346
|
+
whose SHA-256 an earlier attachment already promoted through the same action answers that
|
|
347
|
+
promotion with `deduplicated: true` instead of a second record. Storage maintenance expires
|
|
348
|
+
attachments past their deadline and removes the files no record adopted.
|
|
349
|
+
|
|
350
|
+
An MCP action tool whose input takes an upload also accepts `_options: { idempotency_key,
|
|
351
|
+
create_intent }`, the options the REST route takes beside `input`, so an MCP client can run it.
|
|
352
|
+
|
|
314
353
|
## The `segmentstream-ontology` binary
|
|
315
354
|
|
|
316
355
|
Run the binary in the `ontology/` folder. The entry defaults to `./index.ts`. The binary
|
package/dist/migrate/apply.js
CHANGED
|
@@ -6,6 +6,7 @@ import { isNoopMigration } from "./model.js";
|
|
|
6
6
|
import { planMigration } from "./plan.js";
|
|
7
7
|
import { REPLICATION_DDL, REPLICATION_SCHEMA_VERSION, replicationEntityDdl, } from "../replication/schema.js";
|
|
8
8
|
import { createReplicationBaseline } from "../replication/baseline.js";
|
|
9
|
+
import { ATTACHMENTS_TABLE } from "../storage/tables.js";
|
|
9
10
|
import { resolveReplicationSelection } from "../replication/model.js";
|
|
10
11
|
import { AGENT_TABLES, API_KEYS_TABLE, CATALOG_TABLE, OAUTH_TABLES, USER_ROLES_TABLE, quoteIdentifier, quoteLiteral, } from "./storage.js";
|
|
11
12
|
const CATALOG = quoteIdentifier(CATALOG_TABLE);
|
|
@@ -172,6 +173,7 @@ export const RUNTIME_TABLES = [
|
|
|
172
173
|
API_KEYS_TABLE,
|
|
173
174
|
...OAUTH_TABLES,
|
|
174
175
|
...AGENT_TABLES,
|
|
176
|
+
ATTACHMENTS_TABLE,
|
|
175
177
|
];
|
|
176
178
|
/**
|
|
177
179
|
* The runtime tables the database has, for `planMigration`'s `existingTables`: a database that an
|
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 });
|
|
@@ -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>>;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { Cause, Effect, Layer, Redacted, Result, Schema } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
|
|
4
|
+
import { ATTACHMENT_SOURCES, } from "../../storage/attachments.js";
|
|
5
|
+
import { schemaIssues } from "../../runtime/validation.js";
|
|
6
|
+
import { CurrentPrincipal } from "../authentication.js";
|
|
7
|
+
import { RequestId, errorBodyForWire, failureResponse, forbidden, toServerResponse, unauthorized, unavailable, } from "../errors.js";
|
|
8
|
+
import { identityToken, makeOperations } from "../operations.js";
|
|
9
|
+
import { OntologyBackend } from "../runtime-contract.js";
|
|
10
|
+
import { ACT_AS_HEADER, logRuntimeRejection, } from "../runtime-identity.js";
|
|
11
|
+
import { AGENT_PATH } from "./server.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 const ATTACHMENTS_PATH = `${AGENT_PATH}/attachments`;
|
|
20
|
+
/** The chat an attachment belongs to, such as `telegram:12345`. */
|
|
21
|
+
export const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
|
|
22
|
+
/** The agent session the request comes from. */
|
|
23
|
+
export const SESSION_HEADER = "x-segmentstream-agent-session";
|
|
24
|
+
const CONVERSATION = /^[a-z][a-z0-9_-]{0,31}:[A-Za-z0-9:_-]{1,200}$/;
|
|
25
|
+
const SESSION = /^[A-Za-z0-9:_.-]{1,200}$/;
|
|
26
|
+
const NO_STORE = { "cache-control": "no-store" };
|
|
27
|
+
const Received = Schema.Struct({
|
|
28
|
+
source: Schema.Literals(ATTACHMENT_SOURCES),
|
|
29
|
+
source_ref: Schema.String,
|
|
30
|
+
filename: Schema.String,
|
|
31
|
+
content_type: Schema.String,
|
|
32
|
+
size: Schema.Int,
|
|
33
|
+
sha256: Schema.String,
|
|
34
|
+
});
|
|
35
|
+
const Promotion = Schema.Struct({
|
|
36
|
+
entity: Schema.String,
|
|
37
|
+
action: Schema.String,
|
|
38
|
+
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
|
|
39
|
+
field: Schema.optionalKey(Schema.String),
|
|
40
|
+
record_id: Schema.optionalKey(Schema.String),
|
|
41
|
+
});
|
|
42
|
+
function json(body, status = 200) {
|
|
43
|
+
return HttpServerResponse.jsonUnsafe(body, { status, headers: NO_STORE });
|
|
44
|
+
}
|
|
45
|
+
function attachmentForWire(attachment) {
|
|
46
|
+
return {
|
|
47
|
+
id: attachment.id,
|
|
48
|
+
filename: attachment.filename,
|
|
49
|
+
content_type: attachment.contentType,
|
|
50
|
+
size: attachment.size,
|
|
51
|
+
sha256: attachment.sha256,
|
|
52
|
+
state: attachment.state,
|
|
53
|
+
created_at: attachment.createdAt,
|
|
54
|
+
expires_at: attachment.expiresAt,
|
|
55
|
+
promotion: attachment.promotion,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function grantForWire(grant) {
|
|
59
|
+
return {
|
|
60
|
+
url: grant.url,
|
|
61
|
+
method: grant.method,
|
|
62
|
+
headers: grant.headers,
|
|
63
|
+
expires_at: grant.expiresAt,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function decodeBody(schema, request) {
|
|
67
|
+
return Effect.gen(function* () {
|
|
68
|
+
const text = yield* Effect.orDie(request.text);
|
|
69
|
+
let body;
|
|
70
|
+
try {
|
|
71
|
+
body = JSON.parse(text);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
body = undefined;
|
|
75
|
+
}
|
|
76
|
+
const decoded = yield* Effect.result(Schema.decodeUnknownEffect(schema)(body, { errors: "all", onExcessProperty: "error" }));
|
|
77
|
+
return Result.isFailure(decoded)
|
|
78
|
+
? { ok: false, issues: schemaIssues(decoded.failure) }
|
|
79
|
+
: { ok: true, value: decoded.success };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function invalidBody(requestId, issues) {
|
|
83
|
+
return HttpServerResponse.jsonUnsafe(errorBodyForWire({
|
|
84
|
+
error: "invalid_request",
|
|
85
|
+
detail: "The request body is invalid.",
|
|
86
|
+
requestId,
|
|
87
|
+
issues: issues,
|
|
88
|
+
}), { status: 400 });
|
|
89
|
+
}
|
|
90
|
+
/** Answers a failure the way every other route does: expected ones by status, the rest as 500. */
|
|
91
|
+
function answered(effect) {
|
|
92
|
+
return Effect.gen(function* () {
|
|
93
|
+
const requestId = yield* RequestId;
|
|
94
|
+
return yield* effect.pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
|
|
95
|
+
? Effect.failCause(cause)
|
|
96
|
+
: failureResponse(cause, requestId)));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The attachment routes. Without file storage or the agent tables, or while runtime authentication
|
|
101
|
+
* is disabled, it serves nothing.
|
|
102
|
+
*/
|
|
103
|
+
export function attachmentsLayer(options) {
|
|
104
|
+
return Layer.effectDiscard(Effect.gen(function* () {
|
|
105
|
+
const router = yield* HttpRouter.HttpRouter;
|
|
106
|
+
const backend = yield* OntologyBackend;
|
|
107
|
+
const attachments = backend.attachments;
|
|
108
|
+
const agents = backend.agents;
|
|
109
|
+
const verifyRuntime = options.verifyRuntime;
|
|
110
|
+
if (attachments === undefined || agents === undefined || verifyRuntime === undefined) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const operations = makeOperations(backend, options.plan, options.hooks, options.connections, options.replicate, options.agentChannel);
|
|
114
|
+
const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
|
|
115
|
+
/** Authenticates the runtime, then the member it acts for, the chat and the session. */
|
|
116
|
+
const asAgent = (answer) => (request) => Effect.gen(function* () {
|
|
117
|
+
const requestId = yield* RequestId;
|
|
118
|
+
const bearer = yield* Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request);
|
|
119
|
+
const token = Redacted.value(bearer);
|
|
120
|
+
const verified = token === "" ? undefined : yield* Effect.promise(() => verifyRuntime(token));
|
|
121
|
+
switch (verified?.status) {
|
|
122
|
+
case "valid":
|
|
123
|
+
break;
|
|
124
|
+
case "invalid":
|
|
125
|
+
yield* logRuntimeRejection(verified.reason);
|
|
126
|
+
return toServerResponse(unauthorized(requestId, "The bearer token is invalid, expired or revoked."));
|
|
127
|
+
case "unavailable":
|
|
128
|
+
return toServerResponse(unavailable(requestId));
|
|
129
|
+
default:
|
|
130
|
+
return toServerResponse(forbidden(requestId, "Only the app's agent calls this route."));
|
|
131
|
+
}
|
|
132
|
+
const actAs = request.headers[ACT_AS_HEADER]?.trim() ?? "";
|
|
133
|
+
const conversation = request.headers[CONVERSATION_HEADER]?.trim() ?? "";
|
|
134
|
+
const session = request.headers[SESSION_HEADER]?.trim() ?? "";
|
|
135
|
+
const member = actAs === "" ? undefined : yield* agents.actingMember(actAs);
|
|
136
|
+
if (member === undefined) {
|
|
137
|
+
return toServerResponse(forbidden(requestId, "The member is inactive or has not paired with the agent."));
|
|
138
|
+
}
|
|
139
|
+
if (!CONVERSATION.test(conversation) || (session !== "" && !SESSION.test(session))) {
|
|
140
|
+
return toServerResponse(forbidden(requestId, "The agent names the chat and session of every request."));
|
|
141
|
+
}
|
|
142
|
+
return yield* answer(request, {
|
|
143
|
+
principal: member,
|
|
144
|
+
conversation,
|
|
145
|
+
...(session === "" ? {} : { sessionId: session }),
|
|
146
|
+
}, attachments);
|
|
147
|
+
});
|
|
148
|
+
const idOf = Effect.map(HttpRouter.params, (params) => params.id ?? "");
|
|
149
|
+
yield* router.add("POST", ATTACHMENTS_PATH, asAgent((request, caller, runtime) => answered(Effect.gen(function* () {
|
|
150
|
+
const requestId = yield* RequestId;
|
|
151
|
+
const body = yield* decodeBody(Received, request);
|
|
152
|
+
if (!body.ok) {
|
|
153
|
+
return invalidBody(requestId, body.issues);
|
|
154
|
+
}
|
|
155
|
+
const received = yield* runtime.receive({
|
|
156
|
+
source: body.value.source,
|
|
157
|
+
sourceRef: body.value.source_ref,
|
|
158
|
+
filename: body.value.filename,
|
|
159
|
+
contentType: body.value.content_type,
|
|
160
|
+
size: body.value.size,
|
|
161
|
+
sha256: body.value.sha256,
|
|
162
|
+
}, caller);
|
|
163
|
+
return json({
|
|
164
|
+
attachment: attachmentForWire(received.attachment),
|
|
165
|
+
...(received.upload ? { upload: grantForWire(received.upload) } : {}),
|
|
166
|
+
}, 201);
|
|
167
|
+
}))));
|
|
168
|
+
yield* router.add("POST", `${ATTACHMENTS_PATH}/:id/complete`, asAgent((_, caller, runtime) => answered(Effect.gen(function* () {
|
|
169
|
+
const attachment = yield* runtime.complete(yield* idOf, caller);
|
|
170
|
+
return json({ attachment: attachmentForWire(attachment) });
|
|
171
|
+
}))));
|
|
172
|
+
yield* router.add("GET", ATTACHMENTS_PATH, asAgent((_, caller, runtime) => answered(Effect.gen(function* () {
|
|
173
|
+
const listed = yield* runtime.list(caller);
|
|
174
|
+
return json({ attachments: listed.map(attachmentForWire) });
|
|
175
|
+
}))));
|
|
176
|
+
yield* router.add("GET", `${ATTACHMENTS_PATH}/:id`, asAgent((_, caller, runtime) => answered(Effect.gen(function* () {
|
|
177
|
+
const { attachment, download } = yield* runtime.read(yield* idOf, caller);
|
|
178
|
+
return json({
|
|
179
|
+
attachment: attachmentForWire(attachment),
|
|
180
|
+
download: grantForWire(download),
|
|
181
|
+
});
|
|
182
|
+
}))));
|
|
183
|
+
yield* router.add("POST", `${ATTACHMENTS_PATH}/:id/discard`, asAgent((_, caller, runtime) => answered(Effect.gen(function* () {
|
|
184
|
+
const attachment = yield* runtime.discard(yield* idOf, caller);
|
|
185
|
+
return json({ attachment: attachmentForWire(attachment) });
|
|
186
|
+
}))));
|
|
187
|
+
yield* router.add("POST", `${ATTACHMENTS_PATH}/:id/promote`, asAgent((request, caller, runtime) => answered(Effect.gen(function* () {
|
|
188
|
+
const requestId = yield* RequestId;
|
|
189
|
+
const body = yield* decodeBody(Promotion, request);
|
|
190
|
+
if (!body.ok) {
|
|
191
|
+
return invalidBody(requestId, body.issues);
|
|
192
|
+
}
|
|
193
|
+
const promotion = body.value;
|
|
194
|
+
const promoted = yield* runtime.promote(yield* idOf, {
|
|
195
|
+
entity: promotion.entity,
|
|
196
|
+
action: promotion.action,
|
|
197
|
+
input: promotion.input ?? {},
|
|
198
|
+
...(promotion.field === undefined ? {} : { field: promotion.field }),
|
|
199
|
+
...(promotion.record_id === undefined ? {} : { recordId: promotion.record_id }),
|
|
200
|
+
}, caller, (call) => Effect.gen(function* () {
|
|
201
|
+
const planned = options.plan.entities.find((entity) => entity.name === call.entity);
|
|
202
|
+
const action = planned?.actions.find((item) => item.name === call.action);
|
|
203
|
+
if (planned === undefined || action === undefined) {
|
|
204
|
+
return yield* Effect.die(new Error("The promotion's action was not planned."));
|
|
205
|
+
}
|
|
206
|
+
// The action runs as the member, with the after-commit hooks and connections
|
|
207
|
+
// of any other call.
|
|
208
|
+
const answer = yield* operations
|
|
209
|
+
.runAction(planned, action, {
|
|
210
|
+
input: call.input,
|
|
211
|
+
validateOnly: false,
|
|
212
|
+
options: call.options,
|
|
213
|
+
identityToken: identityToken(request),
|
|
214
|
+
})
|
|
215
|
+
.pipe(Effect.provideService(CurrentPrincipal, caller.principal), Effect.provideService(RequestId, requestId));
|
|
216
|
+
return { result: answer.result, edits: answer.edits };
|
|
217
|
+
}));
|
|
218
|
+
return json({
|
|
219
|
+
attachment: attachmentForWire(promoted.attachment),
|
|
220
|
+
deduplicated: promoted.deduplicated,
|
|
221
|
+
result: promoted.result,
|
|
222
|
+
});
|
|
223
|
+
}))));
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Cause, Data, Effect, Layer, Option, Schema } from "effect";
|
|
1
|
+
import { Cause, Data, Effect, Layer, Option, Schema, SchemaTransformation } from "effect";
|
|
2
2
|
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
|
3
3
|
import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
|
|
4
4
|
import { encodeManifest } from "../../manifest.js";
|
|
@@ -14,7 +14,8 @@ import { identityToken, makeOperations, toQuery } from "../operations.js";
|
|
|
14
14
|
import { OntologyBackend } from "../runtime-contract.js";
|
|
15
15
|
import { runtimeCredential } from "../runtime-identity.js";
|
|
16
16
|
import { ActionResponse, HistoryPage, HistoryParams, IdParam, Me, } from "../schemas.js";
|
|
17
|
-
import {
|
|
17
|
+
import { uploadFields } from "../../storage/attachments.js";
|
|
18
|
+
import { ACTION_OPTIONS_ARGUMENT, mcpTools, visibleTools } from "./tools.js";
|
|
18
19
|
/** The URL path of the ontology's MCP server, outside `/api/ontology`: one per app. */
|
|
19
20
|
export const MCP_PATH = "/mcp";
|
|
20
21
|
/** The MCP revisions the server speaks, newest first. */
|
|
@@ -66,6 +67,23 @@ function decodeArguments(schema, args) {
|
|
|
66
67
|
onExcessProperty: "error",
|
|
67
68
|
}).pipe(Effect.mapError((error) => new InvalidArguments({ issues: schemaIssues(error.issue) })));
|
|
68
69
|
}
|
|
70
|
+
/** The `_options` argument of an action tool that consumes an upload. */
|
|
71
|
+
const McpActionOptions = Schema.Struct({
|
|
72
|
+
idempotency_key: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(128))),
|
|
73
|
+
create_intent: Schema.optionalKey(Schema.String.check(Schema.isUUID())),
|
|
74
|
+
}).pipe(Schema.decodeTo(Schema.Struct({
|
|
75
|
+
idempotencyKey: Schema.optionalKey(Schema.String),
|
|
76
|
+
createIntent: Schema.optionalKey(Schema.String),
|
|
77
|
+
}), SchemaTransformation.transform({
|
|
78
|
+
decode: (value) => ({
|
|
79
|
+
...(value.idempotency_key === undefined ? {} : { idempotencyKey: value.idempotency_key }),
|
|
80
|
+
...(value.create_intent === undefined ? {} : { createIntent: value.create_intent }),
|
|
81
|
+
}),
|
|
82
|
+
encode: (value) => ({
|
|
83
|
+
...(value.idempotencyKey === undefined ? {} : { idempotency_key: value.idempotencyKey }),
|
|
84
|
+
...(value.createIntent === undefined ? {} : { create_intent: value.createIntent }),
|
|
85
|
+
}),
|
|
86
|
+
})));
|
|
69
87
|
/** What the service sends: a value encoded with the schema of the matching HTTP route. */
|
|
70
88
|
function encoded(schema, value) {
|
|
71
89
|
return Schema.encodeUnknownEffect(schema)(value).pipe(Effect.orDie);
|
|
@@ -178,9 +196,14 @@ function runTool({ built, operations, aggregate }, tool, args, request) {
|
|
|
178
196
|
return structured(yield* encoded(HistoryPage, yield* operations.history(target.planned, id, params)));
|
|
179
197
|
}
|
|
180
198
|
case "action": {
|
|
199
|
+
const { [ACTION_OPTIONS_ARGUMENT]: rawOptions, ...input } = args;
|
|
200
|
+
const options = rawOptions === undefined || uploadFields(target.action.action.input).length === 0
|
|
201
|
+
? undefined
|
|
202
|
+
: yield* decodeArguments(McpActionOptions, rawOptions);
|
|
181
203
|
const answer = yield* operations.runAction(target.planned, target.action, {
|
|
182
|
-
input: args,
|
|
204
|
+
input: options === undefined ? args : input,
|
|
183
205
|
validateOnly: false,
|
|
206
|
+
...(options === undefined ? {} : { options }),
|
|
184
207
|
identityToken: identityToken(request),
|
|
185
208
|
});
|
|
186
209
|
return structured(yield* encoded(ActionResponse, answer));
|
|
@@ -43,6 +43,12 @@ export interface McpTool {
|
|
|
43
43
|
readonly inputSchemaFor?: (caller: PermissionHolder) => JsonSchemaObject | undefined;
|
|
44
44
|
readonly target: McpToolTarget;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* The argument an action that consumes an upload takes beside its input: the idempotency key the
|
|
48
|
+
* runtime requires of it, and the create intent the upload was begun with. The service removes it
|
|
49
|
+
* before the input is validated.
|
|
50
|
+
*/
|
|
51
|
+
export declare const ACTION_OPTIONS_ARGUMENT = "_options";
|
|
46
52
|
/**
|
|
47
53
|
* The MCP tools of an ontology: `describe` and `me`, `aggregate` when the ontology has measures,
|
|
48
54
|
* then for every stored entity `find`, `get`,
|
|
@@ -7,6 +7,7 @@ import { isAllowed, permissionOf, READ_VERB } from "../../permissions.js";
|
|
|
7
7
|
import { queryJsonSchema } from "../../query.js";
|
|
8
8
|
import { measuresFor, RECORDS_OR_AGGREGATES } from "../../render.js";
|
|
9
9
|
import { actionInputJsonSchema } from "../../runtime/generated-actions.js";
|
|
10
|
+
import { uploadFields } from "../../storage/attachments.js";
|
|
10
11
|
import { actionDescription, requires } from "../descriptions.js";
|
|
11
12
|
import { AGGREGATE_FILTER_OPERATORS, AGGREGATE_GRANULARITIES, AGGREGATE_LIMITS, HistoryPage, Me, } from "../schemas.js";
|
|
12
13
|
const DEFS = "#/$defs/";
|
|
@@ -160,6 +161,37 @@ function readTools(planned, schemas, built) {
|
|
|
160
161
|
},
|
|
161
162
|
];
|
|
162
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* The argument an action that consumes an upload takes beside its input: the idempotency key the
|
|
166
|
+
* runtime requires of it, and the create intent the upload was begun with. The service removes it
|
|
167
|
+
* before the input is validated.
|
|
168
|
+
*/
|
|
169
|
+
export const ACTION_OPTIONS_ARGUMENT = "_options";
|
|
170
|
+
const ACTION_OPTIONS_SCHEMA = {
|
|
171
|
+
type: "object",
|
|
172
|
+
description: "Options of an action that stores an uploaded file: idempotency_key, required with an upload, " +
|
|
173
|
+
"and create_intent, the one the upload was begun with when it creates a record.",
|
|
174
|
+
properties: {
|
|
175
|
+
idempotency_key: { type: "string", minLength: 1, maxLength: 128 },
|
|
176
|
+
create_intent: { type: "string", format: "uuid" },
|
|
177
|
+
},
|
|
178
|
+
additionalProperties: false,
|
|
179
|
+
};
|
|
180
|
+
/** The input schema of an action tool; one that consumes an upload also takes its options. */
|
|
181
|
+
function actionToolInput(action) {
|
|
182
|
+
const schema = rootObject(actionInputJsonSchema(action.input));
|
|
183
|
+
const properties = schema.properties;
|
|
184
|
+
if (uploadFields(action.input).length === 0 ||
|
|
185
|
+
schema.type !== "object" ||
|
|
186
|
+
properties === undefined ||
|
|
187
|
+
ACTION_OPTIONS_ARGUMENT in properties) {
|
|
188
|
+
return schema;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
...schema,
|
|
192
|
+
properties: { ...properties, [ACTION_OPTIONS_ARGUMENT]: ACTION_OPTIONS_SCHEMA },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
163
195
|
function actionTool(planned, plannedAction) {
|
|
164
196
|
const { name, action, permissions } = plannedAction;
|
|
165
197
|
const hints = hintsOf(planned, plannedAction);
|
|
@@ -167,7 +199,7 @@ function actionTool(planned, plannedAction) {
|
|
|
167
199
|
name: mcpToolName(planned.name, name),
|
|
168
200
|
title: `${planned.resolved.entity.definition.label}: ${humanize(name).toLowerCase()}`,
|
|
169
201
|
description: actionDescription(plannedAction),
|
|
170
|
-
inputSchema:
|
|
202
|
+
inputSchema: actionToolInput(action),
|
|
171
203
|
annotations: {
|
|
172
204
|
readOnlyHint: false,
|
|
173
205
|
destructiveHint: hints.destructive,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Context, Schema, type Effect, type Layer, type Redacted } from "effect";
|
|
2
|
+
import type { AttachmentRuntime } from "../storage/attachments.js";
|
|
2
3
|
import type { BlobRuntime } from "../storage/sessions.js";
|
|
3
4
|
import type { BlobStorageBinding } from "../storage/provider.js";
|
|
4
5
|
import type { AnyAction } from "../action.js";
|
|
@@ -247,6 +248,8 @@ export interface AgentStore {
|
|
|
247
248
|
}
|
|
248
249
|
declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segmentstream-cloud/ontology/OntologyBackend", {
|
|
249
250
|
readonly blobs?: BlobRuntime;
|
|
251
|
+
/** The agent's temporary attachments, on deployments with file storage. */
|
|
252
|
+
readonly attachments?: AttachmentRuntime;
|
|
250
253
|
readonly maintainBlobs?: Effect.Effect<{
|
|
251
254
|
readonly removed: number;
|
|
252
255
|
}, unknown>;
|
package/dist/service/service.js
CHANGED
|
@@ -4,6 +4,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi";
|
|
|
4
4
|
import { storageMaintenanceAuthenticationLayer } from "../storage/maintenance-auth.js";
|
|
5
5
|
import { nameToWire } from "../authoring-names.js";
|
|
6
6
|
import { sqlServiceRuntime } from "../runtime/service-binding.js";
|
|
7
|
+
import { attachmentsLayer } from "./agent/attachments.js";
|
|
7
8
|
import { deployedAgentChannel } from "./agent/channel.js";
|
|
8
9
|
import { agentLayer } from "./agent/server.js";
|
|
9
10
|
import { makeAggregates } from "./aggregate.js";
|
|
@@ -116,7 +117,16 @@ export function createOntologyService(ontology, config) {
|
|
|
116
117
|
agentChannel,
|
|
117
118
|
}), oauthLayer({ verifyToken }));
|
|
118
119
|
const agent = agentLayer({ verifyToken, verifyRuntime, exchangeSession });
|
|
119
|
-
const
|
|
120
|
+
const attachments = attachmentsLayer({
|
|
121
|
+
plan: built.plan,
|
|
122
|
+
hooks,
|
|
123
|
+
connections,
|
|
124
|
+
replicate,
|
|
125
|
+
agentChannel,
|
|
126
|
+
verifyToken,
|
|
127
|
+
verifyRuntime,
|
|
128
|
+
});
|
|
129
|
+
const application = Layer.mergeAll(routes, mcp, agent, attachments, DocsRoute, UnmatchedRoutes).pipe(Layer.provide(backend), Layer.provide(HttpServer.layerServices));
|
|
120
130
|
const web = HttpRouter.toWebHandler(application, { disableLogger: true });
|
|
121
131
|
const hash = ontology.manifest.hash;
|
|
122
132
|
return {
|