@electric-ax/agents-server 0.4.12 → 0.4.14
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/dist/entrypoint.js +325 -62
- package/dist/index.cjs +325 -62
- package/dist/index.d.cts +50 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +325 -62
- package/package.json +9 -9
- package/src/entity-manager.ts +351 -1
- package/src/routing/durable-streams-router.ts +4 -1
- package/src/routing/entities-router.ts +226 -0
- package/src/routing/stream-append.ts +3 -0
- package/src/stream-client.ts +14 -33
- package/src/utils/log.ts +63 -52
package/dist/index.js
CHANGED
|
@@ -1189,35 +1189,48 @@ const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
|
|
|
1189
1189
|
const IS_ELECTRON_MAIN = Boolean(process.versions.electron);
|
|
1190
1190
|
const USE_FILE_LOGS = process.env.ELECTRIC_AGENTS_LOG_FILE !== `false`;
|
|
1191
1191
|
const USE_PRETTY_LOGS = LOG_LEVEL !== `silent` && !process.env.VITEST && !IS_ELECTRON_MAIN;
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
if (
|
|
1195
|
-
const streams = [];
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1192
|
+
let _logger;
|
|
1193
|
+
function getLogger() {
|
|
1194
|
+
if (_logger) return _logger;
|
|
1195
|
+
const streams = [];
|
|
1196
|
+
try {
|
|
1197
|
+
if (USE_FILE_LOGS) {
|
|
1198
|
+
const logDir = process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`);
|
|
1199
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
1200
|
+
const logFile = path.join(logDir, `agent-server-${Date.now()}.jsonl`);
|
|
1201
|
+
streams.push({ stream: pino.destination({
|
|
1202
|
+
dest: logFile,
|
|
1203
|
+
sync: IS_ELECTRON_MAIN
|
|
1204
|
+
}) });
|
|
1205
|
+
}
|
|
1206
|
+
} catch (err) {
|
|
1207
|
+
process.stderr.write(`[agents-server] Failed to initialize file logging: ${err instanceof Error ? err.message : err}\n`);
|
|
1208
|
+
}
|
|
1209
|
+
try {
|
|
1210
|
+
if (USE_PRETTY_LOGS) streams.push({ stream: pino.transport({
|
|
1211
|
+
target: `pino-pretty`,
|
|
1212
|
+
options: {
|
|
1213
|
+
colorize: true,
|
|
1214
|
+
ignore: `pid,hostname,name`,
|
|
1215
|
+
translateTime: `SYS:HH:MM:ss`
|
|
1216
|
+
}
|
|
1217
|
+
}) });
|
|
1218
|
+
} catch {}
|
|
1219
|
+
_logger = streams.length > 0 ? pino({
|
|
1220
|
+
base: void 0,
|
|
1221
|
+
level: LOG_LEVEL
|
|
1222
|
+
}, pino.multistream(streams)) : pino({
|
|
1223
|
+
base: void 0,
|
|
1224
|
+
enabled: false,
|
|
1225
|
+
level: LOG_LEVEL
|
|
1226
|
+
});
|
|
1227
|
+
return _logger;
|
|
1228
|
+
}
|
|
1216
1229
|
function formatArgs(args) {
|
|
1217
1230
|
const errors = [];
|
|
1218
1231
|
const parts = [];
|
|
1219
|
-
for (const
|
|
1220
|
-
else parts.push(typeof
|
|
1232
|
+
for (const value of args) if (value instanceof Error) errors.push(value);
|
|
1233
|
+
else parts.push(typeof value === `string` ? value : JSON.stringify(value));
|
|
1221
1234
|
return {
|
|
1222
1235
|
err: errors[0],
|
|
1223
1236
|
msg: parts.join(` `)
|
|
@@ -1226,20 +1239,20 @@ function formatArgs(args) {
|
|
|
1226
1239
|
const serverLog = {
|
|
1227
1240
|
info(...args) {
|
|
1228
1241
|
const { msg } = formatArgs(args);
|
|
1229
|
-
|
|
1242
|
+
getLogger().info(msg);
|
|
1230
1243
|
},
|
|
1231
1244
|
warn(...args) {
|
|
1232
1245
|
const { err, msg } = formatArgs(args);
|
|
1233
|
-
if (err)
|
|
1234
|
-
else
|
|
1246
|
+
if (err) getLogger().warn({ err }, msg);
|
|
1247
|
+
else getLogger().warn(msg);
|
|
1235
1248
|
},
|
|
1236
1249
|
error(...args) {
|
|
1237
1250
|
const { err, msg } = formatArgs(args);
|
|
1238
|
-
if (err)
|
|
1239
|
-
else
|
|
1251
|
+
if (err) getLogger().error({ err }, msg);
|
|
1252
|
+
else getLogger().error(msg);
|
|
1240
1253
|
},
|
|
1241
1254
|
event(obj, msg) {
|
|
1242
|
-
|
|
1255
|
+
getLogger().info(obj, msg);
|
|
1243
1256
|
}
|
|
1244
1257
|
};
|
|
1245
1258
|
|
|
@@ -1960,7 +1973,8 @@ var StreamClient = class {
|
|
|
1960
1973
|
url: this.streamUrl(path$1),
|
|
1961
1974
|
headers: this.streamHeaders(),
|
|
1962
1975
|
contentType: opts.contentType,
|
|
1963
|
-
body: opts.body
|
|
1976
|
+
body: opts.body,
|
|
1977
|
+
closed: opts.closed
|
|
1964
1978
|
});
|
|
1965
1979
|
});
|
|
1966
1980
|
}
|
|
@@ -2060,30 +2074,11 @@ var StreamClient = class {
|
|
|
2060
2074
|
offset: fromOffset ?? `-1`,
|
|
2061
2075
|
live: false
|
|
2062
2076
|
});
|
|
2063
|
-
const
|
|
2064
|
-
return
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
if (settled) return;
|
|
2069
|
-
settled = true;
|
|
2070
|
-
unsub();
|
|
2071
|
-
resolve$1(r);
|
|
2072
|
-
};
|
|
2073
|
-
unsub = response.subscribeBytes((chunk) => {
|
|
2074
|
-
messages.push({
|
|
2075
|
-
data: chunk.data,
|
|
2076
|
-
offset: chunk.offset
|
|
2077
|
-
});
|
|
2078
|
-
if (chunk.upToDate || chunk.streamClosed) finish({ messages });
|
|
2079
|
-
});
|
|
2080
|
-
response.closed.then(() => finish({ messages })).catch((err) => {
|
|
2081
|
-
if (settled) return;
|
|
2082
|
-
settled = true;
|
|
2083
|
-
unsub();
|
|
2084
|
-
reject(err);
|
|
2085
|
-
});
|
|
2086
|
-
});
|
|
2077
|
+
const body = await response.body();
|
|
2078
|
+
return { messages: body.length === 0 ? [] : [{
|
|
2079
|
+
data: body,
|
|
2080
|
+
offset: response.offset
|
|
2081
|
+
}] };
|
|
2087
2082
|
});
|
|
2088
2083
|
}
|
|
2089
2084
|
async readJson(path$1, fromOffset) {
|
|
@@ -2237,11 +2232,11 @@ var StreamClient = class {
|
|
|
2237
2232
|
if (res.status === 404 || res.status === 204) return;
|
|
2238
2233
|
if (!res.ok) throw new Error(`Subscription delete failed: ${res.status} ${await res.text()}`);
|
|
2239
2234
|
}
|
|
2240
|
-
async addSubscriptionStreams(subscriptionId, streams
|
|
2235
|
+
async addSubscriptionStreams(subscriptionId, streams) {
|
|
2241
2236
|
const res = await fetch(this.subscriptionChildUrl(subscriptionId, `streams`), {
|
|
2242
2237
|
method: `POST`,
|
|
2243
2238
|
headers: await this.requestHeaders({ "content-type": `application/json` }),
|
|
2244
|
-
body: JSON.stringify({ streams: streams
|
|
2239
|
+
body: JSON.stringify({ streams: streams.map((stream) => this.backendSubscriptionPath(normalizeSubscriptionStreamPath(stream))) })
|
|
2245
2240
|
});
|
|
2246
2241
|
return await this.subscriptionJson(res, `Subscription stream add failed`);
|
|
2247
2242
|
}
|
|
@@ -2690,9 +2685,45 @@ function createInitialQueuePosition(date) {
|
|
|
2690
2685
|
const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
|
|
2691
2686
|
const DEFAULT_FORK_WAIT_POLL_MS = 250;
|
|
2692
2687
|
const SERVER_SIGNAL_SENDER = `/_electric/server`;
|
|
2688
|
+
const DEFAULT_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
2693
2689
|
function sleep(ms) {
|
|
2694
2690
|
return new Promise((resolve$1) => setTimeout(resolve$1, ms));
|
|
2695
2691
|
}
|
|
2692
|
+
function maxAttachmentBytes() {
|
|
2693
|
+
const configured = Number(process.env.ELECTRIC_AGENTS_MAX_ATTACHMENT_BYTES);
|
|
2694
|
+
return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : DEFAULT_MAX_ATTACHMENT_BYTES;
|
|
2695
|
+
}
|
|
2696
|
+
function manifestAttachmentKey(id) {
|
|
2697
|
+
return `attachment:${id}`;
|
|
2698
|
+
}
|
|
2699
|
+
function getEntityAttachmentStreamPath(entityUrl, attachmentId) {
|
|
2700
|
+
return `${entityUrl.replace(/\/+$/, ``)}/attachments/${attachmentId}`;
|
|
2701
|
+
}
|
|
2702
|
+
function isStreamCreateConflict(error) {
|
|
2703
|
+
return !!error && typeof error === `object` && (`status` in error && error.status === 409 || `code` in error && error.code === `CONFLICT_SEQ`);
|
|
2704
|
+
}
|
|
2705
|
+
function assertCanonicalAttachmentStreamPath(entityUrl, attachment) {
|
|
2706
|
+
const expected = getEntityAttachmentStreamPath(entityUrl, attachment.id);
|
|
2707
|
+
if (attachment.streamPath === expected) return;
|
|
2708
|
+
throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment stream path does not match its entity and id`, 409);
|
|
2709
|
+
}
|
|
2710
|
+
function validateAttachmentId(id) {
|
|
2711
|
+
if (!id || id.includes(`/`) || id.startsWith(`.`)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `attachment id must not be empty, start with ".", or contain forward slashes`, 400);
|
|
2712
|
+
}
|
|
2713
|
+
function validateAttachmentSubject(subject) {
|
|
2714
|
+
if (!subject.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `attachment subject key is required`, 400);
|
|
2715
|
+
if (subject.type !== `inbox` && subject.type !== `run` && subject.type !== `text` && subject.type !== `tool_call` && subject.type !== `context`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `invalid attachment subject type`, 400);
|
|
2716
|
+
}
|
|
2717
|
+
function concatByteMessages(messages) {
|
|
2718
|
+
const total = messages.reduce((sum, message) => sum + message.data.length, 0);
|
|
2719
|
+
const bytes = new Uint8Array(total);
|
|
2720
|
+
let offset = 0;
|
|
2721
|
+
for (const message of messages) {
|
|
2722
|
+
bytes.set(message.data, offset);
|
|
2723
|
+
offset += message.data.length;
|
|
2724
|
+
}
|
|
2725
|
+
return bytes;
|
|
2726
|
+
}
|
|
2696
2727
|
function omitUndefined$1(value) {
|
|
2697
2728
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== void 0));
|
|
2698
2729
|
}
|
|
@@ -3058,6 +3089,15 @@ var EntityManager = class {
|
|
|
3058
3089
|
await this.streamClient.fork(forkPath, sourcePath);
|
|
3059
3090
|
createdStreams.push(forkPath);
|
|
3060
3091
|
}
|
|
3092
|
+
for (const plan of entityPlans) {
|
|
3093
|
+
const manifests = snapshot.manifestsByEntity.get(plan.source.url) ?? new Map();
|
|
3094
|
+
for (const manifest of manifests.values()) {
|
|
3095
|
+
if (manifest.kind !== `attachment` || typeof manifest.streamPath !== `string` || typeof manifest.id !== `string`) continue;
|
|
3096
|
+
const forkPath = getEntityAttachmentStreamPath(plan.fork.url, manifest.id);
|
|
3097
|
+
await this.streamClient.fork(forkPath, manifest.streamPath);
|
|
3098
|
+
createdStreams.push(forkPath);
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3061
3101
|
for (const plan of entityPlans) {
|
|
3062
3102
|
const reconciliation = this.buildForkReconciliation(plan, snapshot, entityUrlMap, sharedStateIdMap, stringMap);
|
|
3063
3103
|
activeManifestsByEntity.set(plan.fork.url, reconciliation.manifests);
|
|
@@ -3467,6 +3507,16 @@ var EntityManager = class {
|
|
|
3467
3507
|
changed: true
|
|
3468
3508
|
};
|
|
3469
3509
|
}
|
|
3510
|
+
if (next.kind === `attachment` && typeof next.streamPath === `string` && typeof next.id === `string`) for (const [sourceUrl, forkUrl] of entityUrlMap) {
|
|
3511
|
+
const prefix = `${sourceUrl}/attachments/`;
|
|
3512
|
+
if (!next.streamPath.startsWith(prefix)) continue;
|
|
3513
|
+
next.streamPath = getEntityAttachmentStreamPath(forkUrl, next.id);
|
|
3514
|
+
return {
|
|
3515
|
+
key,
|
|
3516
|
+
value: next,
|
|
3517
|
+
changed: true
|
|
3518
|
+
};
|
|
3519
|
+
}
|
|
3470
3520
|
if (next.kind === `schedule` && next.scheduleType === `future_send`) {
|
|
3471
3521
|
let changed = false;
|
|
3472
3522
|
if (typeof next.targetUrl === `string`) {
|
|
@@ -3664,6 +3714,93 @@ var EntityManager = class {
|
|
|
3664
3714
|
const envelope = entityStateSchema.inbox.delete({ key });
|
|
3665
3715
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
3666
3716
|
}
|
|
3717
|
+
isAttachmentStreamPath(path$1) {
|
|
3718
|
+
return /^\/[^/]+\/[^/]+\/attachments\/[^/]+$/.test(path$1);
|
|
3719
|
+
}
|
|
3720
|
+
async createAttachment(entityUrl, req) {
|
|
3721
|
+
const entity = await this.registry.getEntity(entityUrl);
|
|
3722
|
+
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3723
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3724
|
+
if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
|
|
3725
|
+
const id = req.id ?? randomUUID();
|
|
3726
|
+
validateAttachmentId(id);
|
|
3727
|
+
validateAttachmentSubject(req.subject);
|
|
3728
|
+
const limit = maxAttachmentBytes();
|
|
3729
|
+
if (req.bytes.length > limit) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment exceeds maximum size of ${limit} bytes`, 413);
|
|
3730
|
+
const mimeType = req.mimeType.trim() || `application/octet-stream`;
|
|
3731
|
+
const streamPath = getEntityAttachmentStreamPath(entityUrl, id);
|
|
3732
|
+
const manifestKey = manifestAttachmentKey(id);
|
|
3733
|
+
const txid = randomUUID();
|
|
3734
|
+
const now = new Date().toISOString();
|
|
3735
|
+
const sha256 = createHash(`sha256`).update(req.bytes).digest(`hex`);
|
|
3736
|
+
const attachment = {
|
|
3737
|
+
key: manifestKey,
|
|
3738
|
+
kind: `attachment`,
|
|
3739
|
+
id,
|
|
3740
|
+
streamPath,
|
|
3741
|
+
status: `complete`,
|
|
3742
|
+
subject: req.subject,
|
|
3743
|
+
role: req.role ?? `input`,
|
|
3744
|
+
mimeType,
|
|
3745
|
+
...req.filename ? { filename: req.filename } : {},
|
|
3746
|
+
byteLength: req.bytes.length,
|
|
3747
|
+
sha256,
|
|
3748
|
+
createdAt: now,
|
|
3749
|
+
...req.createdBy ? { createdBy: req.createdBy } : {},
|
|
3750
|
+
...req.meta ? { meta: req.meta } : {}
|
|
3751
|
+
};
|
|
3752
|
+
let streamCreated = false;
|
|
3753
|
+
try {
|
|
3754
|
+
await this.streamClient.create(streamPath, {
|
|
3755
|
+
contentType: mimeType,
|
|
3756
|
+
body: req.bytes,
|
|
3757
|
+
closed: true
|
|
3758
|
+
});
|
|
3759
|
+
streamCreated = true;
|
|
3760
|
+
await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, attachment, { txid });
|
|
3761
|
+
} catch (error) {
|
|
3762
|
+
if (streamCreated) await this.streamClient.delete(streamPath).catch(() => void 0);
|
|
3763
|
+
if (!streamCreated && isStreamCreateConflict(error)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment already exists at id "${id}"`, 409);
|
|
3764
|
+
throw error;
|
|
3765
|
+
}
|
|
3766
|
+
return {
|
|
3767
|
+
txid,
|
|
3768
|
+
attachment
|
|
3769
|
+
};
|
|
3770
|
+
}
|
|
3771
|
+
async getAttachment(entityUrl, id) {
|
|
3772
|
+
validateAttachmentId(id);
|
|
3773
|
+
const entity = await this.registry.getEntity(entityUrl);
|
|
3774
|
+
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3775
|
+
const events = await this.streamClient.readJson(entity.streams.main);
|
|
3776
|
+
const manifest = this.reduceStateRows(events, `manifest`).get(manifestAttachmentKey(id));
|
|
3777
|
+
if (!manifest || manifest.kind !== `attachment`) return null;
|
|
3778
|
+
return manifest;
|
|
3779
|
+
}
|
|
3780
|
+
async readAttachment(entityUrl, id) {
|
|
3781
|
+
const attachment = await this.getAttachment(entityUrl, id);
|
|
3782
|
+
if (!attachment) throw new ElectricAgentsError(ErrCodeNotFound, `Attachment not found`, 404);
|
|
3783
|
+
if (attachment.status !== `complete`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment is not complete`, 409);
|
|
3784
|
+
assertCanonicalAttachmentStreamPath(entityUrl, attachment);
|
|
3785
|
+
const result = await this.streamClient.read(attachment.streamPath);
|
|
3786
|
+
return {
|
|
3787
|
+
attachment,
|
|
3788
|
+
bytes: concatByteMessages(result.messages)
|
|
3789
|
+
};
|
|
3790
|
+
}
|
|
3791
|
+
async deleteAttachment(entityUrl, id) {
|
|
3792
|
+
const entity = await this.registry.getEntity(entityUrl);
|
|
3793
|
+
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3794
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3795
|
+
if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
|
|
3796
|
+
const attachment = await this.getAttachment(entityUrl, id);
|
|
3797
|
+
if (!attachment) throw new ElectricAgentsError(ErrCodeNotFound, `Attachment not found`, 404);
|
|
3798
|
+
assertCanonicalAttachmentStreamPath(entityUrl, attachment);
|
|
3799
|
+
const txid = randomUUID();
|
|
3800
|
+
await this.writeManifestEntry(entityUrl, manifestAttachmentKey(id), `delete`, void 0, { txid });
|
|
3801
|
+
await this.streamClient.delete(attachment.streamPath).catch(() => void 0);
|
|
3802
|
+
return { txid };
|
|
3803
|
+
}
|
|
3667
3804
|
async setTag(entityUrl, key, req, token) {
|
|
3668
3805
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3669
3806
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
@@ -6065,6 +6202,7 @@ async function handleStreamAppend(request, runtime, forward) {
|
|
|
6065
6202
|
const { manager } = runtime;
|
|
6066
6203
|
const entity = await manager.registry.getEntityByStream(path$1);
|
|
6067
6204
|
const isSharedState = path$1.startsWith(`/_electric/shared-state/`);
|
|
6205
|
+
if (!entity && manager.isAttachmentStreamPath(path$1)) return apiError(401, ErrCodeUnauthorized, `Invalid write token`);
|
|
6068
6206
|
if (!entity && !isSharedState) return void 0;
|
|
6069
6207
|
const body = await request.readBody();
|
|
6070
6208
|
const event = decodeStreamAppendEvent(body);
|
|
@@ -6633,8 +6771,9 @@ async function streamAppend(request, ctx) {
|
|
|
6633
6771
|
}));
|
|
6634
6772
|
}
|
|
6635
6773
|
async function proxyPassThrough(request, ctx) {
|
|
6636
|
-
const upstream = await forwardToDurableStreams(ctx, request);
|
|
6637
6774
|
const streamPath = new URL(request.url).pathname;
|
|
6775
|
+
if (ctx.entityManager?.isAttachmentStreamPath(streamPath)) return new Response(null, { status: 404 });
|
|
6776
|
+
const upstream = await forwardToDurableStreams(ctx, request);
|
|
6638
6777
|
const method = request.method.toUpperCase();
|
|
6639
6778
|
const endTrackedRead = method === `GET` ? await ctx.entityBridgeManager.beginClientRead(streamPath) : null;
|
|
6640
6779
|
try {
|
|
@@ -6787,6 +6926,13 @@ const eventSourceSubscriptionBodySchema = Type.Object({
|
|
|
6787
6926
|
lifetime: Type.Optional(subscriptionLifetimeSchema),
|
|
6788
6927
|
reason: Type.Optional(Type.String())
|
|
6789
6928
|
});
|
|
6929
|
+
const attachmentSubjectTypes = new Set([
|
|
6930
|
+
`inbox`,
|
|
6931
|
+
`run`,
|
|
6932
|
+
`text`,
|
|
6933
|
+
`tool_call`,
|
|
6934
|
+
`context`
|
|
6935
|
+
]);
|
|
6790
6936
|
const entitiesRouter = Router({ base: `/_electric/entities` });
|
|
6791
6937
|
entitiesRouter.get(`/`, listEntities);
|
|
6792
6938
|
entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spawnBodySchema), spawnEntity);
|
|
@@ -6795,6 +6941,9 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
|
|
|
6795
6941
|
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
|
|
6796
6942
|
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
|
|
6797
6943
|
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
|
|
6944
|
+
entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, createAttachment);
|
|
6945
|
+
entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, readAttachment);
|
|
6946
|
+
entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, deleteAttachment);
|
|
6798
6947
|
entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
|
|
6799
6948
|
entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
|
|
6800
6949
|
entitiesRouter.post(`/:type/:instanceId/fork`, withExistingEntity, withSchema(forkBodySchema), forkEntity);
|
|
@@ -6821,6 +6970,82 @@ function requireExistingEntityRoute(request) {
|
|
|
6821
6970
|
if (!request.entityRoute) throw new Error(`existing entity middleware did not run`);
|
|
6822
6971
|
return request.entityRoute;
|
|
6823
6972
|
}
|
|
6973
|
+
function invalidAttachmentRequest(message) {
|
|
6974
|
+
throw new ElectricAgentsError(ErrCodeInvalidRequest, message, 400);
|
|
6975
|
+
}
|
|
6976
|
+
function formString(form, key) {
|
|
6977
|
+
const value = form.get(key);
|
|
6978
|
+
if (typeof value !== `string`) return void 0;
|
|
6979
|
+
const trimmed = value.trim();
|
|
6980
|
+
return trimmed || void 0;
|
|
6981
|
+
}
|
|
6982
|
+
function parseJsonFormField(form, key) {
|
|
6983
|
+
const raw = formString(form, key);
|
|
6984
|
+
if (!raw) return void 0;
|
|
6985
|
+
try {
|
|
6986
|
+
return JSON.parse(raw);
|
|
6987
|
+
} catch {
|
|
6988
|
+
invalidAttachmentRequest(`Invalid JSON field: ${key}`);
|
|
6989
|
+
}
|
|
6990
|
+
}
|
|
6991
|
+
function parseAttachmentSubject(form) {
|
|
6992
|
+
const explicit = parseJsonFormField(form, `subject`);
|
|
6993
|
+
if (explicit !== void 0) {
|
|
6994
|
+
if (!explicit || typeof explicit !== `object` || Array.isArray(explicit)) invalidAttachmentRequest(`attachment subject must be an object`);
|
|
6995
|
+
const subject = explicit;
|
|
6996
|
+
const type$1 = subject.type;
|
|
6997
|
+
const key$1 = subject.key;
|
|
6998
|
+
if (typeof type$1 !== `string` || typeof key$1 !== `string`) invalidAttachmentRequest(`attachment subject requires type and key`);
|
|
6999
|
+
if (!attachmentSubjectTypes.has(type$1)) invalidAttachmentRequest(`invalid attachment subject type`);
|
|
7000
|
+
return {
|
|
7001
|
+
type: type$1,
|
|
7002
|
+
key: key$1
|
|
7003
|
+
};
|
|
7004
|
+
}
|
|
7005
|
+
const type = formString(form, `subjectType`);
|
|
7006
|
+
const key = formString(form, `subjectKey`);
|
|
7007
|
+
if (!type || !key) invalidAttachmentRequest(`attachment subject is required`);
|
|
7008
|
+
if (!attachmentSubjectTypes.has(type)) invalidAttachmentRequest(`invalid attachment subject type`);
|
|
7009
|
+
return {
|
|
7010
|
+
type,
|
|
7011
|
+
key
|
|
7012
|
+
};
|
|
7013
|
+
}
|
|
7014
|
+
function getUploadedFormFile(value) {
|
|
7015
|
+
if (value !== null && typeof value === `object` && `arrayBuffer` in value && typeof value.arrayBuffer === `function`) return value;
|
|
7016
|
+
return null;
|
|
7017
|
+
}
|
|
7018
|
+
async function parseAttachmentForm(request) {
|
|
7019
|
+
const contentType = request.headers.get(`content-type`)?.toLowerCase() ?? ``;
|
|
7020
|
+
if (!contentType.includes(`multipart/form-data`)) invalidAttachmentRequest(`Attachment uploads must use multipart/form-data`);
|
|
7021
|
+
let form;
|
|
7022
|
+
try {
|
|
7023
|
+
form = await request.formData();
|
|
7024
|
+
} catch {
|
|
7025
|
+
invalidAttachmentRequest(`Invalid multipart form data`);
|
|
7026
|
+
}
|
|
7027
|
+
const file = getUploadedFormFile(form.get(`file`));
|
|
7028
|
+
if (!file) invalidAttachmentRequest(`Missing file field`);
|
|
7029
|
+
const role = formString(form, `role`);
|
|
7030
|
+
if (role !== void 0 && role !== `input` && role !== `output`) invalidAttachmentRequest(`invalid attachment role`);
|
|
7031
|
+
const fileName = formString(form, `filename`) ?? (typeof file.name === `string` ? file.name : void 0);
|
|
7032
|
+
const mimeType = formString(form, `mimeType`) || (typeof file.type === `string` ? file.type : void 0) || `application/octet-stream`;
|
|
7033
|
+
const meta = parseJsonFormField(form, `meta`);
|
|
7034
|
+
if (meta !== void 0 && (typeof meta !== `object` || Array.isArray(meta))) invalidAttachmentRequest(`attachment meta must be an object`);
|
|
7035
|
+
return {
|
|
7036
|
+
id: formString(form, `id`),
|
|
7037
|
+
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
7038
|
+
mimeType,
|
|
7039
|
+
filename: fileName,
|
|
7040
|
+
subject: parseAttachmentSubject(form),
|
|
7041
|
+
role,
|
|
7042
|
+
meta
|
|
7043
|
+
};
|
|
7044
|
+
}
|
|
7045
|
+
function contentDisposition(filename) {
|
|
7046
|
+
const fallback = filename.replace(/["\\\r\n]/g, `_`);
|
|
7047
|
+
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
|
|
7048
|
+
}
|
|
6824
7049
|
function rejectPrincipalEntityMutation(request, action) {
|
|
6825
7050
|
const { entity } = requireExistingEntityRoute(request);
|
|
6826
7051
|
if (entity.type !== `principal`) return void 0;
|
|
@@ -7005,6 +7230,44 @@ async function sendEntity(request, ctx) {
|
|
|
7005
7230
|
});
|
|
7006
7231
|
return status(204);
|
|
7007
7232
|
}
|
|
7233
|
+
async function createAttachment(request, ctx) {
|
|
7234
|
+
const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
|
|
7235
|
+
if (principalMutationError) return principalMutationError;
|
|
7236
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
7237
|
+
const form = await parseAttachmentForm(request);
|
|
7238
|
+
const result = await ctx.entityManager.createAttachment(entityUrl, {
|
|
7239
|
+
id: form.id,
|
|
7240
|
+
bytes: form.bytes,
|
|
7241
|
+
mimeType: form.mimeType,
|
|
7242
|
+
filename: form.filename,
|
|
7243
|
+
subject: form.subject,
|
|
7244
|
+
role: form.role,
|
|
7245
|
+
createdBy: ctx.principal.url,
|
|
7246
|
+
meta: form.meta
|
|
7247
|
+
});
|
|
7248
|
+
return json(result, { status: 201 });
|
|
7249
|
+
}
|
|
7250
|
+
async function readAttachment(request, ctx) {
|
|
7251
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
7252
|
+
const result = await ctx.entityManager.readAttachment(entityUrl, decodeURIComponent(request.params.attachmentId));
|
|
7253
|
+
const headers = new Headers({
|
|
7254
|
+
"content-type": result.attachment.mimeType,
|
|
7255
|
+
"content-length": String(result.bytes.length),
|
|
7256
|
+
"cache-control": `private, max-age=31536000, immutable`
|
|
7257
|
+
});
|
|
7258
|
+
if (result.attachment.filename) headers.set(`content-disposition`, contentDisposition(result.attachment.filename));
|
|
7259
|
+
return new Response(result.bytes, {
|
|
7260
|
+
status: 200,
|
|
7261
|
+
headers
|
|
7262
|
+
});
|
|
7263
|
+
}
|
|
7264
|
+
async function deleteAttachment(request, ctx) {
|
|
7265
|
+
const principalMutationError = rejectPrincipalEntityMutation(request, `stripped of attachments`);
|
|
7266
|
+
if (principalMutationError) return principalMutationError;
|
|
7267
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
7268
|
+
const result = await ctx.entityManager.deleteAttachment(entityUrl, decodeURIComponent(request.params.attachmentId));
|
|
7269
|
+
return json(result);
|
|
7270
|
+
}
|
|
7008
7271
|
async function updateInboxMessage(request, ctx) {
|
|
7009
7272
|
const parsed = routeBody(request);
|
|
7010
7273
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
@@ -7605,7 +7868,7 @@ async function notificationFromClaim(ctx, input) {
|
|
|
7605
7868
|
leaseExpiresAt: input.claim.lease_ttl_ms ? new Date(Date.now() + input.claim.lease_ttl_ms) : void 0
|
|
7606
7869
|
});
|
|
7607
7870
|
await ctx.entityManager.registry.updateStatus(entity.url, `running`);
|
|
7608
|
-
const streams
|
|
7871
|
+
const streams = input.claim.streams.map((stream) => ({
|
|
7609
7872
|
path: withLeadingSlash(stream.path),
|
|
7610
7873
|
offset: stream.tail_offset ?? ``
|
|
7611
7874
|
}));
|
|
@@ -7614,7 +7877,7 @@ async function notificationFromClaim(ctx, input) {
|
|
|
7614
7877
|
epoch: input.claim.generation,
|
|
7615
7878
|
wakeId: input.claim.wake_id,
|
|
7616
7879
|
streamPath: primaryStream,
|
|
7617
|
-
streams
|
|
7880
|
+
streams,
|
|
7618
7881
|
callback: appendPathToUrl(ctx.publicUrl, `/_electric/wake-callbacks/${encodeURIComponent(input.claim.wake_id)}`),
|
|
7619
7882
|
claimToken: input.claim.token,
|
|
7620
7883
|
triggerEvent: `message_received`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@electric-ax/agents-server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.14",
|
|
4
4
|
"description": "Electric Agents entity runtime server",
|
|
5
5
|
"author": "Durable Stream contributors",
|
|
6
6
|
"bin": {
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
"sideEffects": false,
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
39
|
-
"@durable-streams/client": "
|
|
40
|
-
"@durable-streams/server": "
|
|
41
|
-
"@durable-streams/state": "
|
|
42
|
-
"@electric-sql/client": "^1.5.
|
|
39
|
+
"@durable-streams/client": "^0.2.6",
|
|
40
|
+
"@durable-streams/server": "^0.3.5",
|
|
41
|
+
"@durable-streams/state": "^0.2.9",
|
|
42
|
+
"@electric-sql/client": "^1.5.20",
|
|
43
43
|
"@mariozechner/pi-agent-core": "^0.70.2",
|
|
44
44
|
"@opentelemetry/api": "^1.9.1",
|
|
45
45
|
"@sinclair/typebox": "^0.34.48",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"pino-pretty": "^13.0.0",
|
|
55
55
|
"postgres": "^3.4.0",
|
|
56
56
|
"undici": "^7.24.7",
|
|
57
|
-
"@electric-ax/agents-runtime": "0.3.
|
|
57
|
+
"@electric-ax/agents-runtime": "0.3.7"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^22.19.15",
|
|
@@ -65,9 +65,9 @@
|
|
|
65
65
|
"tsx": "^4.19.0",
|
|
66
66
|
"typescript": "^5.0.0",
|
|
67
67
|
"vitest": "^4.1.0",
|
|
68
|
-
"@electric-ax/agents
|
|
69
|
-
"@electric-ax/agents": "0.
|
|
70
|
-
"@electric-ax/agents-server-ui": "0.4.
|
|
68
|
+
"@electric-ax/agents": "0.4.11",
|
|
69
|
+
"@electric-ax/agents-server-conformance-tests": "0.1.9",
|
|
70
|
+
"@electric-ax/agents-server-ui": "0.4.14"
|
|
71
71
|
},
|
|
72
72
|
"files": [
|
|
73
73
|
"dist",
|