@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/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
- const LOG_DIR = USE_FILE_LOGS ? process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`) : void 0;
1193
- const LOG_FILE = LOG_DIR ? path.join(LOG_DIR, `agent-server-${Date.now()}.jsonl`) : void 0;
1194
- if (LOG_DIR) fs.mkdirSync(LOG_DIR, { recursive: true });
1195
- const streams = [];
1196
- if (LOG_FILE) streams.push({ stream: pino.destination({
1197
- dest: LOG_FILE,
1198
- sync: IS_ELECTRON_MAIN
1199
- }) });
1200
- if (USE_PRETTY_LOGS) streams.push({ stream: pino.transport({
1201
- target: `pino-pretty`,
1202
- options: {
1203
- colorize: true,
1204
- ignore: `pid,hostname,name`,
1205
- translateTime: `SYS:HH:MM:ss`
1206
- }
1207
- }) });
1208
- const logger = streams.length > 0 ? pino({
1209
- base: void 0,
1210
- level: LOG_LEVEL
1211
- }, pino.multistream(streams)) : pino({
1212
- base: void 0,
1213
- enabled: false,
1214
- level: LOG_LEVEL
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 a of args) if (a instanceof Error) errors.push(a);
1220
- else parts.push(typeof a === `string` ? a : JSON.stringify(a));
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
- logger.info(msg);
1242
+ getLogger().info(msg);
1230
1243
  },
1231
1244
  warn(...args) {
1232
1245
  const { err, msg } = formatArgs(args);
1233
- if (err) logger.warn({ err }, msg);
1234
- else logger.warn(msg);
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) logger.error({ err }, msg);
1239
- else logger.error(msg);
1251
+ if (err) getLogger().error({ err }, msg);
1252
+ else getLogger().error(msg);
1240
1253
  },
1241
1254
  event(obj, msg) {
1242
- logger.info(obj, msg);
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 messages = [];
2064
- return await new Promise((resolve$1, reject) => {
2065
- let settled = false;
2066
- let unsub = () => {};
2067
- const finish = (r) => {
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$1) {
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$1.map((stream) => this.backendSubscriptionPath(normalizeSubscriptionStreamPath(stream))) })
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$1 = input.claim.streams.map((stream) => ({
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: streams$1,
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.12",
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": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/client@5d5c217",
40
- "@durable-streams/server": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/server@eac712f",
41
- "@durable-streams/state": "https://pkg.pr.new/durable-streams/durable-streams/@durable-streams/state@5d5c217",
42
- "@electric-sql/client": "^1.5.19",
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.5"
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-server-conformance-tests": "0.1.8",
69
- "@electric-ax/agents": "0.4.9",
70
- "@electric-ax/agents-server-ui": "0.4.12"
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",