@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/entrypoint.js
CHANGED
|
@@ -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
|
|
738
|
-
return
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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) {
|
|
@@ -911,11 +893,11 @@ var StreamClient = class {
|
|
|
911
893
|
if (res.status === 404 || res.status === 204) return;
|
|
912
894
|
if (!res.ok) throw new Error(`Subscription delete failed: ${res.status} ${await res.text()}`);
|
|
913
895
|
}
|
|
914
|
-
async addSubscriptionStreams(subscriptionId, streams
|
|
896
|
+
async addSubscriptionStreams(subscriptionId, streams) {
|
|
915
897
|
const res = await fetch(this.subscriptionChildUrl(subscriptionId, `streams`), {
|
|
916
898
|
method: `POST`,
|
|
917
899
|
headers: await this.requestHeaders({ "content-type": `application/json` }),
|
|
918
|
-
body: JSON.stringify({ streams: streams
|
|
900
|
+
body: JSON.stringify({ streams: streams.map((stream) => this.backendSubscriptionPath(normalizeSubscriptionStreamPath(stream))) })
|
|
919
901
|
});
|
|
920
902
|
return await this.subscriptionJson(res, `Subscription stream add failed`);
|
|
921
903
|
}
|
|
@@ -1157,35 +1139,48 @@ const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
|
|
|
1157
1139
|
const IS_ELECTRON_MAIN = Boolean(process.versions.electron);
|
|
1158
1140
|
const USE_FILE_LOGS = process.env.ELECTRIC_AGENTS_LOG_FILE !== `false`;
|
|
1159
1141
|
const USE_PRETTY_LOGS = LOG_LEVEL !== `silent` && !process.env.VITEST && !IS_ELECTRON_MAIN;
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
if (
|
|
1163
|
-
const streams = [];
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1142
|
+
let _logger;
|
|
1143
|
+
function getLogger() {
|
|
1144
|
+
if (_logger) return _logger;
|
|
1145
|
+
const streams = [];
|
|
1146
|
+
try {
|
|
1147
|
+
if (USE_FILE_LOGS) {
|
|
1148
|
+
const logDir = process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`);
|
|
1149
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
1150
|
+
const logFile = path.join(logDir, `agent-server-${Date.now()}.jsonl`);
|
|
1151
|
+
streams.push({ stream: pino.destination({
|
|
1152
|
+
dest: logFile,
|
|
1153
|
+
sync: IS_ELECTRON_MAIN
|
|
1154
|
+
}) });
|
|
1155
|
+
}
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
process.stderr.write(`[agents-server] Failed to initialize file logging: ${err instanceof Error ? err.message : err}\n`);
|
|
1158
|
+
}
|
|
1159
|
+
try {
|
|
1160
|
+
if (USE_PRETTY_LOGS) streams.push({ stream: pino.transport({
|
|
1161
|
+
target: `pino-pretty`,
|
|
1162
|
+
options: {
|
|
1163
|
+
colorize: true,
|
|
1164
|
+
ignore: `pid,hostname,name`,
|
|
1165
|
+
translateTime: `SYS:HH:MM:ss`
|
|
1166
|
+
}
|
|
1167
|
+
}) });
|
|
1168
|
+
} catch {}
|
|
1169
|
+
_logger = streams.length > 0 ? pino({
|
|
1170
|
+
base: void 0,
|
|
1171
|
+
level: LOG_LEVEL
|
|
1172
|
+
}, pino.multistream(streams)) : pino({
|
|
1173
|
+
base: void 0,
|
|
1174
|
+
enabled: false,
|
|
1175
|
+
level: LOG_LEVEL
|
|
1176
|
+
});
|
|
1177
|
+
return _logger;
|
|
1178
|
+
}
|
|
1184
1179
|
function formatArgs(args) {
|
|
1185
1180
|
const errors = [];
|
|
1186
1181
|
const parts = [];
|
|
1187
|
-
for (const
|
|
1188
|
-
else parts.push(typeof
|
|
1182
|
+
for (const value of args) if (value instanceof Error) errors.push(value);
|
|
1183
|
+
else parts.push(typeof value === `string` ? value : JSON.stringify(value));
|
|
1189
1184
|
return {
|
|
1190
1185
|
err: errors[0],
|
|
1191
1186
|
msg: parts.join(` `)
|
|
@@ -1194,20 +1189,20 @@ function formatArgs(args) {
|
|
|
1194
1189
|
const serverLog = {
|
|
1195
1190
|
info(...args) {
|
|
1196
1191
|
const { msg } = formatArgs(args);
|
|
1197
|
-
|
|
1192
|
+
getLogger().info(msg);
|
|
1198
1193
|
},
|
|
1199
1194
|
warn(...args) {
|
|
1200
1195
|
const { err, msg } = formatArgs(args);
|
|
1201
|
-
if (err)
|
|
1202
|
-
else
|
|
1196
|
+
if (err) getLogger().warn({ err }, msg);
|
|
1197
|
+
else getLogger().warn(msg);
|
|
1203
1198
|
},
|
|
1204
1199
|
error(...args) {
|
|
1205
1200
|
const { err, msg } = formatArgs(args);
|
|
1206
|
-
if (err)
|
|
1207
|
-
else
|
|
1201
|
+
if (err) getLogger().error({ err }, msg);
|
|
1202
|
+
else getLogger().error(msg);
|
|
1208
1203
|
},
|
|
1209
1204
|
event(obj, msg) {
|
|
1210
|
-
|
|
1205
|
+
getLogger().info(obj, msg);
|
|
1211
1206
|
}
|
|
1212
1207
|
};
|
|
1213
1208
|
|
|
@@ -1228,6 +1223,7 @@ async function handleStreamAppend(request, runtime, forward) {
|
|
|
1228
1223
|
const { manager } = runtime;
|
|
1229
1224
|
const entity = await manager.registry.getEntityByStream(path$1);
|
|
1230
1225
|
const isSharedState = path$1.startsWith(`/_electric/shared-state/`);
|
|
1226
|
+
if (!entity && manager.isAttachmentStreamPath(path$1)) return apiError(401, ErrCodeUnauthorized, `Invalid write token`);
|
|
1231
1227
|
if (!entity && !isSharedState) return void 0;
|
|
1232
1228
|
const body = await request.readBody();
|
|
1233
1229
|
const event = decodeStreamAppendEvent(body);
|
|
@@ -1721,8 +1717,9 @@ async function streamAppend(request, ctx) {
|
|
|
1721
1717
|
}));
|
|
1722
1718
|
}
|
|
1723
1719
|
async function proxyPassThrough(request, ctx) {
|
|
1724
|
-
const upstream = await forwardToDurableStreams(ctx, request);
|
|
1725
1720
|
const streamPath = new URL(request.url).pathname;
|
|
1721
|
+
if (ctx.entityManager?.isAttachmentStreamPath(streamPath)) return new Response(null, { status: 404 });
|
|
1722
|
+
const upstream = await forwardToDurableStreams(ctx, request);
|
|
1726
1723
|
const method = request.method.toUpperCase();
|
|
1727
1724
|
const endTrackedRead = method === `GET` ? await ctx.entityBridgeManager.beginClientRead(streamPath) : null;
|
|
1728
1725
|
try {
|
|
@@ -2720,9 +2717,45 @@ function createInitialQueuePosition(date) {
|
|
|
2720
2717
|
const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
|
|
2721
2718
|
const DEFAULT_FORK_WAIT_POLL_MS = 250;
|
|
2722
2719
|
const SERVER_SIGNAL_SENDER = `/_electric/server`;
|
|
2720
|
+
const DEFAULT_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
2723
2721
|
function sleep(ms) {
|
|
2724
2722
|
return new Promise((resolve$1) => setTimeout(resolve$1, ms));
|
|
2725
2723
|
}
|
|
2724
|
+
function maxAttachmentBytes() {
|
|
2725
|
+
const configured = Number(process.env.ELECTRIC_AGENTS_MAX_ATTACHMENT_BYTES);
|
|
2726
|
+
return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : DEFAULT_MAX_ATTACHMENT_BYTES;
|
|
2727
|
+
}
|
|
2728
|
+
function manifestAttachmentKey(id) {
|
|
2729
|
+
return `attachment:${id}`;
|
|
2730
|
+
}
|
|
2731
|
+
function getEntityAttachmentStreamPath(entityUrl, attachmentId) {
|
|
2732
|
+
return `${entityUrl.replace(/\/+$/, ``)}/attachments/${attachmentId}`;
|
|
2733
|
+
}
|
|
2734
|
+
function isStreamCreateConflict(error) {
|
|
2735
|
+
return !!error && typeof error === `object` && (`status` in error && error.status === 409 || `code` in error && error.code === `CONFLICT_SEQ`);
|
|
2736
|
+
}
|
|
2737
|
+
function assertCanonicalAttachmentStreamPath(entityUrl, attachment) {
|
|
2738
|
+
const expected = getEntityAttachmentStreamPath(entityUrl, attachment.id);
|
|
2739
|
+
if (attachment.streamPath === expected) return;
|
|
2740
|
+
throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment stream path does not match its entity and id`, 409);
|
|
2741
|
+
}
|
|
2742
|
+
function validateAttachmentId(id) {
|
|
2743
|
+
if (!id || id.includes(`/`) || id.startsWith(`.`)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `attachment id must not be empty, start with ".", or contain forward slashes`, 400);
|
|
2744
|
+
}
|
|
2745
|
+
function validateAttachmentSubject(subject) {
|
|
2746
|
+
if (!subject.key) throw new ElectricAgentsError(ErrCodeInvalidRequest, `attachment subject key is required`, 400);
|
|
2747
|
+
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);
|
|
2748
|
+
}
|
|
2749
|
+
function concatByteMessages(messages) {
|
|
2750
|
+
const total = messages.reduce((sum, message) => sum + message.data.length, 0);
|
|
2751
|
+
const bytes = new Uint8Array(total);
|
|
2752
|
+
let offset = 0;
|
|
2753
|
+
for (const message of messages) {
|
|
2754
|
+
bytes.set(message.data, offset);
|
|
2755
|
+
offset += message.data.length;
|
|
2756
|
+
}
|
|
2757
|
+
return bytes;
|
|
2758
|
+
}
|
|
2726
2759
|
function omitUndefined$1(value) {
|
|
2727
2760
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== void 0));
|
|
2728
2761
|
}
|
|
@@ -3088,6 +3121,15 @@ var EntityManager = class {
|
|
|
3088
3121
|
await this.streamClient.fork(forkPath, sourcePath);
|
|
3089
3122
|
createdStreams.push(forkPath);
|
|
3090
3123
|
}
|
|
3124
|
+
for (const plan of entityPlans) {
|
|
3125
|
+
const manifests = snapshot.manifestsByEntity.get(plan.source.url) ?? new Map();
|
|
3126
|
+
for (const manifest of manifests.values()) {
|
|
3127
|
+
if (manifest.kind !== `attachment` || typeof manifest.streamPath !== `string` || typeof manifest.id !== `string`) continue;
|
|
3128
|
+
const forkPath = getEntityAttachmentStreamPath(plan.fork.url, manifest.id);
|
|
3129
|
+
await this.streamClient.fork(forkPath, manifest.streamPath);
|
|
3130
|
+
createdStreams.push(forkPath);
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3091
3133
|
for (const plan of entityPlans) {
|
|
3092
3134
|
const reconciliation = this.buildForkReconciliation(plan, snapshot, entityUrlMap, sharedStateIdMap, stringMap);
|
|
3093
3135
|
activeManifestsByEntity.set(plan.fork.url, reconciliation.manifests);
|
|
@@ -3497,6 +3539,16 @@ var EntityManager = class {
|
|
|
3497
3539
|
changed: true
|
|
3498
3540
|
};
|
|
3499
3541
|
}
|
|
3542
|
+
if (next.kind === `attachment` && typeof next.streamPath === `string` && typeof next.id === `string`) for (const [sourceUrl, forkUrl] of entityUrlMap) {
|
|
3543
|
+
const prefix = `${sourceUrl}/attachments/`;
|
|
3544
|
+
if (!next.streamPath.startsWith(prefix)) continue;
|
|
3545
|
+
next.streamPath = getEntityAttachmentStreamPath(forkUrl, next.id);
|
|
3546
|
+
return {
|
|
3547
|
+
key,
|
|
3548
|
+
value: next,
|
|
3549
|
+
changed: true
|
|
3550
|
+
};
|
|
3551
|
+
}
|
|
3500
3552
|
if (next.kind === `schedule` && next.scheduleType === `future_send`) {
|
|
3501
3553
|
let changed = false;
|
|
3502
3554
|
if (typeof next.targetUrl === `string`) {
|
|
@@ -3694,6 +3746,93 @@ var EntityManager = class {
|
|
|
3694
3746
|
const envelope = entityStateSchema.inbox.delete({ key });
|
|
3695
3747
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
3696
3748
|
}
|
|
3749
|
+
isAttachmentStreamPath(path$1) {
|
|
3750
|
+
return /^\/[^/]+\/[^/]+\/attachments\/[^/]+$/.test(path$1);
|
|
3751
|
+
}
|
|
3752
|
+
async createAttachment(entityUrl, req) {
|
|
3753
|
+
const entity = await this.registry.getEntity(entityUrl);
|
|
3754
|
+
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3755
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3756
|
+
if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
|
|
3757
|
+
const id = req.id ?? randomUUID();
|
|
3758
|
+
validateAttachmentId(id);
|
|
3759
|
+
validateAttachmentSubject(req.subject);
|
|
3760
|
+
const limit = maxAttachmentBytes();
|
|
3761
|
+
if (req.bytes.length > limit) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment exceeds maximum size of ${limit} bytes`, 413);
|
|
3762
|
+
const mimeType = req.mimeType.trim() || `application/octet-stream`;
|
|
3763
|
+
const streamPath = getEntityAttachmentStreamPath(entityUrl, id);
|
|
3764
|
+
const manifestKey = manifestAttachmentKey(id);
|
|
3765
|
+
const txid = randomUUID();
|
|
3766
|
+
const now = new Date().toISOString();
|
|
3767
|
+
const sha256 = createHash(`sha256`).update(req.bytes).digest(`hex`);
|
|
3768
|
+
const attachment = {
|
|
3769
|
+
key: manifestKey,
|
|
3770
|
+
kind: `attachment`,
|
|
3771
|
+
id,
|
|
3772
|
+
streamPath,
|
|
3773
|
+
status: `complete`,
|
|
3774
|
+
subject: req.subject,
|
|
3775
|
+
role: req.role ?? `input`,
|
|
3776
|
+
mimeType,
|
|
3777
|
+
...req.filename ? { filename: req.filename } : {},
|
|
3778
|
+
byteLength: req.bytes.length,
|
|
3779
|
+
sha256,
|
|
3780
|
+
createdAt: now,
|
|
3781
|
+
...req.createdBy ? { createdBy: req.createdBy } : {},
|
|
3782
|
+
...req.meta ? { meta: req.meta } : {}
|
|
3783
|
+
};
|
|
3784
|
+
let streamCreated = false;
|
|
3785
|
+
try {
|
|
3786
|
+
await this.streamClient.create(streamPath, {
|
|
3787
|
+
contentType: mimeType,
|
|
3788
|
+
body: req.bytes,
|
|
3789
|
+
closed: true
|
|
3790
|
+
});
|
|
3791
|
+
streamCreated = true;
|
|
3792
|
+
await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, attachment, { txid });
|
|
3793
|
+
} catch (error) {
|
|
3794
|
+
if (streamCreated) await this.streamClient.delete(streamPath).catch(() => void 0);
|
|
3795
|
+
if (!streamCreated && isStreamCreateConflict(error)) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment already exists at id "${id}"`, 409);
|
|
3796
|
+
throw error;
|
|
3797
|
+
}
|
|
3798
|
+
return {
|
|
3799
|
+
txid,
|
|
3800
|
+
attachment
|
|
3801
|
+
};
|
|
3802
|
+
}
|
|
3803
|
+
async getAttachment(entityUrl, id) {
|
|
3804
|
+
validateAttachmentId(id);
|
|
3805
|
+
const entity = await this.registry.getEntity(entityUrl);
|
|
3806
|
+
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3807
|
+
const events = await this.streamClient.readJson(entity.streams.main);
|
|
3808
|
+
const manifest = this.reduceStateRows(events, `manifest`).get(manifestAttachmentKey(id));
|
|
3809
|
+
if (!manifest || manifest.kind !== `attachment`) return null;
|
|
3810
|
+
return manifest;
|
|
3811
|
+
}
|
|
3812
|
+
async readAttachment(entityUrl, id) {
|
|
3813
|
+
const attachment = await this.getAttachment(entityUrl, id);
|
|
3814
|
+
if (!attachment) throw new ElectricAgentsError(ErrCodeNotFound, `Attachment not found`, 404);
|
|
3815
|
+
if (attachment.status !== `complete`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Attachment is not complete`, 409);
|
|
3816
|
+
assertCanonicalAttachmentStreamPath(entityUrl, attachment);
|
|
3817
|
+
const result = await this.streamClient.read(attachment.streamPath);
|
|
3818
|
+
return {
|
|
3819
|
+
attachment,
|
|
3820
|
+
bytes: concatByteMessages(result.messages)
|
|
3821
|
+
};
|
|
3822
|
+
}
|
|
3823
|
+
async deleteAttachment(entityUrl, id) {
|
|
3824
|
+
const entity = await this.registry.getEntity(entityUrl);
|
|
3825
|
+
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3826
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3827
|
+
if (this.isForkWorkLockedEntity(entityUrl)) this.assertEntityNotForkWorkLocked(entityUrl);
|
|
3828
|
+
const attachment = await this.getAttachment(entityUrl, id);
|
|
3829
|
+
if (!attachment) throw new ElectricAgentsError(ErrCodeNotFound, `Attachment not found`, 404);
|
|
3830
|
+
assertCanonicalAttachmentStreamPath(entityUrl, attachment);
|
|
3831
|
+
const txid = randomUUID();
|
|
3832
|
+
await this.writeManifestEntry(entityUrl, manifestAttachmentKey(id), `delete`, void 0, { txid });
|
|
3833
|
+
await this.streamClient.delete(attachment.streamPath).catch(() => void 0);
|
|
3834
|
+
return { txid };
|
|
3835
|
+
}
|
|
3697
3836
|
async setTag(entityUrl, key, req, token) {
|
|
3698
3837
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3699
3838
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
@@ -4579,6 +4718,13 @@ const eventSourceSubscriptionBodySchema = Type.Object({
|
|
|
4579
4718
|
lifetime: Type.Optional(subscriptionLifetimeSchema),
|
|
4580
4719
|
reason: Type.Optional(Type.String())
|
|
4581
4720
|
});
|
|
4721
|
+
const attachmentSubjectTypes = new Set([
|
|
4722
|
+
`inbox`,
|
|
4723
|
+
`run`,
|
|
4724
|
+
`text`,
|
|
4725
|
+
`tool_call`,
|
|
4726
|
+
`context`
|
|
4727
|
+
]);
|
|
4582
4728
|
const entitiesRouter = Router({ base: `/_electric/entities` });
|
|
4583
4729
|
entitiesRouter.get(`/`, listEntities);
|
|
4584
4730
|
entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spawnBodySchema), spawnEntity);
|
|
@@ -4587,6 +4733,9 @@ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
|
|
|
4587
4733
|
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
|
|
4588
4734
|
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
|
|
4589
4735
|
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
|
|
4736
|
+
entitiesRouter.post(`/:type/:instanceId/attachments`, withExistingEntity, createAttachment);
|
|
4737
|
+
entitiesRouter.get(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, readAttachment);
|
|
4738
|
+
entitiesRouter.delete(`/:type/:instanceId/attachments/:attachmentId`, withExistingEntity, deleteAttachment);
|
|
4590
4739
|
entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
|
|
4591
4740
|
entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
|
|
4592
4741
|
entitiesRouter.post(`/:type/:instanceId/fork`, withExistingEntity, withSchema(forkBodySchema), forkEntity);
|
|
@@ -4613,6 +4762,82 @@ function requireExistingEntityRoute(request) {
|
|
|
4613
4762
|
if (!request.entityRoute) throw new Error(`existing entity middleware did not run`);
|
|
4614
4763
|
return request.entityRoute;
|
|
4615
4764
|
}
|
|
4765
|
+
function invalidAttachmentRequest(message) {
|
|
4766
|
+
throw new ElectricAgentsError(ErrCodeInvalidRequest, message, 400);
|
|
4767
|
+
}
|
|
4768
|
+
function formString(form, key) {
|
|
4769
|
+
const value = form.get(key);
|
|
4770
|
+
if (typeof value !== `string`) return void 0;
|
|
4771
|
+
const trimmed = value.trim();
|
|
4772
|
+
return trimmed || void 0;
|
|
4773
|
+
}
|
|
4774
|
+
function parseJsonFormField(form, key) {
|
|
4775
|
+
const raw = formString(form, key);
|
|
4776
|
+
if (!raw) return void 0;
|
|
4777
|
+
try {
|
|
4778
|
+
return JSON.parse(raw);
|
|
4779
|
+
} catch {
|
|
4780
|
+
invalidAttachmentRequest(`Invalid JSON field: ${key}`);
|
|
4781
|
+
}
|
|
4782
|
+
}
|
|
4783
|
+
function parseAttachmentSubject(form) {
|
|
4784
|
+
const explicit = parseJsonFormField(form, `subject`);
|
|
4785
|
+
if (explicit !== void 0) {
|
|
4786
|
+
if (!explicit || typeof explicit !== `object` || Array.isArray(explicit)) invalidAttachmentRequest(`attachment subject must be an object`);
|
|
4787
|
+
const subject = explicit;
|
|
4788
|
+
const type$1 = subject.type;
|
|
4789
|
+
const key$1 = subject.key;
|
|
4790
|
+
if (typeof type$1 !== `string` || typeof key$1 !== `string`) invalidAttachmentRequest(`attachment subject requires type and key`);
|
|
4791
|
+
if (!attachmentSubjectTypes.has(type$1)) invalidAttachmentRequest(`invalid attachment subject type`);
|
|
4792
|
+
return {
|
|
4793
|
+
type: type$1,
|
|
4794
|
+
key: key$1
|
|
4795
|
+
};
|
|
4796
|
+
}
|
|
4797
|
+
const type = formString(form, `subjectType`);
|
|
4798
|
+
const key = formString(form, `subjectKey`);
|
|
4799
|
+
if (!type || !key) invalidAttachmentRequest(`attachment subject is required`);
|
|
4800
|
+
if (!attachmentSubjectTypes.has(type)) invalidAttachmentRequest(`invalid attachment subject type`);
|
|
4801
|
+
return {
|
|
4802
|
+
type,
|
|
4803
|
+
key
|
|
4804
|
+
};
|
|
4805
|
+
}
|
|
4806
|
+
function getUploadedFormFile(value) {
|
|
4807
|
+
if (value !== null && typeof value === `object` && `arrayBuffer` in value && typeof value.arrayBuffer === `function`) return value;
|
|
4808
|
+
return null;
|
|
4809
|
+
}
|
|
4810
|
+
async function parseAttachmentForm(request) {
|
|
4811
|
+
const contentType = request.headers.get(`content-type`)?.toLowerCase() ?? ``;
|
|
4812
|
+
if (!contentType.includes(`multipart/form-data`)) invalidAttachmentRequest(`Attachment uploads must use multipart/form-data`);
|
|
4813
|
+
let form;
|
|
4814
|
+
try {
|
|
4815
|
+
form = await request.formData();
|
|
4816
|
+
} catch {
|
|
4817
|
+
invalidAttachmentRequest(`Invalid multipart form data`);
|
|
4818
|
+
}
|
|
4819
|
+
const file = getUploadedFormFile(form.get(`file`));
|
|
4820
|
+
if (!file) invalidAttachmentRequest(`Missing file field`);
|
|
4821
|
+
const role = formString(form, `role`);
|
|
4822
|
+
if (role !== void 0 && role !== `input` && role !== `output`) invalidAttachmentRequest(`invalid attachment role`);
|
|
4823
|
+
const fileName = formString(form, `filename`) ?? (typeof file.name === `string` ? file.name : void 0);
|
|
4824
|
+
const mimeType = formString(form, `mimeType`) || (typeof file.type === `string` ? file.type : void 0) || `application/octet-stream`;
|
|
4825
|
+
const meta = parseJsonFormField(form, `meta`);
|
|
4826
|
+
if (meta !== void 0 && (typeof meta !== `object` || Array.isArray(meta))) invalidAttachmentRequest(`attachment meta must be an object`);
|
|
4827
|
+
return {
|
|
4828
|
+
id: formString(form, `id`),
|
|
4829
|
+
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
4830
|
+
mimeType,
|
|
4831
|
+
filename: fileName,
|
|
4832
|
+
subject: parseAttachmentSubject(form),
|
|
4833
|
+
role,
|
|
4834
|
+
meta
|
|
4835
|
+
};
|
|
4836
|
+
}
|
|
4837
|
+
function contentDisposition(filename) {
|
|
4838
|
+
const fallback = filename.replace(/["\\\r\n]/g, `_`);
|
|
4839
|
+
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
|
|
4840
|
+
}
|
|
4616
4841
|
function rejectPrincipalEntityMutation(request, action) {
|
|
4617
4842
|
const { entity } = requireExistingEntityRoute(request);
|
|
4618
4843
|
if (entity.type !== `principal`) return void 0;
|
|
@@ -4797,6 +5022,44 @@ async function sendEntity(request, ctx) {
|
|
|
4797
5022
|
});
|
|
4798
5023
|
return status(204);
|
|
4799
5024
|
}
|
|
5025
|
+
async function createAttachment(request, ctx) {
|
|
5026
|
+
const principalMutationError = rejectPrincipalEntityMutation(request, `given attachments`);
|
|
5027
|
+
if (principalMutationError) return principalMutationError;
|
|
5028
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
5029
|
+
const form = await parseAttachmentForm(request);
|
|
5030
|
+
const result = await ctx.entityManager.createAttachment(entityUrl, {
|
|
5031
|
+
id: form.id,
|
|
5032
|
+
bytes: form.bytes,
|
|
5033
|
+
mimeType: form.mimeType,
|
|
5034
|
+
filename: form.filename,
|
|
5035
|
+
subject: form.subject,
|
|
5036
|
+
role: form.role,
|
|
5037
|
+
createdBy: ctx.principal.url,
|
|
5038
|
+
meta: form.meta
|
|
5039
|
+
});
|
|
5040
|
+
return json(result, { status: 201 });
|
|
5041
|
+
}
|
|
5042
|
+
async function readAttachment(request, ctx) {
|
|
5043
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
5044
|
+
const result = await ctx.entityManager.readAttachment(entityUrl, decodeURIComponent(request.params.attachmentId));
|
|
5045
|
+
const headers = new Headers({
|
|
5046
|
+
"content-type": result.attachment.mimeType,
|
|
5047
|
+
"content-length": String(result.bytes.length),
|
|
5048
|
+
"cache-control": `private, max-age=31536000, immutable`
|
|
5049
|
+
});
|
|
5050
|
+
if (result.attachment.filename) headers.set(`content-disposition`, contentDisposition(result.attachment.filename));
|
|
5051
|
+
return new Response(result.bytes, {
|
|
5052
|
+
status: 200,
|
|
5053
|
+
headers
|
|
5054
|
+
});
|
|
5055
|
+
}
|
|
5056
|
+
async function deleteAttachment(request, ctx) {
|
|
5057
|
+
const principalMutationError = rejectPrincipalEntityMutation(request, `stripped of attachments`);
|
|
5058
|
+
if (principalMutationError) return principalMutationError;
|
|
5059
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
5060
|
+
const result = await ctx.entityManager.deleteAttachment(entityUrl, decodeURIComponent(request.params.attachmentId));
|
|
5061
|
+
return json(result);
|
|
5062
|
+
}
|
|
4800
5063
|
async function updateInboxMessage(request, ctx) {
|
|
4801
5064
|
const parsed = routeBody(request);
|
|
4802
5065
|
const { entityUrl } = requireExistingEntityRoute(request);
|
|
@@ -5397,7 +5660,7 @@ async function notificationFromClaim(ctx, input) {
|
|
|
5397
5660
|
leaseExpiresAt: input.claim.lease_ttl_ms ? new Date(Date.now() + input.claim.lease_ttl_ms) : void 0
|
|
5398
5661
|
});
|
|
5399
5662
|
await ctx.entityManager.registry.updateStatus(entity.url, `running`);
|
|
5400
|
-
const streams
|
|
5663
|
+
const streams = input.claim.streams.map((stream) => ({
|
|
5401
5664
|
path: withLeadingSlash(stream.path),
|
|
5402
5665
|
offset: stream.tail_offset ?? ``
|
|
5403
5666
|
}));
|
|
@@ -5406,7 +5669,7 @@ async function notificationFromClaim(ctx, input) {
|
|
|
5406
5669
|
epoch: input.claim.generation,
|
|
5407
5670
|
wakeId: input.claim.wake_id,
|
|
5408
5671
|
streamPath: primaryStream,
|
|
5409
|
-
streams
|
|
5672
|
+
streams,
|
|
5410
5673
|
callback: appendPathToUrl(ctx.publicUrl, `/_electric/wake-callbacks/${encodeURIComponent(input.claim.wake_id)}`),
|
|
5411
5674
|
claimToken: input.claim.token,
|
|
5412
5675
|
triggerEvent: `message_received`,
|