@segmentstream-cloud/ontology 0.25.0 → 0.26.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 +78 -0
- package/dist/entity.d.ts +53 -2
- package/dist/entity.js +31 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +8 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/runtime/batch-diagnostics.d.ts +22 -0
- package/dist/runtime/batch-diagnostics.js +89 -0
- package/dist/runtime/batch.d.ts +48 -0
- package/dist/runtime/batch.js +473 -0
- package/dist/runtime/records.d.ts +2 -2
- package/dist/runtime/records.js +25 -6
- package/dist/runtime/sql-runtime.js +125 -5
- package/dist/runtime.d.ts +51 -2
- package/dist/runtime.js +2 -1
- package/dist/service/agent/attachments.d.ts +3 -2
- package/dist/service/agent/attachments.js +4 -2
- package/dist/service/agent/authentication.d.ts +3 -1
- package/dist/service/agent/authentication.js +10 -4
- package/dist/service/errors.js +17 -0
- package/dist/service/schemas.js +4 -0
- package/dist/storage/attachments.d.ts +8 -7
- package/dist/storage/attachments.js +83 -23
- package/dist/storage/tables.d.ts +2 -1
- package/dist/storage/tables.js +2 -1
- package/package.json +4 -2
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { Effect, Option } from "effect";
|
|
1
|
+
import { Effect, Exit, Option } from "effect";
|
|
2
2
|
import { beginReplicationWrite } from "../replication/transaction.js";
|
|
3
3
|
import { isSqlError } from "effect/unstable/sql/SqlError";
|
|
4
|
-
import { Conflict } from "../errors.js";
|
|
4
|
+
import { ActionRejected, Conflict } from "../errors.js";
|
|
5
5
|
import { toSnakeCase } from "../names.js";
|
|
6
6
|
import { User } from "../user.js";
|
|
7
|
+
import { ACTION_MAX_BATCH_ROWS, BATCH_MAX_BYTES, BATCH_MAX_ROWS, batchInputBytes, executeBatch, planBatch, } from "./batch.js";
|
|
8
|
+
import { logBatchConflict } from "./batch-diagnostics.js";
|
|
7
9
|
import { InvalidQuery, WriteOutsideAction } from "./errors.js";
|
|
8
10
|
import { entityModel, ontologyModel } from "./model.js";
|
|
9
11
|
import { loadRecord, notFound, readHistory, relatedCondition, runQuery, validateQuery, } from "./reads.js";
|
|
@@ -23,7 +25,7 @@ function withoutUndefined(value) {
|
|
|
23
25
|
.map(([key, entry]) => [key, withoutUndefined(entry)]));
|
|
24
26
|
}
|
|
25
27
|
/** A unique violation becomes Conflict, naming the property when the index covers one column. */
|
|
26
|
-
function uniqueConflict(entity, error) {
|
|
28
|
+
function uniqueConflict(entity, error, batch) {
|
|
27
29
|
const cause = (error.reason.cause ?? {});
|
|
28
30
|
const unique = error.reason._tag === "UniqueViolation" || cause.code === "23505";
|
|
29
31
|
if (!unique) {
|
|
@@ -31,17 +33,36 @@ function uniqueConflict(entity, error) {
|
|
|
31
33
|
}
|
|
32
34
|
const columns = typeof cause.detail === "string" ? /^Key \(([^)]*)\)=/.exec(cause.detail)?.[1] : undefined;
|
|
33
35
|
const field = entity.fields.find((candidate) => candidate.column === columns);
|
|
36
|
+
const fields = typeof columns === "string"
|
|
37
|
+
? columns.split(", ").flatMap((column) => {
|
|
38
|
+
const candidate = entity.fields.find((entry) => entry.column === column);
|
|
39
|
+
return candidate === undefined ? [] : [candidate.relationship ?? candidate.name];
|
|
40
|
+
})
|
|
41
|
+
: [];
|
|
42
|
+
const details = batch ? { batch: { indices: [], ...(fields.length > 0 ? { fields } : {}) } } : {};
|
|
34
43
|
const label = entity.label.toLowerCase();
|
|
35
|
-
|
|
44
|
+
const conflict = field === undefined
|
|
36
45
|
? new Conflict({
|
|
37
46
|
entity: entity.name,
|
|
38
47
|
message: `The ${label} conflicts with an existing one.`,
|
|
48
|
+
...details,
|
|
39
49
|
})
|
|
40
50
|
: new Conflict({
|
|
41
51
|
entity: entity.name,
|
|
42
52
|
property: field.name,
|
|
43
53
|
message: `A ${label} with this ${field.name} already exists.`,
|
|
44
|
-
|
|
54
|
+
...details,
|
|
55
|
+
});
|
|
56
|
+
if (batch) {
|
|
57
|
+
// Keep the driver's complete cause available to operators without serializing SQL or values.
|
|
58
|
+
Object.defineProperty(conflict, "cause", { value: error, enumerable: false });
|
|
59
|
+
return logBatchConflict(entity, error, {
|
|
60
|
+
operation: batch.operation,
|
|
61
|
+
action: batch.scope.action,
|
|
62
|
+
actionId: batch.scope.actionId,
|
|
63
|
+
}).pipe(Effect.andThen(Effect.fail(conflict)));
|
|
64
|
+
}
|
|
65
|
+
return Effect.fail(conflict);
|
|
45
66
|
}
|
|
46
67
|
/**
|
|
47
68
|
* Platform identity bookkeeping touches only User properties and their history. User is excluded
|
|
@@ -91,6 +112,8 @@ export function makeOntologyRuntime(ontology, sql, scope) {
|
|
|
91
112
|
run: (text, params) => client.unsafe(text, params.values),
|
|
92
113
|
};
|
|
93
114
|
const entityOf = (name) => Effect.sync(() => entityModel(model, name));
|
|
115
|
+
let attemptedBatchRows = 0;
|
|
116
|
+
let attributionInitialized = false;
|
|
94
117
|
const write = (entityName, operation) => Effect.gen(function* () {
|
|
95
118
|
const entity = yield* entityOf(entityName);
|
|
96
119
|
if (scope === undefined) {
|
|
@@ -108,14 +131,111 @@ export function makeOntologyRuntime(ontology, sql, scope) {
|
|
|
108
131
|
for (const edit of edits) {
|
|
109
132
|
scope.onEdit?.(edit);
|
|
110
133
|
}
|
|
134
|
+
if (edits.length > 0 && scope.execution !== undefined) {
|
|
135
|
+
attributionInitialized = true;
|
|
136
|
+
}
|
|
111
137
|
return result;
|
|
112
138
|
});
|
|
139
|
+
const batch = (entityName, operation, input) => Effect.suspend(() => {
|
|
140
|
+
const startedAt = Date.now();
|
|
141
|
+
let statements = 0;
|
|
142
|
+
let distinctReferences = 0;
|
|
143
|
+
let counts = { created: 0, updated: 0, unchanged: 0 };
|
|
144
|
+
const run = Effect.gen(function* () {
|
|
145
|
+
if (scope === undefined) {
|
|
146
|
+
return yield* Effect.die(new WriteOutsideAction({
|
|
147
|
+
entity: entityName,
|
|
148
|
+
message: `Writes to ${entityName} run inside an action, which history records.`,
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
const declared = ontology.entities.find((entity) => entity.name === entityName);
|
|
152
|
+
if (declared?.builtIn === true || declared?.entity.definition.source !== undefined) {
|
|
153
|
+
return yield* new ActionRejected({
|
|
154
|
+
message: "Batch writes require an ordinary stored entity.",
|
|
155
|
+
batch: { indices: [] },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const entity = yield* entityOf(entityName);
|
|
159
|
+
const length = input.records.length;
|
|
160
|
+
if (length > 0) {
|
|
161
|
+
attemptedBatchRows += length;
|
|
162
|
+
}
|
|
163
|
+
if (length > BATCH_MAX_ROWS || (length > 0 && attemptedBatchRows > ACTION_MAX_BATCH_ROWS)) {
|
|
164
|
+
return yield* new ActionRejected({
|
|
165
|
+
message: length > BATCH_MAX_ROWS
|
|
166
|
+
? `A batch accepts at most ${BATCH_MAX_ROWS} records.`
|
|
167
|
+
: `An action accepts at most ${ACTION_MAX_BATCH_ROWS} attempted batch records.`,
|
|
168
|
+
batch: { indices: [] },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (batchInputBytes(input) > BATCH_MAX_BYTES) {
|
|
172
|
+
return yield* new ActionRejected({
|
|
173
|
+
message: `A batch accepts at most ${BATCH_MAX_BYTES} UTF-8 JSON bytes.`,
|
|
174
|
+
batch: { indices: [] },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const plan = yield* planBatch(entity, operation, input);
|
|
178
|
+
distinctReferences = plan.distinctReferences;
|
|
179
|
+
if (length === 0) {
|
|
180
|
+
return plan.returning === "summary" ? counts : { ...counts, items: [] };
|
|
181
|
+
}
|
|
182
|
+
const enclosingTransaction = yield* Effect.serviceOption(sql.transactionService);
|
|
183
|
+
if (Option.isNone(enclosingTransaction)) {
|
|
184
|
+
return yield* Effect.die(new WriteOutsideAction({
|
|
185
|
+
entity: entityName,
|
|
186
|
+
message: "Batch writes require the enclosing action transaction.",
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
const edits = [];
|
|
190
|
+
statements += 1; // The SQL client's nested transaction issues one savepoint.
|
|
191
|
+
const result = yield* sql
|
|
192
|
+
.withTransaction((scope.replicationInitialized ? Effect.void : beginReplicationWrite(sql)).pipe(Effect.andThen(executeBatch({
|
|
193
|
+
...context,
|
|
194
|
+
scope,
|
|
195
|
+
edits,
|
|
196
|
+
attributionInitialized,
|
|
197
|
+
run: (text, params) => {
|
|
198
|
+
statements += 1;
|
|
199
|
+
return context.run(text, params);
|
|
200
|
+
},
|
|
201
|
+
}, entity, plan)), Effect.onExit((exit) => Effect.sync(() => {
|
|
202
|
+
if (Exit.isFailure(exit)) {
|
|
203
|
+
statements += 1; // A failed nested transaction rolls back to its savepoint.
|
|
204
|
+
}
|
|
205
|
+
}))))
|
|
206
|
+
.pipe(Effect.catchIf(isSqlError, (error) => uniqueConflict(entity, error, { operation, scope })));
|
|
207
|
+
counts = { created: result.created, updated: result.updated, unchanged: result.unchanged };
|
|
208
|
+
for (const edit of edits) {
|
|
209
|
+
scope.onEdit?.(edit);
|
|
210
|
+
}
|
|
211
|
+
if (edits.length > 0 && scope.execution !== undefined) {
|
|
212
|
+
attributionInitialized = true;
|
|
213
|
+
}
|
|
214
|
+
return result;
|
|
215
|
+
});
|
|
216
|
+
return run.pipe(Effect.onExit((exit) => Effect.logDebug("ontology.action.batch_completed").pipe(Effect.annotateLogs({
|
|
217
|
+
operation,
|
|
218
|
+
entity: entityName,
|
|
219
|
+
action: scope?.action,
|
|
220
|
+
actionId: scope?.actionId,
|
|
221
|
+
inputCount: input.records.length,
|
|
222
|
+
distinctReferenceCount: distinctReferences,
|
|
223
|
+
changedCount: counts.created + counts.updated,
|
|
224
|
+
unchangedCount: counts.unchanged,
|
|
225
|
+
sqlStatements: statements,
|
|
226
|
+
batchMs: Date.now() - startedAt,
|
|
227
|
+
outcome: Exit.isSuccess(exit) ? "success" : "failure",
|
|
228
|
+
}))));
|
|
229
|
+
});
|
|
113
230
|
const query = (input) => Effect.gen(function* () {
|
|
114
231
|
const entity = yield* entityOf(input.entity);
|
|
115
232
|
const validated = yield* validateQuery(entity, input.query);
|
|
116
233
|
return yield* orDieOnSql(runQuery(context, entity, validated));
|
|
117
234
|
});
|
|
118
235
|
return {
|
|
236
|
+
createMany: ((input) => batch(input.entity, "create", input)),
|
|
237
|
+
updateMany: ((input) => batch(input.entity, "update", input)),
|
|
238
|
+
upsertMany: ((input) => batch(input.entity, "upsert", input)),
|
|
119
239
|
get: (input) => orDieOnSql(Effect.gen(function* () {
|
|
120
240
|
const entity = yield* entityOf(input.entity);
|
|
121
241
|
const record = yield* loadRecord(context, entity, input.id, input.forUpdate === true ? "update" : undefined);
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Context, Effect } from "effect";
|
|
2
|
-
import type { Conflict, NotFound } from "./errors.js";
|
|
2
|
+
import type { ActionRejected, Conflict, NotFound } from "./errors.js";
|
|
3
3
|
import type { Where } from "./query.js";
|
|
4
4
|
import type { WorkloadExecution } from "./runtime/workload.js";
|
|
5
5
|
/** Who runs an action: an app member or a service principal, both `user` records. */
|
|
@@ -76,6 +76,51 @@ export interface UpsertResult<R = StoredRecord> {
|
|
|
76
76
|
readonly record: R;
|
|
77
77
|
readonly created: boolean;
|
|
78
78
|
}
|
|
79
|
+
/** Whether a batch returns its records or only outcome counts. Defaults to `records`. */
|
|
80
|
+
export type BatchReturning = "records" | "summary";
|
|
81
|
+
export interface BatchOptions {
|
|
82
|
+
readonly returning?: BatchReturning;
|
|
83
|
+
}
|
|
84
|
+
/** Outcome counts, which together equal the number of inputs in a successful batch. */
|
|
85
|
+
export interface BatchSummaryResult {
|
|
86
|
+
readonly created: number;
|
|
87
|
+
readonly updated: number;
|
|
88
|
+
readonly unchanged: number;
|
|
89
|
+
}
|
|
90
|
+
/** One result per input, in input order, including records whose values did not change. */
|
|
91
|
+
export interface BatchRecordResult<R = StoredRecord> extends BatchSummaryResult {
|
|
92
|
+
readonly items: ReadonlyArray<{
|
|
93
|
+
readonly index: number;
|
|
94
|
+
readonly status: "created" | "updated" | "unchanged";
|
|
95
|
+
readonly record: R;
|
|
96
|
+
}>;
|
|
97
|
+
}
|
|
98
|
+
export type BatchResult<R = StoredRecord> = BatchRecordResult<R> | BatchSummaryResult;
|
|
99
|
+
/** Raw batch inputs use wire entity and field names. */
|
|
100
|
+
export interface CreateManyInput extends BatchOptions {
|
|
101
|
+
readonly entity: string;
|
|
102
|
+
readonly records: ReadonlyArray<StoredValues>;
|
|
103
|
+
}
|
|
104
|
+
export interface UpdateManyInput extends BatchOptions {
|
|
105
|
+
readonly entity: string;
|
|
106
|
+
readonly records: ReadonlyArray<{
|
|
107
|
+
readonly id: string;
|
|
108
|
+
readonly values: StoredValues;
|
|
109
|
+
}>;
|
|
110
|
+
}
|
|
111
|
+
export interface UpsertManyInput extends CreateManyInput {
|
|
112
|
+
readonly on: readonly [string, ...string[]];
|
|
113
|
+
}
|
|
114
|
+
type BatchWriteError = ActionRejected | Conflict | NotFound;
|
|
115
|
+
interface BatchWrite<Input extends BatchOptions> {
|
|
116
|
+
(input: Input & {
|
|
117
|
+
readonly returning: "summary";
|
|
118
|
+
}): Effect.Effect<BatchSummaryResult, BatchWriteError>;
|
|
119
|
+
(input: Input & {
|
|
120
|
+
readonly returning?: "records";
|
|
121
|
+
}): Effect.Effect<BatchRecordResult, BatchWriteError>;
|
|
122
|
+
(input: Input): Effect.Effect<BatchResult, BatchWriteError>;
|
|
123
|
+
}
|
|
79
124
|
type ReadsService = {
|
|
80
125
|
readonly get: (input: {
|
|
81
126
|
readonly entity: string;
|
|
@@ -88,6 +133,9 @@ type ReadsService = {
|
|
|
88
133
|
}) => Effect.Effect<ReadonlyArray<StoredRecord>>;
|
|
89
134
|
};
|
|
90
135
|
type WritesService = {
|
|
136
|
+
readonly createMany: BatchWrite<CreateManyInput>;
|
|
137
|
+
readonly updateMany: BatchWrite<UpdateManyInput>;
|
|
138
|
+
readonly upsertMany: BatchWrite<UpsertManyInput>;
|
|
91
139
|
readonly create: (input: {
|
|
92
140
|
readonly entity: string;
|
|
93
141
|
readonly values: StoredValues;
|
|
@@ -130,7 +178,8 @@ export declare class OntologyReads extends OntologyReads_base {
|
|
|
130
178
|
declare const OntologyWrites_base: Context.ServiceClass<OntologyWrites, "@segmentstream-cloud/ontology/OntologyWrites", WritesService>;
|
|
131
179
|
/**
|
|
132
180
|
* The writes an action handler runs, inside the action's transaction: `create`, `update`,
|
|
133
|
-
* `upsertBy`, `delete`, `link` and `unlink` of entity handles require it.
|
|
181
|
+
* `upsertBy`, the batch methods, `delete`, `link` and `unlink` of entity handles require it.
|
|
182
|
+
* `prepare` never has it.
|
|
134
183
|
*/
|
|
135
184
|
export declare class OntologyWrites extends OntologyWrites_base {
|
|
136
185
|
}
|
package/dist/runtime.js
CHANGED
|
@@ -8,7 +8,8 @@ export class OntologyReads extends Context.Service()("@segmentstream-cloud/ontol
|
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
10
10
|
* The writes an action handler runs, inside the action's transaction: `create`, `update`,
|
|
11
|
-
* `upsertBy`, `delete`, `link` and `unlink` of entity handles require it.
|
|
11
|
+
* `upsertBy`, the batch methods, `delete`, `link` and `unlink` of entity handles require it.
|
|
12
|
+
* `prepare` never has it.
|
|
12
13
|
*/
|
|
13
14
|
export class OntologyWrites extends Context.Service()("@segmentstream-cloud/ontology/OntologyWrites") {
|
|
14
15
|
}
|
|
@@ -13,9 +13,10 @@ export { CONVERSATION_HEADER, SESSION_HEADER } from "./authentication.js";
|
|
|
13
13
|
/**
|
|
14
14
|
* The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
|
|
15
15
|
* deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
|
|
16
|
-
* while they are active
|
|
16
|
+
* while they are active. The chat and the agent session come from the agent's runtime in
|
|
17
17
|
* `X-SegmentStream-Agent-Conversation` and `X-SegmentStream-Agent-Session`, never from the model's
|
|
18
|
-
* tool arguments
|
|
18
|
+
* tool arguments. Storage checks current group participation or chat administration; private files
|
|
19
|
+
* remain confined to their sender and session.
|
|
19
20
|
*/
|
|
20
21
|
export declare const ATTACHMENTS_PATH = "/api/ontology/agent/attachments";
|
|
21
22
|
export interface AttachmentRoutesOptions {
|
|
@@ -12,9 +12,10 @@ export { CONVERSATION_HEADER, SESSION_HEADER } from "./authentication.js";
|
|
|
12
12
|
/**
|
|
13
13
|
* The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
|
|
14
14
|
* deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
|
|
15
|
-
* while they are active
|
|
15
|
+
* while they are active. The chat and the agent session come from the agent's runtime in
|
|
16
16
|
* `X-SegmentStream-Agent-Conversation` and `X-SegmentStream-Agent-Session`, never from the model's
|
|
17
|
-
* tool arguments
|
|
17
|
+
* tool arguments. Storage checks current group participation or chat administration; private files
|
|
18
|
+
* remain confined to their sender and session.
|
|
18
19
|
*/
|
|
19
20
|
export const ATTACHMENTS_PATH = `${AGENT_PATH}/attachments`;
|
|
20
21
|
const NO_STORE = { "cache-control": "no-store" };
|
|
@@ -111,6 +112,7 @@ export function attachmentsLayer(options) {
|
|
|
111
112
|
request,
|
|
112
113
|
agents,
|
|
113
114
|
verify: verifyRuntime,
|
|
115
|
+
allowUnpairedMember: true,
|
|
114
116
|
});
|
|
115
117
|
return authenticated.ok
|
|
116
118
|
? yield* answer(request, authenticated.caller, attachments)
|
|
@@ -6,11 +6,13 @@ import type { AgentStore } from "../runtime-contract.js";
|
|
|
6
6
|
import { type RuntimeTokenVerifier } from "../runtime-identity.js";
|
|
7
7
|
export declare const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
|
|
8
8
|
export declare const SESSION_HEADER = "x-segmentstream-agent-session";
|
|
9
|
-
/**
|
|
9
|
+
/** Authenticate the runtime and resolve its member; attachment storage also checks chat access. */
|
|
10
10
|
export declare function authenticateAgentCaller(input: {
|
|
11
11
|
readonly request: HttpServerRequest.HttpServerRequest;
|
|
12
12
|
readonly agents: AgentStore;
|
|
13
13
|
readonly verify: RuntimeTokenVerifier;
|
|
14
|
+
/** Temporary attachments authorize active members against the chat registry, without a pairing. */
|
|
15
|
+
readonly allowUnpairedMember?: boolean;
|
|
14
16
|
}): Effect.Effect<{
|
|
15
17
|
readonly ok: false;
|
|
16
18
|
readonly response: import("effect/unstable/http/HttpServerResponse").HttpServerResponse;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { Effect, Redacted } from "effect";
|
|
2
2
|
import { HttpServerRequest } from "effect/unstable/http";
|
|
3
3
|
import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
|
|
4
|
-
import { RequestId, forbidden, toServerResponse, unauthorized, unavailable } from "../errors.js";
|
|
4
|
+
import { RequestId, forbidden, notFound, toServerResponse, unauthorized, unavailable, } from "../errors.js";
|
|
5
5
|
import { ACT_AS_HEADER, logRuntimeRejection, } from "../runtime-identity.js";
|
|
6
6
|
export const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
|
|
7
7
|
export const SESSION_HEADER = "x-segmentstream-agent-session";
|
|
8
8
|
const CONVERSATION = /^[a-z][a-z0-9_-]{0,31}:[A-Za-z0-9:_-]{1,200}$/;
|
|
9
9
|
const SESSION = /^[A-Za-z0-9:_.-]{1,200}$/;
|
|
10
|
-
/**
|
|
10
|
+
/** Authenticate the runtime and resolve its member; attachment storage also checks chat access. */
|
|
11
11
|
export function authenticateAgentCaller(input) {
|
|
12
12
|
return Effect.gen(function* () {
|
|
13
13
|
const { request, agents, verify } = input;
|
|
@@ -31,9 +31,15 @@ export function authenticateAgentCaller(input) {
|
|
|
31
31
|
const actAs = request.headers[ACT_AS_HEADER]?.trim() ?? "";
|
|
32
32
|
const conversation = request.headers[CONVERSATION_HEADER]?.trim() ?? "";
|
|
33
33
|
const session = request.headers[SESSION_HEADER]?.trim() ?? "";
|
|
34
|
-
const member = actAs === ""
|
|
34
|
+
const member = actAs === ""
|
|
35
|
+
? undefined
|
|
36
|
+
: input.allowUnpairedMember
|
|
37
|
+
? (yield* agents.member(actAs))?.principal
|
|
38
|
+
: yield* agents.actingMember(actAs);
|
|
35
39
|
if (member === undefined) {
|
|
36
|
-
return denied(
|
|
40
|
+
return denied(input.allowUnpairedMember
|
|
41
|
+
? notFound(requestId, "Attachment not found.")
|
|
42
|
+
: forbidden(requestId, "The member is inactive or has not paired with the agent."));
|
|
37
43
|
}
|
|
38
44
|
if (!CONVERSATION.test(conversation) || (session !== "" && !SESSION.test(session))) {
|
|
39
45
|
return denied(forbidden(requestId, "The agent names the chat and session of every request."));
|
package/dist/service/errors.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Cause, Context, Effect, Option, Predicate, Schema, SchemaIssue } from "effect";
|
|
2
2
|
import { HttpServerResponse } from "effect/unstable/http";
|
|
3
3
|
import { HttpApiError } from "effect/unstable/httpapi";
|
|
4
|
+
import { BatchErrorDetails } from "../errors.js";
|
|
4
5
|
/** The correlation id of the current request, in every error body and the `x-request-id` header. */
|
|
5
6
|
export class RequestId extends Context.Service()("@segmentstream-cloud/ontology/RequestId") {
|
|
6
7
|
}
|
|
@@ -64,6 +65,19 @@ function listed(values) {
|
|
|
64
65
|
? values.join("")
|
|
65
66
|
: `${values.slice(0, -1).join(", ")} and ${values.at(-1)}`;
|
|
66
67
|
}
|
|
68
|
+
/** Only the declared safe locations cross a transport, even for structurally tagged failures. */
|
|
69
|
+
function batchDetails(error) {
|
|
70
|
+
const batch = error.batch;
|
|
71
|
+
if (!Schema.is(BatchErrorDetails)(batch)) {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
batch: {
|
|
76
|
+
indices: batch.indices,
|
|
77
|
+
...(batch.fields === undefined ? {} : { fields: batch.fields }),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
67
81
|
export function toServerResponse(response) {
|
|
68
82
|
return HttpServerResponse.jsonUnsafe(errorBodyForWire(response.body), {
|
|
69
83
|
status: response.status,
|
|
@@ -323,6 +337,7 @@ export function expectedFailure(error, requestId) {
|
|
|
323
337
|
detail: message,
|
|
324
338
|
requestId,
|
|
325
339
|
issues: [{ path: [], message }],
|
|
340
|
+
...batchDetails(error),
|
|
326
341
|
},
|
|
327
342
|
};
|
|
328
343
|
}
|
|
@@ -421,6 +436,7 @@ export function expectedFailure(error, requestId) {
|
|
|
421
436
|
requestId,
|
|
422
437
|
...(typeof entity === "string" ? { entity } : {}),
|
|
423
438
|
...(typeof id === "string" ? { id } : {}),
|
|
439
|
+
...batchDetails(error),
|
|
424
440
|
},
|
|
425
441
|
};
|
|
426
442
|
}
|
|
@@ -434,6 +450,7 @@ export function expectedFailure(error, requestId) {
|
|
|
434
450
|
requestId,
|
|
435
451
|
...(typeof entity === "string" ? { entity } : {}),
|
|
436
452
|
...(typeof property === "string" ? { property } : {}),
|
|
453
|
+
...batchDetails(error),
|
|
437
454
|
},
|
|
438
455
|
};
|
|
439
456
|
}
|
package/dist/service/schemas.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BlobValue } from "../blob.js";
|
|
2
2
|
import { Schema, SchemaTransformation } from "effect";
|
|
3
3
|
import { wireStruct } from "../wire.js";
|
|
4
|
+
import { BatchErrorDetails } from "../errors.js";
|
|
4
5
|
import { AGGREGATE_FILTER_OPERATORS, ENTITY_AGGREGATE_FILTER_OPERATORS, filterValuesIssue, MAXIMUM_FILTER_VALUES, } from "../aggregate-filters.js";
|
|
5
6
|
import { isValueMatched, OntologyManifestWire } from "../manifest.js";
|
|
6
7
|
import { entityQuerySchemas, queryLinks } from "../query.js";
|
|
@@ -510,14 +511,17 @@ export const ErrorSchemas = {
|
|
|
510
511
|
notFound: errorBody("NotFound", 404, ["not_found"], {
|
|
511
512
|
entity: Schema.optionalKey(Schema.String),
|
|
512
513
|
id: Schema.optionalKey(Schema.String),
|
|
514
|
+
batch: Schema.optionalKey(BatchErrorDetails),
|
|
513
515
|
}),
|
|
514
516
|
conflict: errorBody("Conflict", 409, ["conflict"], {
|
|
515
517
|
entity: Schema.optionalKey(Schema.String),
|
|
516
518
|
property: Schema.optionalKey(Schema.String),
|
|
519
|
+
batch: Schema.optionalKey(BatchErrorDetails),
|
|
517
520
|
}),
|
|
518
521
|
unsupportedMediaType: errorBody("UnsupportedMediaType", 415, ["unsupported_media_type"]),
|
|
519
522
|
unprocessable: errorBody("Unprocessable", 422, ["invalid_input", "action_rejected"], {
|
|
520
523
|
issues: Schema.Array(ValidationIssue),
|
|
524
|
+
batch: Schema.optionalKey(BatchErrorDetails),
|
|
521
525
|
}),
|
|
522
526
|
rateLimited: errorBody("RateLimited", 429, ["rate_limited"]),
|
|
523
527
|
internal: errorBody("InternalError", 500, ["internal_error"]),
|
|
@@ -6,11 +6,12 @@ import type { Principal } from "../runtime.js";
|
|
|
6
6
|
import type { BlobProvider, TransferGrant } from "./provider.js";
|
|
7
7
|
/**
|
|
8
8
|
* The agent's temporary attachments: a file a member sends the app's agent is stored once, as a
|
|
9
|
-
* `_blobs` revision in the ontology's private file storage
|
|
10
|
-
*
|
|
11
|
-
* expires 24 hours after receipt;
|
|
12
|
-
* promote it into a record through an action, which adopts the same stored object
|
|
13
|
-
* it. Every operation
|
|
9
|
+
* `_blobs` revision in the ontology's private file storage. Group files belong to the chat's current
|
|
10
|
+
* members; private files belong to their sender and the agent session that first reads them.
|
|
11
|
+
* Nothing about it is an ontology record. It expires 24 hours after receipt; the agent may read it,
|
|
12
|
+
* discard it, or promote it into a record through an action, which adopts the same stored object
|
|
13
|
+
* without copying it. Every operation derives chat access from the caller and registry, never from
|
|
14
|
+
* tool input.
|
|
14
15
|
*/
|
|
15
16
|
/** The largest file the agent takes: what Telegram's Bot API lets a bot download. */
|
|
16
17
|
export declare const MAX_ATTACHMENT_BYTES: number;
|
|
@@ -25,8 +26,8 @@ export interface AttachmentCaller {
|
|
|
25
26
|
/** The chat the attachment arrived in, such as `telegram:12345`. */
|
|
26
27
|
readonly conversation: string;
|
|
27
28
|
/**
|
|
28
|
-
* The agent session the request comes from.
|
|
29
|
-
*
|
|
29
|
+
* The agent session the request comes from. Private attachments stay bound to the first session
|
|
30
|
+
* that reads them. Group attachments are shared across authorized sessions in the same chat.
|
|
30
31
|
*/
|
|
31
32
|
readonly sessionId?: string;
|
|
32
33
|
}
|
|
@@ -9,11 +9,12 @@ import { authorizeBlobOperation } from "./authorization.js";
|
|
|
9
9
|
import { ATTACHMENTS_TABLE } from "./tables.js";
|
|
10
10
|
/**
|
|
11
11
|
* The agent's temporary attachments: a file a member sends the app's agent is stored once, as a
|
|
12
|
-
* `_blobs` revision in the ontology's private file storage
|
|
13
|
-
*
|
|
14
|
-
* expires 24 hours after receipt;
|
|
15
|
-
* promote it into a record through an action, which adopts the same stored object
|
|
16
|
-
* it. Every operation
|
|
12
|
+
* `_blobs` revision in the ontology's private file storage. Group files belong to the chat's current
|
|
13
|
+
* members; private files belong to their sender and the agent session that first reads them.
|
|
14
|
+
* Nothing about it is an ontology record. It expires 24 hours after receipt; the agent may read it,
|
|
15
|
+
* discard it, or promote it into a record through an action, which adopts the same stored object
|
|
16
|
+
* without copying it. Every operation derives chat access from the caller and registry, never from
|
|
17
|
+
* tool input.
|
|
17
18
|
*/
|
|
18
19
|
/** The largest file the agent takes: what Telegram's Bot API lets a bot download. */
|
|
19
20
|
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
|
@@ -102,9 +103,34 @@ export function attachmentRuntime(ontology, sql, provider) {
|
|
|
102
103
|
const binding = provider.binding;
|
|
103
104
|
const missing = (id) => new NotFound({ entity: "attachment", id, message: "Attachment not found." });
|
|
104
105
|
const unavailable = (message) => new Conflict({ entity: "attachment", message });
|
|
106
|
+
/** The registry contains groups only. Neither a tool argument nor a caller's roles name a group. */
|
|
107
|
+
const accessScope = (caller) => Effect.gen(function* () {
|
|
108
|
+
const [member] = yield* sql.unsafe(`SELECT kind::text, active FROM "user" WHERE id = $1::uuid FOR SHARE`, [caller.principal.id]);
|
|
109
|
+
if (member?.kind !== "member" || !member.active) {
|
|
110
|
+
return "denied";
|
|
111
|
+
}
|
|
112
|
+
yield* authorizeBlobOperation(ontology, sql, caller.principal.id, []);
|
|
113
|
+
const separator = caller.conversation.indexOf(":");
|
|
114
|
+
const [chat] = yield* sql.unsafe(`SELECT id::text FROM "agent_chat"
|
|
115
|
+
WHERE provider = $1 AND external_id = $2 FOR SHARE`, [caller.conversation.slice(0, separator), caller.conversation.slice(separator + 1)]);
|
|
116
|
+
if (chat === undefined) {
|
|
117
|
+
return "private";
|
|
118
|
+
}
|
|
119
|
+
const [participant] = yield* sql.unsafe(`SELECT 1 FROM "agent_chat_members"
|
|
120
|
+
WHERE agent_chat_id = $1::uuid AND user_id = $2::uuid FOR SHARE`, [chat.id, caller.principal.id]);
|
|
121
|
+
if (participant !== undefined) {
|
|
122
|
+
return "group";
|
|
123
|
+
}
|
|
124
|
+
// App roles are authored by the workspace. Chat management grants its administrator access;
|
|
125
|
+
// re-read the roles here rather than trusting permissions cached on the principal.
|
|
126
|
+
const administrator = yield* authorizeBlobOperation(ontology, sql, caller.principal.id, [
|
|
127
|
+
"agent_chat:update",
|
|
128
|
+
]).pipe(Effect.as(true), Effect.catchTag("PermissionDenied", () => Effect.succeed(false)));
|
|
129
|
+
return administrator ? "group" : "denied";
|
|
130
|
+
});
|
|
105
131
|
/**
|
|
106
|
-
* The caller's attachment, locked.
|
|
107
|
-
*
|
|
132
|
+
* The caller's attachment, locked. Group access follows current chat membership. Private access
|
|
133
|
+
* requires the sender and their session. Source transfers always require the original sender.
|
|
108
134
|
*/
|
|
109
135
|
const load = (id, caller,
|
|
110
136
|
/** `source`: the webhook redelivering the file it stored, before or after a session read it. */
|
|
@@ -112,23 +138,37 @@ export function attachmentRuntime(ontology, sql, provider) {
|
|
|
112
138
|
if (!/^[0-9a-f-]{36}$/i.test(id)) {
|
|
113
139
|
return yield* missing(id);
|
|
114
140
|
}
|
|
115
|
-
const
|
|
116
|
-
|
|
141
|
+
const access = yield* accessScope(caller);
|
|
142
|
+
if (access === "denied") {
|
|
143
|
+
return yield* missing(id);
|
|
144
|
+
}
|
|
145
|
+
const rows = yield* sql.unsafe(`${SELECT} WHERE a.id = $1::uuid AND (a.owner_id = $2::uuid OR $5) AND a.conversation = $3
|
|
146
|
+
AND a.branch_id = $4 FOR UPDATE OF a, b`, [
|
|
147
|
+
id,
|
|
148
|
+
caller.principal.id,
|
|
149
|
+
caller.conversation,
|
|
150
|
+
binding.branchId,
|
|
151
|
+
access === "group" && scope === "session",
|
|
152
|
+
]);
|
|
117
153
|
const row = rows[0];
|
|
118
154
|
if (!row) {
|
|
119
155
|
return yield* missing(id);
|
|
120
156
|
}
|
|
121
|
-
if (
|
|
157
|
+
if (access === "private" &&
|
|
158
|
+
scope === "session" &&
|
|
159
|
+
row.session_id !== null &&
|
|
160
|
+
row.session_id !== caller.sessionId) {
|
|
122
161
|
return yield* missing(id);
|
|
123
162
|
}
|
|
124
|
-
if (
|
|
163
|
+
if (access === "private" &&
|
|
164
|
+
scope === "session" &&
|
|
165
|
+
row.session_id === null &&
|
|
166
|
+
caller.sessionId !== undefined) {
|
|
125
167
|
yield* sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET session_id = $1 WHERE id = $2::uuid`, [
|
|
126
168
|
caller.sessionId,
|
|
127
169
|
row.id,
|
|
128
170
|
]);
|
|
129
171
|
}
|
|
130
|
-
// The member must still be active; a deactivated member's agent reads nothing.
|
|
131
|
-
yield* authorizeBlobOperation(ontology, sql, caller.principal.id, [], caller.principal.execution);
|
|
132
172
|
return row;
|
|
133
173
|
});
|
|
134
174
|
/**
|
|
@@ -203,7 +243,7 @@ export function attachmentRuntime(ontology, sql, provider) {
|
|
|
203
243
|
input.size,
|
|
204
244
|
input.sha256,
|
|
205
245
|
]);
|
|
206
|
-
return yield* load(id, caller);
|
|
246
|
+
return yield* load(id, caller, "source");
|
|
207
247
|
}));
|
|
208
248
|
if (reserved.state !== "received" || reserved.expired) {
|
|
209
249
|
return { attachment: view(reserved) };
|
|
@@ -232,7 +272,7 @@ export function attachmentRuntime(ontology, sql, provider) {
|
|
|
232
272
|
});
|
|
233
273
|
/** Completes the transfer, then verifies the stored bytes against the declared size and digest. */
|
|
234
274
|
const complete = (id, caller) => Effect.gen(function* () {
|
|
235
|
-
const row = yield* sql.withTransaction(load(id, caller));
|
|
275
|
+
const row = yield* sql.withTransaction(load(id, caller, "source"));
|
|
236
276
|
if (row.state !== "received") {
|
|
237
277
|
return READABLE.has(row.state) && !row.expired
|
|
238
278
|
? view(row)
|
|
@@ -267,16 +307,25 @@ export function attachmentRuntime(ontology, sql, provider) {
|
|
|
267
307
|
return yield* sql.withTransaction(Effect.gen(function* () {
|
|
268
308
|
yield* sql.unsafe(`UPDATE "${ATTACHMENTS_TABLE}" SET state = 'ready'
|
|
269
309
|
WHERE id = $1::uuid AND state = 'received'`, [row.id]);
|
|
270
|
-
return view(yield* load(row.id, caller));
|
|
310
|
+
return view(yield* load(row.id, caller, "source"));
|
|
271
311
|
}));
|
|
272
312
|
});
|
|
273
|
-
/**
|
|
313
|
+
/** Accessible attachments in this chat that are still readable, newest first. */
|
|
274
314
|
const list = (caller) => sql.withTransaction(Effect.gen(function* () {
|
|
275
|
-
yield*
|
|
276
|
-
|
|
315
|
+
const access = yield* accessScope(caller);
|
|
316
|
+
if (access === "denied") {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
const rows = yield* sql.unsafe(`${SELECT} WHERE (a.owner_id = $1::uuid OR $5) AND a.conversation = $2 AND a.branch_id = $3
|
|
277
320
|
AND a.state IN ('ready', 'promoted') AND a.expires_at > now()
|
|
278
|
-
AND (a.session_id IS NULL OR a.session_id = $4)
|
|
279
|
-
ORDER BY a.created_at DESC LIMIT ${MAX_LIVE_ATTACHMENTS}`, [
|
|
321
|
+
AND ($5 OR a.session_id IS NULL OR a.session_id = $4)
|
|
322
|
+
ORDER BY a.created_at DESC LIMIT ${MAX_LIVE_ATTACHMENTS}`, [
|
|
323
|
+
caller.principal.id,
|
|
324
|
+
caller.conversation,
|
|
325
|
+
binding.branchId,
|
|
326
|
+
caller.sessionId ?? null,
|
|
327
|
+
access === "group",
|
|
328
|
+
]);
|
|
280
329
|
return rows.map(view);
|
|
281
330
|
}));
|
|
282
331
|
/**
|
|
@@ -351,6 +400,7 @@ export function attachmentRuntime(ontology, sql, provider) {
|
|
|
351
400
|
const qualified = `${target.entity}.${target.action}`;
|
|
352
401
|
const prepared = yield* sql.withTransaction(Effect.gen(function* () {
|
|
353
402
|
const row = yield* load(id, caller);
|
|
403
|
+
yield* authorizeBlobOperation(ontology, sql, caller.principal.id, found.permissions, caller.principal.execution);
|
|
354
404
|
if (row.state === "promoted") {
|
|
355
405
|
return { kind: "done", row };
|
|
356
406
|
}
|
|
@@ -365,11 +415,21 @@ export function attachmentRuntime(ontology, sql, provider) {
|
|
|
365
415
|
`${row.content_type}, ${row.size} bytes.`,
|
|
366
416
|
});
|
|
367
417
|
}
|
|
368
|
-
yield* authorizeBlobOperation(ontology, sql, caller.principal.id, found.permissions, caller.principal.execution);
|
|
369
418
|
const [earlier] = yield* sql.unsafe(`SELECT id::text, promotion FROM "${ATTACHMENTS_TABLE}"
|
|
370
419
|
WHERE sha256 = $1 AND branch_id = $2 AND state = 'promoted' AND id <> $3::uuid
|
|
371
420
|
AND promotion->>'action' = $4 AND promotion->>'deduplicated_from' IS NULL
|
|
372
|
-
|
|
421
|
+
AND conversation = $5 AND (owner_id = $6::uuid OR $7)
|
|
422
|
+
AND ($7 OR session_id IS NULL OR session_id = $8)
|
|
423
|
+
ORDER BY created_at LIMIT 1`, [
|
|
424
|
+
row.sha256,
|
|
425
|
+
binding.branchId,
|
|
426
|
+
row.id,
|
|
427
|
+
qualified,
|
|
428
|
+
caller.conversation,
|
|
429
|
+
caller.principal.id,
|
|
430
|
+
(yield* accessScope(caller)) === "group",
|
|
431
|
+
caller.sessionId ?? null,
|
|
432
|
+
]);
|
|
373
433
|
if (earlier) {
|
|
374
434
|
const promotion = {
|
|
375
435
|
...earlier.promotion,
|