@electric-ax/agents-server 0.4.12 → 0.4.13

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.
@@ -634,7 +634,8 @@ var StreamClient = class {
634
634
  url: this.streamUrl(path$1),
635
635
  headers: this.streamHeaders(),
636
636
  contentType: opts.contentType,
637
- body: opts.body
637
+ body: opts.body,
638
+ closed: opts.closed
638
639
  });
639
640
  });
640
641
  }
@@ -734,30 +735,11 @@ var StreamClient = class {
734
735
  offset: fromOffset ?? `-1`,
735
736
  live: false
736
737
  });
737
- const messages = [];
738
- return await new Promise((resolve$1, reject) => {
739
- let settled = false;
740
- let unsub = () => {};
741
- const finish = (r) => {
742
- if (settled) return;
743
- settled = true;
744
- unsub();
745
- resolve$1(r);
746
- };
747
- unsub = response.subscribeBytes((chunk) => {
748
- messages.push({
749
- data: chunk.data,
750
- offset: chunk.offset
751
- });
752
- if (chunk.upToDate || chunk.streamClosed) finish({ messages });
753
- });
754
- response.closed.then(() => finish({ messages })).catch((err) => {
755
- if (settled) return;
756
- settled = true;
757
- unsub();
758
- reject(err);
759
- });
760
- });
738
+ const body = await response.body();
739
+ return { messages: body.length === 0 ? [] : [{
740
+ data: body,
741
+ offset: response.offset
742
+ }] };
761
743
  });
762
744
  }
763
745
  async readJson(path$1, fromOffset) {
@@ -1228,6 +1210,7 @@ async function handleStreamAppend(request, runtime, forward) {
1228
1210
  const { manager } = runtime;
1229
1211
  const entity = await manager.registry.getEntityByStream(path$1);
1230
1212
  const isSharedState = path$1.startsWith(`/_electric/shared-state/`);
1213
+ if (!entity && manager.isAttachmentStreamPath(path$1)) return apiError(401, ErrCodeUnauthorized, `Invalid write token`);
1231
1214
  if (!entity && !isSharedState) return void 0;
1232
1215
  const body = await request.readBody();
1233
1216
  const event = decodeStreamAppendEvent(body);
@@ -1721,8 +1704,9 @@ async function streamAppend(request, ctx) {
1721
1704
  }));
1722
1705
  }
1723
1706
  async function proxyPassThrough(request, ctx) {
1724
- const upstream = await forwardToDurableStreams(ctx, request);
1725
1707
  const streamPath = new URL(request.url).pathname;
1708
+ if (ctx.entityManager?.isAttachmentStreamPath(streamPath)) return new Response(null, { status: 404 });
1709
+ const upstream = await forwardToDurableStreams(ctx, request);
1726
1710
  const method = request.method.toUpperCase();
1727
1711
  const endTrackedRead = method === `GET` ? await ctx.entityBridgeManager.beginClientRead(streamPath) : null;
1728
1712
  try {
@@ -2720,9 +2704,45 @@ function createInitialQueuePosition(date) {
2720
2704
  const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
2721
2705
  const DEFAULT_FORK_WAIT_POLL_MS = 250;
2722
2706
  const SERVER_SIGNAL_SENDER = `/_electric/server`;
2707
+ const DEFAULT_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
2723
2708
  function sleep(ms) {
2724
2709
  return new Promise((resolve$1) => setTimeout(resolve$1, ms));
2725
2710
  }
2711
+ function maxAttachmentBytes() {
2712
+ const configured = Number(process.env.ELECTRIC_AGENTS_MAX_ATTACHMENT_BYTES);
2713
+ return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : DEFAULT_MAX_ATTACHMENT_BYTES;
2714
+ }
2715
+ function manifestAttachmentKey(id) {
2716
+ return `attachment:${id}`;
2717
+ }
2718
+ function getEntityAttachmentStreamPath(entityUrl, attachmentId) {
2719
+ return `${entityUrl.replace(/\/+$/, ``)}/attachments/${attachmentId}`;
2720
+ }
2721
+ function isStreamCreateConflict(error) {
2722
+ return !!error && typeof error === `object` && (`status` in error && error.status === 409 || `code` in error && error.code === `CONFLICT_SEQ`);
2723
+ }
2724
+ function assertCanonicalAttachmentStreamPath(entityUrl, attachment) {
2725
+ const expected = getEntityAttachmentStreamPath(entityUrl, attachment.id);
2726
+ if (attachment.streamPath === expected) return;
2727
+ throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment stream path does not match its entity and id`, 409);
2728
+ }
2729
+ function validateAttachmentId(id) {
2730
+ if (!id || id.includes(`/`) || id.startsWith(`.`)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `attachment id must not be empty, start with ".", or contain forward slashes`, 400);
2731
+ }
2732
+ function validateAttachmentSubject(subject) {
2733
+ if (!subject.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `attachment subject key is required`, 400);
2734
+ 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);
2735
+ }
2736
+ function concatByteMessages(messages) {
2737
+ const total = messages.reduce((sum, message) => sum + message.data.length, 0);
2738
+ const bytes = new Uint8Array(total);
2739
+ let offset = 0;
2740
+ for (const message of messages) {
2741
+ bytes.set(message.data, offset);
2742
+ offset += message.data.length;
2743
+ }
2744
+ return bytes;
2745
+ }
2726
2746
  function omitUndefined$1(value) {
2727
2747
  return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== void 0));
2728
2748
  }
@@ -3088,6 +3108,15 @@ var EntityManager = class {
3088
3108
  await this.streamClient.fork(forkPath, sourcePath);
3089
3109
  createdStreams.push(forkPath);
3090
3110
  }
3111
+ for (const plan of entityPlans) {
3112
+ const manifests = snapshot.manifestsByEntity.get(plan.source.url) ?? new Map();
3113
+ for (const manifest of manifests.values()) {
3114
+ if (manifest.kind !== `attachment` || typeof manifest.streamPath !== `string` || typeof manifest.id !== `string`) continue;
3115
+ const forkPath = getEntityAttachmentStreamPath(plan.fork.url, manifest.id);
3116
+ await this.streamClient.fork(forkPath, manifest.streamPath);
3117
+ createdStreams.push(forkPath);
3118
+ }
3119
+ }
3091
3120
  for (const plan of entityPlans) {
3092
3121
  const reconciliation = this.buildForkReconciliation(plan, snapshot, entityUrlMap, sharedStateIdMap, stringMap);
3093
3122
  activeManifestsByEntity.set(plan.fork.url, reconciliation.manifests);
@@ -3497,6 +3526,16 @@ var EntityManager = class {
3497
3526
  changed: true
3498
3527
  };
3499
3528
  }
3529
+ if (next.kind === `attachment` && typeof next.streamPath === `string` && typeof next.id === `string`) for (const [sourceUrl, forkUrl] of entityUrlMap) {
3530
+ const prefix = `${sourceUrl}/attachments/`;
3531
+ if (!next.streamPath.startsWith(prefix)) continue;
3532
+ next.streamPath = getEntityAttachmentStreamPath(forkUrl, next.id);
3533
+ return {
3534
+ key,
3535
+ value: next,
3536
+ changed: true
3537
+ };
3538
+ }
3500
3539
  if (next.kind === `schedule` && next.scheduleType === `future_send`) {
3501
3540
  let changed = false;
3502
3541
  if (typeof next.targetUrl === `string`) {
@@ -3694,6 +3733,93 @@ var EntityManager = class {
3694
3733
  const envelope = entityStateSchema.inbox.delete({ key });
3695
3734
  await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
3696
3735
  }
3736
+ isAttachmentStreamPath(path$1) {
3737
+ return /^\/[^/]+\/[^/]+\/attachments\/[^/]+$/.test(path$1);
3738
+ }
3739
+ async createAttachment(entityUrl, req) {
3740
+ const entity = await this.registry.getEntity(entityUrl);
3741
+ if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3742
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3743
+ if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
3744
+ const id = req.id ?? randomUUID();
3745
+ validateAttachmentId(id);
3746
+ validateAttachmentSubject(req.subject);
3747
+ const limit = maxAttachmentBytes();
3748
+ if (req.bytes.length > limit) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment exceeds maximum size of ${limit} bytes`, 413);
3749
+ const mimeType = req.mimeType.trim() || `application/octet-stream`;
3750
+ const streamPath = getEntityAttachmentStreamPath(entityUrl, id);
3751
+ const manifestKey = manifestAttachmentKey(id);
3752
+ const txid = randomUUID();
3753
+ const now = new Date().toISOString();
3754
+ const sha256 = createHash(`sha256`).update(req.bytes).digest(`hex`);
3755
+ const attachment = {
3756
+ key: manifestKey,
3757
+ kind: `attachment`,
3758
+ id,
3759
+ streamPath,
3760
+ status: `complete`,
3761
+ subject: req.subject,
3762
+ role: req.role ?? `input`,
3763
+ mimeType,
3764
+ ...req.filename ? { filename: req.filename } : {},
3765
+ byteLength: req.bytes.length,
3766
+ sha256,
3767
+ createdAt: now,
3768
+ ...req.createdBy ? { createdBy: req.createdBy } : {},
3769
+ ...req.meta ? { meta: req.meta } : {}
3770
+ };
3771
+ let streamCreated = false;
3772
+ try {
3773
+ await this.streamClient.create(streamPath, {
3774
+ contentType: mimeType,
3775
+ body: req.bytes,
3776
+ closed: true
3777
+ });
3778
+ streamCreated = true;
3779
+ await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, attachment, { txid });
3780
+ } catch (error) {
3781
+ if (streamCreated) await this.streamClient.delete(streamPath).catch(() => void 0);
3782
+ if (!streamCreated && isStreamCreateConflict(error)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment already exists at id "${id}"`, 409);
3783
+ throw error;
3784
+ }
3785
+ return {
3786
+ txid,
3787
+ attachment
3788
+ };
3789
+ }
3790
+ async getAttachment(entityUrl, id) {
3791
+ validateAttachmentId(id);
3792
+ const entity = await this.registry.getEntity(entityUrl);
3793
+ if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3794
+ const events = await this.streamClient.readJson(entity.streams.main);
3795
+ const manifest = this.reduceStateRows(events, `manifest`).get(manifestAttachmentKey(id));
3796
+ if (!manifest || manifest.kind !== `attachment`) return null;
3797
+ return manifest;
3798
+ }
3799
+ async readAttachment(entityUrl, id) {
3800
+ const attachment = await this.getAttachment(entityUrl, id);
3801
+ if (!attachment) throw new ElectricAgentsError(ErrCodeNotFound, `Attachment not found`, 404);
3802
+ if (attachment.status !== `complete`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment is not complete`, 409);
3803
+ assertCanonicalAttachmentStreamPath(entityUrl, attachment);
3804
+ const result = await this.streamClient.read(attachment.streamPath);
3805
+ return {
3806
+ attachment,
3807
+ bytes: concatByteMessages(result.messages)
3808
+ };
3809
+ }
3810
+ async deleteAttachment(entityUrl, id) {
3811
+ const entity = await this.registry.getEntity(entityUrl);
3812
+ if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3813
+ if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
3814
+ if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
3815
+ const attachment = await this.getAttachment(entityUrl, id);
3816
+ if (!attachment) throw new ElectricAgentsError(ErrCodeNotFound, `Attachment not found`, 404);
3817
+ assertCanonicalAttachmentStreamPath(entityUrl, attachment);
3818
+ const txid = randomUUID();
3819
+ await this.writeManifestEntry(entityUrl, manifestAttachmentKey(id), `delete`, void 0, { txid });
3820
+ await this.streamClient.delete(attachment.streamPath).catch(() => void 0);
3821
+ return { txid };
3822
+ }
3697
3823
  async setTag(entityUrl, key, req, token) {
3698
3824
  const entity = await this.registry.getEntity(entityUrl);
3699
3825
  if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
@@ -4579,6 +4705,13 @@ const eventSourceSubscriptionBodySchema = Type.Object({
4579
4705
  lifetime: Type.Optional(subscriptionLifetimeSchema),
4580
4706
  reason: Type.Optional(Type.String())
4581
4707
  });
4708
+ const attachmentSubjectTypes = new Set([
4709
+ `inbox`,
4710
+ `run`,
4711
+ `text`,
4712
+ `tool_call`,
4713
+ `context`
4714
+ ]);
4582
4715
  const entitiesRouter = Router({ base: `/_electric/entities` });
4583
4716
  entitiesRouter.get(`/`, listEntities);
4584
4717
  entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spawnBodySchema), spawnEntity);
@@ -4587,6 +4720,9 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
4587
4720
  entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
4588
4721
  entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
4589
4722
  entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
4723
+ entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, createAttachment);
4724
+ entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, readAttachment);
4725
+ entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, deleteAttachment);
4590
4726
  entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
4591
4727
  entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
4592
4728
  entitiesRouter.post(`/:type/:instanceId/fork`, withExistingEntity, withSchema(forkBodySchema), forkEntity);
@@ -4613,6 +4749,82 @@ function requireExistingEntityRoute(request) {
4613
4749
  if (!request.entityRoute) throw new Error(`existing entity middleware did not run`);
4614
4750
  return request.entityRoute;
4615
4751
  }
4752
+ function invalidAttachmentRequest(message) {
4753
+ throw new ElectricAgentsError(ErrCodeInvalidRequest, message, 400);
4754
+ }
4755
+ function formString(form, key) {
4756
+ const value = form.get(key);
4757
+ if (typeof value !== `string`) return void 0;
4758
+ const trimmed = value.trim();
4759
+ return trimmed || void 0;
4760
+ }
4761
+ function parseJsonFormField(form, key) {
4762
+ const raw = formString(form, key);
4763
+ if (!raw) return void 0;
4764
+ try {
4765
+ return JSON.parse(raw);
4766
+ } catch {
4767
+ invalidAttachmentRequest(`Invalid JSON field: ${key}`);
4768
+ }
4769
+ }
4770
+ function parseAttachmentSubject(form) {
4771
+ const explicit = parseJsonFormField(form, `subject`);
4772
+ if (explicit !== void 0) {
4773
+ if (!explicit || typeof explicit !== `object` || Array.isArray(explicit)) invalidAttachmentRequest(`attachment subject must be an object`);
4774
+ const subject = explicit;
4775
+ const type$1 = subject.type;
4776
+ const key$1 = subject.key;
4777
+ if (typeof type$1 !== `string` || typeof key$1 !== `string`) invalidAttachmentRequest(`attachment subject requires type and key`);
4778
+ if (!attachmentSubjectTypes.has(type$1)) invalidAttachmentRequest(`invalid attachment subject type`);
4779
+ return {
4780
+ type: type$1,
4781
+ key: key$1
4782
+ };
4783
+ }
4784
+ const type = formString(form, `subjectType`);
4785
+ const key = formString(form, `subjectKey`);
4786
+ if (!type || !key) invalidAttachmentRequest(`attachment subject is required`);
4787
+ if (!attachmentSubjectTypes.has(type)) invalidAttachmentRequest(`invalid attachment subject type`);
4788
+ return {
4789
+ type,
4790
+ key
4791
+ };
4792
+ }
4793
+ function getUploadedFormFile(value) {
4794
+ if (value !== null && typeof value === `object` && `arrayBuffer` in value && typeof value.arrayBuffer === `function`) return value;
4795
+ return null;
4796
+ }
4797
+ async function parseAttachmentForm(request) {
4798
+ const contentType = request.headers.get(`content-type`)?.toLowerCase() ?? ``;
4799
+ if (!contentType.includes(`multipart/form-data`)) invalidAttachmentRequest(`Attachment uploads must use multipart/form-data`);
4800
+ let form;
4801
+ try {
4802
+ form = await request.formData();
4803
+ } catch {
4804
+ invalidAttachmentRequest(`Invalid multipart form data`);
4805
+ }
4806
+ const file = getUploadedFormFile(form.get(`file`));
4807
+ if (!file) invalidAttachmentRequest(`Missing file field`);
4808
+ const role = formString(form, `role`);
4809
+ if (role !== void 0 && role !== `input` && role !== `output`) invalidAttachmentRequest(`invalid attachment role`);
4810
+ const fileName = formString(form, `filename`) ?? (typeof file.name === `string` ? file.name : void 0);
4811
+ const mimeType = formString(form, `mimeType`) || (typeof file.type === `string` ? file.type : void 0) || `application/octet-stream`;
4812
+ const meta = parseJsonFormField(form, `meta`);
4813
+ if (meta !== void 0 && (typeof meta !== `object` || Array.isArray(meta))) invalidAttachmentRequest(`attachment meta must be an object`);
4814
+ return {
4815
+ id: formString(form, `id`),
4816
+ bytes: new Uint8Array(await file.arrayBuffer()),
4817
+ mimeType,
4818
+ filename: fileName,
4819
+ subject: parseAttachmentSubject(form),
4820
+ role,
4821
+ meta
4822
+ };
4823
+ }
4824
+ function contentDisposition(filename) {
4825
+ const fallback = filename.replace(/["\\\r\n]/g, `_`);
4826
+ return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
4827
+ }
4616
4828
  function rejectPrincipalEntityMutation(request, action) {
4617
4829
  const { entity } = requireExistingEntityRoute(request);
4618
4830
  if (entity.type !== `principal`) return void 0;
@@ -4797,6 +5009,44 @@ async function sendEntity(request, ctx) {
4797
5009
  });
4798
5010
  return status(204);
4799
5011
  }
5012
+ async function createAttachment(request, ctx) {
5013
+ const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
5014
+ if (principalMutationError) return principalMutationError;
5015
+ const { entityUrl } = requireExistingEntityRoute(request);
5016
+ const form = await parseAttachmentForm(request);
5017
+ const result = await ctx.entityManager.createAttachment(entityUrl, {
5018
+ id: form.id,
5019
+ bytes: form.bytes,
5020
+ mimeType: form.mimeType,
5021
+ filename: form.filename,
5022
+ subject: form.subject,
5023
+ role: form.role,
5024
+ createdBy: ctx.principal.url,
5025
+ meta: form.meta
5026
+ });
5027
+ return json(result, { status: 201 });
5028
+ }
5029
+ async function readAttachment(request, ctx) {
5030
+ const { entityUrl } = requireExistingEntityRoute(request);
5031
+ const result = await ctx.entityManager.readAttachment(entityUrl, decodeURIComponent(request.params.attachmentId));
5032
+ const headers = new Headers({
5033
+ "content-type": result.attachment.mimeType,
5034
+ "content-length": String(result.bytes.length),
5035
+ "cache-control": `private, max-age=31536000, immutable`
5036
+ });
5037
+ if (result.attachment.filename) headers.set(`content-disposition`, contentDisposition(result.attachment.filename));
5038
+ return new Response(result.bytes, {
5039
+ status: 200,
5040
+ headers
5041
+ });
5042
+ }
5043
+ async function deleteAttachment(request, ctx) {
5044
+ const principalMutationError = rejectPrincipalEntityMutation(request, `stripped of attachments`);
5045
+ if (principalMutationError) return principalMutationError;
5046
+ const { entityUrl } = requireExistingEntityRoute(request);
5047
+ const result = await ctx.entityManager.deleteAttachment(entityUrl, decodeURIComponent(request.params.attachmentId));
5048
+ return json(result);
5049
+ }
4800
5050
  async function updateInboxMessage(request, ctx) {
4801
5051
  const parsed = routeBody(request);
4802
5052
  const { entityUrl } = requireExistingEntityRoute(request);