@electric-ax/agents-server 0.4.5 → 0.4.7
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 +404 -68
- package/dist/index.cjs +421 -67
- package/dist/index.d.cts +97 -11
- package/dist/index.d.ts +97 -11
- package/dist/index.js +414 -69
- package/drizzle/0009_entity_signal_statuses.sql +3 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +6 -6
- package/src/db/schema.ts +1 -1
- package/src/electric-agents-types.ts +76 -1
- package/src/entity-manager.ts +256 -33
- package/src/entity-registry.ts +57 -20
- package/src/entrypoint-lib.ts +5 -0
- package/src/index.ts +33 -0
- package/src/routing/context.ts +6 -0
- package/src/routing/dispatch-policy.ts +6 -0
- package/src/routing/durable-streams-router.ts +62 -13
- package/src/routing/entities-router.ts +57 -1
- package/src/routing/internal-router.ts +147 -23
- package/src/routing/runners-router.ts +1 -1
- package/src/runtime.ts +5 -1
- package/src/server.ts +18 -0
- package/src/stream-client.ts +10 -4
- package/src/webhook-signing.ts +173 -0
package/dist/index.js
CHANGED
|
@@ -7,8 +7,8 @@ import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
|
7
7
|
import postgres from "postgres";
|
|
8
8
|
import { and, desc, eq, lt, ne, sql } from "drizzle-orm";
|
|
9
9
|
import { bigint, bigserial, boolean, check, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique } from "drizzle-orm/pg-core";
|
|
10
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
-
import { appendPathToUrl, assertTags, buildTagsIndex, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, sourceRefForTags } from "@electric-ax/agents-runtime";
|
|
10
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from "node:crypto";
|
|
11
|
+
import { appendPathToUrl, assertTags, buildTagsIndex, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
|
|
12
12
|
import { DurableStream, DurableStreamError, FetchError, IdempotentProducer } from "@durable-streams/client";
|
|
13
13
|
import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
|
|
14
14
|
import pino from "pino";
|
|
@@ -75,7 +75,7 @@ const entities = pgTable(`entities`, {
|
|
|
75
75
|
index(`idx_entities_parent`).on(table.tenantId, table.parent),
|
|
76
76
|
index(`idx_entities_created_by`).on(table.tenantId, table.createdBy),
|
|
77
77
|
index(`entities_tags_index_gin`).using(`gin`, table.tagsIndex),
|
|
78
|
-
check(`chk_entities_status`, sql`${table.status} IN ('spawning', 'running', 'idle', 'stopped')`)
|
|
78
|
+
check(`chk_entities_status`, sql`${table.status} IN ('spawning', 'running', 'idle', 'paused', 'stopping', 'stopped', 'killed')`)
|
|
79
79
|
]);
|
|
80
80
|
const users = pgTable(`users`, {
|
|
81
81
|
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
@@ -329,12 +329,25 @@ async function runMigrations(postgresUrl) {
|
|
|
329
329
|
|
|
330
330
|
//#endregion
|
|
331
331
|
//#region src/electric-agents-types.ts
|
|
332
|
+
const ENTITY_SIGNALS = [
|
|
333
|
+
`SIGINT`,
|
|
334
|
+
`SIGHUP`,
|
|
335
|
+
`SIGTERM`,
|
|
336
|
+
`SIGKILL`,
|
|
337
|
+
`SIGSTOP`,
|
|
338
|
+
`SIGCONT`,
|
|
339
|
+
`SIGUSR`
|
|
340
|
+
];
|
|
332
341
|
const VALID_ENTITY_STATUSES = new Set([
|
|
333
342
|
`spawning`,
|
|
334
343
|
`running`,
|
|
335
344
|
`idle`,
|
|
336
|
-
`
|
|
345
|
+
`paused`,
|
|
346
|
+
`stopping`,
|
|
347
|
+
`stopped`,
|
|
348
|
+
`killed`
|
|
337
349
|
]);
|
|
350
|
+
const VALID_ENTITY_SIGNALS = new Set(ENTITY_SIGNALS);
|
|
338
351
|
function assertEntityStatus(s) {
|
|
339
352
|
if (!VALID_ENTITY_STATUSES.has(s)) throw new Error(`Invalid entity status: "${s}"`);
|
|
340
353
|
return s;
|
|
@@ -355,6 +368,27 @@ function assertRunnerAdminStatus(s) {
|
|
|
355
368
|
if (!VALID_RUNNER_ADMIN_STATUSES.has(s)) throw new Error(`Invalid runner admin status: "${s}"`);
|
|
356
369
|
return s;
|
|
357
370
|
}
|
|
371
|
+
function assertEntitySignal(s) {
|
|
372
|
+
if (!VALID_ENTITY_SIGNALS.has(s)) throw new Error(`Invalid entity signal: "${s}"`);
|
|
373
|
+
return s;
|
|
374
|
+
}
|
|
375
|
+
function isTerminalEntityStatus(status$1) {
|
|
376
|
+
return status$1 === `stopped` || status$1 === `killed`;
|
|
377
|
+
}
|
|
378
|
+
function rejectsNormalWrites(status$1) {
|
|
379
|
+
return status$1 === `stopping` || isTerminalEntityStatus(status$1);
|
|
380
|
+
}
|
|
381
|
+
function expectedSignalStatus(status$1, signal) {
|
|
382
|
+
switch (signal) {
|
|
383
|
+
case `SIGKILL`: return `killed`;
|
|
384
|
+
case `SIGTERM`: return status$1 === `idle` ? `stopped` : `stopping`;
|
|
385
|
+
case `SIGSTOP`: return status$1 === `idle` ? `paused` : status$1;
|
|
386
|
+
case `SIGCONT`: return status$1 === `paused` ? `idle` : status$1;
|
|
387
|
+
case `SIGINT`:
|
|
388
|
+
case `SIGHUP`:
|
|
389
|
+
case `SIGUSR`: return status$1;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
358
392
|
/** Strip internal fields (write_token, subscription_id) from an entity. */
|
|
359
393
|
function toPublicEntity(entity) {
|
|
360
394
|
return {
|
|
@@ -376,6 +410,7 @@ const ErrCodeUnauthorized = `UNAUTHORIZED`;
|
|
|
376
410
|
const ErrCodeNotFound = `NOT_FOUND`;
|
|
377
411
|
const ErrCodeNotRunning = `NOT_RUNNING`;
|
|
378
412
|
const ErrCodeInvalidRequest = `INVALID_REQUEST`;
|
|
413
|
+
const ErrCodeInvalidSignal = `INVALID_SIGNAL`;
|
|
379
414
|
const ErrCodeUnknownEntityType = `UNKNOWN_ENTITY_TYPE`;
|
|
380
415
|
const ErrCodeSchemaValidationFailed = `SCHEMA_VALIDATION_FAILED`;
|
|
381
416
|
const ErrCodeUnknownMessageType = `UNKNOWN_MESSAGE_TYPE`;
|
|
@@ -571,7 +606,7 @@ var PostgresRegistry = class {
|
|
|
571
606
|
const heartbeatAt = input.heartbeatAt ?? new Date();
|
|
572
607
|
await this.db.update(consumerClaims).set({
|
|
573
608
|
lastHeartbeatAt: heartbeatAt,
|
|
574
|
-
leaseExpiresAt: input.leaseExpiresAt
|
|
609
|
+
...input.leaseExpiresAt !== void 0 ? { leaseExpiresAt: input.leaseExpiresAt } : {},
|
|
575
610
|
updatedAt: heartbeatAt
|
|
576
611
|
}).where(and(eq(consumerClaims.tenantId, this.tenantId), eq(consumerClaims.consumerId, input.consumerId), eq(consumerClaims.epoch, input.epoch)));
|
|
577
612
|
}
|
|
@@ -584,17 +619,24 @@ var PostgresRegistry = class {
|
|
|
584
619
|
updatedAt: releasedAt
|
|
585
620
|
}).where(and(eq(consumerClaims.tenantId, this.tenantId), eq(consumerClaims.consumerId, input.consumerId), eq(consumerClaims.epoch, input.epoch))).returning();
|
|
586
621
|
const claim = rows[0] ? this.rowToConsumerClaim(rows[0]) : null;
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
622
|
+
let entityCleared = false;
|
|
623
|
+
if (claim) {
|
|
624
|
+
const cleared = await this.db.update(entityDispatchState).set({
|
|
625
|
+
activeConsumerId: null,
|
|
626
|
+
activeRunnerId: null,
|
|
627
|
+
activeEpoch: null,
|
|
628
|
+
activeClaimedAt: null,
|
|
629
|
+
activeLeaseExpiresAt: null,
|
|
630
|
+
lastReleasedAt: releasedAt,
|
|
631
|
+
lastCompletedAt: releasedAt,
|
|
632
|
+
updatedAt: releasedAt
|
|
633
|
+
}).where(and(eq(entityDispatchState.tenantId, this.tenantId), eq(entityDispatchState.entityUrl, claim.entity_url), eq(entityDispatchState.activeConsumerId, input.consumerId), eq(entityDispatchState.activeEpoch, input.epoch))).returning({ entityUrl: entityDispatchState.entityUrl });
|
|
634
|
+
entityCleared = cleared.length > 0;
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
claim,
|
|
638
|
+
entityCleared
|
|
639
|
+
};
|
|
598
640
|
}
|
|
599
641
|
async getActiveClaimsForRunner(runnerId) {
|
|
600
642
|
const rows = await this.db.select().from(consumerClaims).where(and(eq(consumerClaims.tenantId, this.tenantId), eq(consumerClaims.runnerId, runnerId), eq(consumerClaims.status, `active`)));
|
|
@@ -770,7 +812,7 @@ var PostgresRegistry = class {
|
|
|
770
812
|
};
|
|
771
813
|
}
|
|
772
814
|
async updateStatus(entityUrl, status$1) {
|
|
773
|
-
const whereClause = status$1
|
|
815
|
+
const whereClause = isTerminalEntityStatus(status$1) ? this.entityWhere(entityUrl) : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`));
|
|
774
816
|
await this.db.update(entities).set({
|
|
775
817
|
status: status$1,
|
|
776
818
|
updatedAt: Date.now()
|
|
@@ -778,13 +820,17 @@ var PostgresRegistry = class {
|
|
|
778
820
|
}
|
|
779
821
|
async updateStatusWithTxid(entityUrl, status$1) {
|
|
780
822
|
return await this.db.transaction(async (tx) => {
|
|
781
|
-
const
|
|
782
|
-
await tx.update(entities).set({
|
|
823
|
+
const rows = await tx.update(entities).set({
|
|
783
824
|
status: status$1,
|
|
784
825
|
updatedAt: Date.now()
|
|
785
|
-
}).where(
|
|
786
|
-
|
|
787
|
-
|
|
826
|
+
}).where(and(this.entityWhere(entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`))).returning({ txid: sql`pg_current_xact_id()::xid::text` });
|
|
827
|
+
return rows[0] ? parseInt(rows[0].txid) : null;
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
async touchEntityWithTxid(entityUrl) {
|
|
831
|
+
return await this.db.transaction(async (tx) => {
|
|
832
|
+
const rows = await tx.update(entities).set({ updatedAt: Date.now() }).where(and(eq(entities.url, entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`))).returning({ txid: sql`pg_current_xact_id()::xid::text` });
|
|
833
|
+
return rows[0] ? parseInt(rows[0].txid) : null;
|
|
788
834
|
});
|
|
789
835
|
}
|
|
790
836
|
async setEntityTag(url, key, value) {
|
|
@@ -2411,6 +2457,9 @@ async function assertDispatchPolicyAllowed(ctx, policy) {
|
|
|
2411
2457
|
if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner "${target.runnerId}" not found`, 404);
|
|
2412
2458
|
if (runner.owner_principal !== ctx.principal.url) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner dispatch requires the authenticated owner`, 403);
|
|
2413
2459
|
}
|
|
2460
|
+
function shouldLinkDispatchBeforeInitialMessage(policy) {
|
|
2461
|
+
return policy?.targets[0] !== void 0;
|
|
2462
|
+
}
|
|
2414
2463
|
async function linkEntityDispatchSubscription(ctx, entity) {
|
|
2415
2464
|
const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(ctx, entity);
|
|
2416
2465
|
const target = dispatchPolicy?.targets[0];
|
|
@@ -2636,6 +2685,7 @@ function createInitialQueuePosition(date) {
|
|
|
2636
2685
|
}
|
|
2637
2686
|
const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
|
|
2638
2687
|
const DEFAULT_FORK_WAIT_POLL_MS = 250;
|
|
2688
|
+
const SERVER_SIGNAL_SENDER = `/_electric/server`;
|
|
2639
2689
|
function sleep(ms) {
|
|
2640
2690
|
return new Promise((resolve$1) => setTimeout(resolve$1, ms));
|
|
2641
2691
|
}
|
|
@@ -3087,16 +3137,16 @@ var EntityManager = class {
|
|
|
3087
3137
|
if (!root) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3088
3138
|
if (root.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
|
|
3089
3139
|
const subtree = await this.listEntitySubtree(root);
|
|
3090
|
-
const stopped = subtree.find((entity) => entity.status
|
|
3091
|
-
if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork
|
|
3092
|
-
let active = subtree.filter((entity) => entity.status !== `idle`);
|
|
3140
|
+
const stopped = subtree.find((entity) => isTerminalEntityStatus(entity.status));
|
|
3141
|
+
if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
|
|
3142
|
+
let active = subtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
|
|
3093
3143
|
if (active.length === 0) {
|
|
3094
3144
|
this.addForkLocks(this.forkWorkLockedEntities, subtree.map((entity) => entity.url), workLocks);
|
|
3095
3145
|
const lockedRoot = await this.registry.getEntity(rootUrl);
|
|
3096
3146
|
if (!lockedRoot) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3097
3147
|
const lockedSubtree = await this.listEntitySubtree(lockedRoot);
|
|
3098
3148
|
this.addForkLocks(this.forkWorkLockedEntities, lockedSubtree.map((entity) => entity.url), workLocks);
|
|
3099
|
-
const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle`);
|
|
3149
|
+
const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
|
|
3100
3150
|
if (lockedActive.length === 0) return lockedSubtree;
|
|
3101
3151
|
this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
|
|
3102
3152
|
active = lockedActive;
|
|
@@ -3552,6 +3602,11 @@ var EntityManager = class {
|
|
|
3552
3602
|
if (req.position) value.position = req.position;
|
|
3553
3603
|
else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
|
|
3554
3604
|
if (value.status === `processed`) value.processed_at = now;
|
|
3605
|
+
const wakePausedEntity = entity.status === `paused` && req.mode !== `paused`;
|
|
3606
|
+
if (wakePausedEntity) {
|
|
3607
|
+
await this.registry.updateStatus(entityUrl, `idle`);
|
|
3608
|
+
await this.entityBridgeManager?.onEntityChanged(entityUrl);
|
|
3609
|
+
}
|
|
3555
3610
|
const envelope = entityStateSchema.inbox.insert({
|
|
3556
3611
|
key,
|
|
3557
3612
|
value
|
|
@@ -3579,7 +3634,7 @@ var EntityManager = class {
|
|
|
3579
3634
|
async updateInboxMessage(entityUrl, key, req) {
|
|
3580
3635
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3581
3636
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3582
|
-
if (entity.status
|
|
3637
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3583
3638
|
const now = new Date().toISOString();
|
|
3584
3639
|
const value = {};
|
|
3585
3640
|
if (`payload` in req) value.payload = req.payload;
|
|
@@ -3600,7 +3655,7 @@ var EntityManager = class {
|
|
|
3600
3655
|
async deleteInboxMessage(entityUrl, key) {
|
|
3601
3656
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3602
3657
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3603
|
-
if (entity.status
|
|
3658
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3604
3659
|
const envelope = entityStateSchema.inbox.delete({ key });
|
|
3605
3660
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
3606
3661
|
}
|
|
@@ -3608,7 +3663,7 @@ var EntityManager = class {
|
|
|
3608
3663
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3609
3664
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3610
3665
|
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
3611
|
-
if (entity.status
|
|
3666
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3612
3667
|
if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
|
|
3613
3668
|
const result = await this.registry.setEntityTag(entityUrl, key, req.value);
|
|
3614
3669
|
const updated = result.entity;
|
|
@@ -3620,7 +3675,7 @@ var EntityManager = class {
|
|
|
3620
3675
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3621
3676
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3622
3677
|
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
3623
|
-
if (entity.status
|
|
3678
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3624
3679
|
const result = await this.registry.removeEntityTag(entityUrl, key);
|
|
3625
3680
|
const updated = result.entity;
|
|
3626
3681
|
if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
|
|
@@ -3873,26 +3928,131 @@ var EntityManager = class {
|
|
|
3873
3928
|
}
|
|
3874
3929
|
};
|
|
3875
3930
|
}
|
|
3876
|
-
async
|
|
3931
|
+
async signal(entityUrl, req) {
|
|
3877
3932
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3878
3933
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
const
|
|
3882
|
-
|
|
3883
|
-
const
|
|
3884
|
-
|
|
3885
|
-
|
|
3934
|
+
if (isTerminalEntityStatus(entity.status)) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal a ${entity.status} entity`, 409);
|
|
3935
|
+
const now = new Date();
|
|
3936
|
+
const previousState = entity.status;
|
|
3937
|
+
const handling = this.serverHandlingForSignal(previousState, req.signal);
|
|
3938
|
+
const txid = handling.status === previousState ? await this.registry.touchEntityWithTxid(entityUrl) : await this.registry.updateStatusWithTxid(entityUrl, handling.status);
|
|
3939
|
+
if (txid === null) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal entity because it is already terminal`, 409);
|
|
3940
|
+
const key = `sig-${now.getTime()}-${randomUUID().slice(0, 8)}`;
|
|
3941
|
+
const signalValue = {
|
|
3942
|
+
signal: req.signal,
|
|
3943
|
+
status: handling.handled ? `handled` : `unhandled`,
|
|
3944
|
+
sender: SERVER_SIGNAL_SENDER,
|
|
3945
|
+
timestamp: now.toISOString()
|
|
3946
|
+
};
|
|
3947
|
+
if (req.reason !== void 0) signalValue.reason = req.reason;
|
|
3948
|
+
if (req.payload !== void 0) signalValue.payload = req.payload;
|
|
3949
|
+
if (handling.handled) {
|
|
3950
|
+
signalValue.handled_at = now.toISOString();
|
|
3951
|
+
signalValue.handled_by = SERVER_SIGNAL_SENDER;
|
|
3952
|
+
signalValue.outcome = handling.outcome;
|
|
3953
|
+
signalValue.previous_state = previousState;
|
|
3954
|
+
signalValue.new_state = handling.status;
|
|
3955
|
+
}
|
|
3956
|
+
const signalEvent = {
|
|
3957
|
+
type: `signal`,
|
|
3958
|
+
key,
|
|
3959
|
+
value: signalValue,
|
|
3960
|
+
headers: {
|
|
3961
|
+
operation: `insert`,
|
|
3962
|
+
timestamp: now.toISOString(),
|
|
3963
|
+
txid: String(txid)
|
|
3964
|
+
}
|
|
3965
|
+
};
|
|
3966
|
+
const shouldCloseStreams = isTerminalEntityStatus(handling.status);
|
|
3967
|
+
await this.appendSignalEvent(entity, signalEvent, shouldCloseStreams);
|
|
3968
|
+
if (!shouldCloseStreams) await this.evaluateWakes(entityUrl, signalEvent);
|
|
3969
|
+
if (handling.unregisterWakes) {
|
|
3970
|
+
await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
|
|
3971
|
+
await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
|
|
3972
|
+
}
|
|
3973
|
+
if (handling.status !== previousState && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
3974
|
+
return {
|
|
3975
|
+
url: entityUrl,
|
|
3976
|
+
signal: req.signal,
|
|
3977
|
+
previous_state: previousState,
|
|
3978
|
+
new_state: handling.status,
|
|
3979
|
+
created_at: now.getTime(),
|
|
3980
|
+
txid
|
|
3981
|
+
};
|
|
3982
|
+
}
|
|
3983
|
+
async kill(entityUrl) {
|
|
3984
|
+
const response = await this.signal(entityUrl, {
|
|
3985
|
+
signal: `SIGKILL`,
|
|
3986
|
+
reason: `Legacy kill command`
|
|
3886
3987
|
});
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3988
|
+
return { txid: response.txid };
|
|
3989
|
+
}
|
|
3990
|
+
serverHandlingForSignal(status$1, signal) {
|
|
3991
|
+
if (signal === `SIGKILL`) return {
|
|
3992
|
+
status: `killed`,
|
|
3993
|
+
handled: true,
|
|
3994
|
+
outcome: `transitioned`,
|
|
3995
|
+
unregisterWakes: true
|
|
3996
|
+
};
|
|
3997
|
+
if (signal === `SIGTERM`) {
|
|
3998
|
+
if (status$1 === `idle` || status$1 === `paused`) return {
|
|
3999
|
+
status: `stopped`,
|
|
4000
|
+
handled: true,
|
|
4001
|
+
outcome: `transitioned`,
|
|
4002
|
+
unregisterWakes: true
|
|
4003
|
+
};
|
|
4004
|
+
if (status$1 === `running`) return {
|
|
4005
|
+
status: `stopping`,
|
|
4006
|
+
handled: false,
|
|
4007
|
+
outcome: `transitioned`,
|
|
4008
|
+
unregisterWakes: false
|
|
4009
|
+
};
|
|
4010
|
+
}
|
|
4011
|
+
if (status$1 === `paused` && signal !== `SIGCONT`) return {
|
|
4012
|
+
status: status$1,
|
|
4013
|
+
handled: true,
|
|
4014
|
+
outcome: `ignored`,
|
|
4015
|
+
unregisterWakes: false
|
|
4016
|
+
};
|
|
4017
|
+
if (signal === `SIGSTOP` && (status$1 === `idle` || status$1 === `running`)) return {
|
|
4018
|
+
status: `paused`,
|
|
4019
|
+
handled: status$1 === `idle`,
|
|
4020
|
+
outcome: `transitioned`,
|
|
4021
|
+
unregisterWakes: false
|
|
4022
|
+
};
|
|
4023
|
+
if (signal === `SIGCONT` && status$1 === `paused`) return {
|
|
4024
|
+
status: `idle`,
|
|
4025
|
+
handled: false,
|
|
4026
|
+
outcome: `transitioned`,
|
|
4027
|
+
unregisterWakes: false
|
|
4028
|
+
};
|
|
4029
|
+
return {
|
|
4030
|
+
status: status$1,
|
|
4031
|
+
handled: false,
|
|
4032
|
+
outcome: `ignored`,
|
|
4033
|
+
unregisterWakes: false
|
|
4034
|
+
};
|
|
4035
|
+
}
|
|
4036
|
+
async appendSignalEvent(entity, signalEvent, closeStreams) {
|
|
4037
|
+
const signalData = this.encodeChangeEvent(signalEvent);
|
|
4038
|
+
if (!closeStreams) {
|
|
4039
|
+
await this.streamClient.append(entity.streams.main, signalData);
|
|
4040
|
+
return;
|
|
4041
|
+
}
|
|
4042
|
+
const errorCloseEvent = {
|
|
4043
|
+
type: `signal`,
|
|
4044
|
+
key: signalEvent.key,
|
|
4045
|
+
value: signalEvent.value,
|
|
4046
|
+
headers: signalEvent.headers
|
|
4047
|
+
};
|
|
4048
|
+
const errorSignalData = this.encodeChangeEvent(errorCloseEvent);
|
|
4049
|
+
for (const [streamPath, data] of [[entity.streams.main, signalData], [entity.streams.error, errorSignalData]]) try {
|
|
4050
|
+
await this.streamClient.append(streamPath, data, { close: true });
|
|
3890
4051
|
} catch (err) {
|
|
3891
4052
|
const message = err instanceof Error ? err.message : String(err);
|
|
3892
4053
|
if (/closed/i.test(message) || /not found/i.test(message) || /404/.test(message) || /409/.test(message)) continue;
|
|
3893
4054
|
throw err;
|
|
3894
4055
|
}
|
|
3895
|
-
return { txid };
|
|
3896
4056
|
}
|
|
3897
4057
|
async validateWriteEvent(entity, event) {
|
|
3898
4058
|
if (!entity.type) return null;
|
|
@@ -4008,7 +4168,7 @@ var EntityManager = class {
|
|
|
4008
4168
|
async validateSendRequest(entityUrl, req) {
|
|
4009
4169
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4010
4170
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4011
|
-
if (entity.status
|
|
4171
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4012
4172
|
if (req.type && entity.type) {
|
|
4013
4173
|
const { inboxSchemas } = await this.getEffectiveSchemas(entity);
|
|
4014
4174
|
if (inboxSchemas) {
|
|
@@ -4973,7 +5133,8 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
4973
5133
|
const primaryStream = `${entityUrl}/main`;
|
|
4974
5134
|
const callbacks = await this.db.select().from(consumerCallbacks).where(and(eq(consumerCallbacks.tenantId, this.serviceId), eq(consumerCallbacks.primaryStream, primaryStream))).limit(1);
|
|
4975
5135
|
if (callbacks.length > 0) return;
|
|
4976
|
-
await this.manager.registry.
|
|
5136
|
+
const entity = await this.manager.registry.getEntity(entityUrl);
|
|
5137
|
+
await this.manager.registry.updateStatus(entityUrl, entity?.status === `stopping` ? `stopped` : `idle`);
|
|
4977
5138
|
await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
4978
5139
|
}
|
|
4979
5140
|
};
|
|
@@ -6115,6 +6276,87 @@ function sqlStringLiteral(value) {
|
|
|
6115
6276
|
return `'${value.replace(/'/g, `''`)}'`;
|
|
6116
6277
|
}
|
|
6117
6278
|
|
|
6279
|
+
//#endregion
|
|
6280
|
+
//#region src/webhook-signing.ts
|
|
6281
|
+
const encoder = new TextEncoder();
|
|
6282
|
+
const defaultWebhookSigner = createEd25519WebhookSigner();
|
|
6283
|
+
function createEd25519WebhookSigner(options = {}) {
|
|
6284
|
+
const privateKey = options.privateKey ? importPrivateKey(options.privateKey) : generateKeyPairSync(`ed25519`).privateKey;
|
|
6285
|
+
if (privateKey.asymmetricKeyType !== `ed25519`) throw new Error(`Webhook signing key must be an Ed25519 private key`);
|
|
6286
|
+
const publicJwk = buildPublicJwk(privateKey, options.kid);
|
|
6287
|
+
return {
|
|
6288
|
+
sign: (body) => signWebhookBody(privateKey, publicJwk.kid, body),
|
|
6289
|
+
jwks: () => ({ keys: [{ ...publicJwk }] })
|
|
6290
|
+
};
|
|
6291
|
+
}
|
|
6292
|
+
function getDefaultWebhookSigner() {
|
|
6293
|
+
return defaultWebhookSigner;
|
|
6294
|
+
}
|
|
6295
|
+
async function webhookSigningMetadata(signer, streamRootUrl) {
|
|
6296
|
+
const jwks = await signer.jwks();
|
|
6297
|
+
const key = jwks.keys[0];
|
|
6298
|
+
if (!key) throw new Error(`Webhook signer did not provide any public keys`);
|
|
6299
|
+
return {
|
|
6300
|
+
alg: `ed25519`,
|
|
6301
|
+
kid: key.kid,
|
|
6302
|
+
jwks_url: appendPathToUrl(streamRootUrl, `/__ds/jwks.json`)
|
|
6303
|
+
};
|
|
6304
|
+
}
|
|
6305
|
+
function signWebhookBody(privateKey, kid, body) {
|
|
6306
|
+
const timestamp$1 = Math.floor(Date.now() / 1e3);
|
|
6307
|
+
const payload = bytesWithTimestamp(timestamp$1, body);
|
|
6308
|
+
const signature = sign(null, payload, privateKey).toString(`base64url`);
|
|
6309
|
+
return `t=${timestamp$1},kid=${kid},ed25519=${signature}`;
|
|
6310
|
+
}
|
|
6311
|
+
function bytesWithTimestamp(timestamp$1, body) {
|
|
6312
|
+
const prefix = encoder.encode(`${timestamp$1}.`);
|
|
6313
|
+
const bodyBytes = typeof body === `string` ? encoder.encode(body) : body;
|
|
6314
|
+
return Buffer.concat([Buffer.from(prefix), Buffer.from(bodyBytes)]);
|
|
6315
|
+
}
|
|
6316
|
+
function importPrivateKey(input) {
|
|
6317
|
+
if (isKeyObject(input)) return input;
|
|
6318
|
+
if (typeof input === `string`) {
|
|
6319
|
+
const trimmed = input.trim();
|
|
6320
|
+
if (trimmed.startsWith(`{`)) return createPrivateKey({
|
|
6321
|
+
key: JSON.parse(trimmed),
|
|
6322
|
+
format: `jwk`
|
|
6323
|
+
});
|
|
6324
|
+
return createPrivateKey(trimmed.replace(/\\n/g, `\n`));
|
|
6325
|
+
}
|
|
6326
|
+
if (Buffer.isBuffer(input)) return createPrivateKey(input);
|
|
6327
|
+
return createPrivateKey({
|
|
6328
|
+
key: input,
|
|
6329
|
+
format: `jwk`
|
|
6330
|
+
});
|
|
6331
|
+
}
|
|
6332
|
+
function isKeyObject(input) {
|
|
6333
|
+
return typeof input === `object` && `type` in input && input.type === `private`;
|
|
6334
|
+
}
|
|
6335
|
+
function buildPublicJwk(privateKey, kid) {
|
|
6336
|
+
const exported = createPublicKey(privateKey).export({ format: `jwk` });
|
|
6337
|
+
if (exported.kty !== `OKP` || exported.crv !== `Ed25519` || !exported.x) throw new Error(`Failed to export Ed25519 webhook signing key`);
|
|
6338
|
+
return {
|
|
6339
|
+
kty: `OKP`,
|
|
6340
|
+
crv: `Ed25519`,
|
|
6341
|
+
x: exported.x,
|
|
6342
|
+
kid: kid ?? deriveKeyId({
|
|
6343
|
+
kty: exported.kty,
|
|
6344
|
+
crv: exported.crv,
|
|
6345
|
+
x: exported.x
|
|
6346
|
+
}),
|
|
6347
|
+
use: `sig`,
|
|
6348
|
+
alg: `EdDSA`
|
|
6349
|
+
};
|
|
6350
|
+
}
|
|
6351
|
+
function deriveKeyId(jwk) {
|
|
6352
|
+
const thumbprintInput = JSON.stringify({
|
|
6353
|
+
crv: jwk.crv,
|
|
6354
|
+
kty: jwk.kty,
|
|
6355
|
+
x: jwk.x
|
|
6356
|
+
});
|
|
6357
|
+
return `ds_${createHash(`sha256`).update(thumbprintInput).digest(`base64url`)}`;
|
|
6358
|
+
}
|
|
6359
|
+
|
|
6118
6360
|
//#endregion
|
|
6119
6361
|
//#region src/routing/durable-streams-router.ts
|
|
6120
6362
|
const subscriptionProxyBodySchema = Type.Object({ webhook: Type.Optional(Type.Object({ url: Type.String() }, { additionalProperties: true })) }, { additionalProperties: true });
|
|
@@ -6131,6 +6373,7 @@ durableStreamsRouter.delete(`/__ds/subscriptions/:subscriptionId`, deleteSubscri
|
|
|
6131
6373
|
durableStreamsRouter.post(`/__ds/subscriptions/:subscriptionId/streams`, postSubscriptionStreams);
|
|
6132
6374
|
durableStreamsRouter.delete(`/__ds/subscriptions/:subscriptionId/streams/:streamPath+`, deleteSubscriptionStream);
|
|
6133
6375
|
for (const action of subscriptionControlActions) durableStreamsRouter.post(`/__ds/subscriptions/:subscriptionId/${action}`, subscriptionAction(action));
|
|
6376
|
+
durableStreamsRouter.get(`/__ds/jwks.json`, webhookJwks);
|
|
6134
6377
|
durableStreamsRouter.all(`/__ds`, controlPassThrough);
|
|
6135
6378
|
durableStreamsRouter.all(`/__ds/*`, controlPassThrough);
|
|
6136
6379
|
durableStreamsRouter.post(`*`, streamAppend);
|
|
@@ -6139,12 +6382,16 @@ function bodyFromBytes$1(body) {
|
|
|
6139
6382
|
return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
|
|
6140
6383
|
}
|
|
6141
6384
|
function responseFromUpstream$1(response, body) {
|
|
6142
|
-
|
|
6385
|
+
const responseBody = forbidsResponseBody$1(response.status) ? null : body !== void 0 ? bodyFromBytes$1(body) : response.body;
|
|
6386
|
+
return new Response(responseBody, {
|
|
6143
6387
|
status: response.status,
|
|
6144
6388
|
statusText: response.statusText,
|
|
6145
6389
|
headers: responseHeaders(response)
|
|
6146
6390
|
});
|
|
6147
6391
|
}
|
|
6392
|
+
function forbidsResponseBody$1(status$1) {
|
|
6393
|
+
return status$1 === 204 || status$1 === 205 || status$1 === 304;
|
|
6394
|
+
}
|
|
6148
6395
|
async function forwardToDurableStreams(ctx, request, body, route = `stream`, urlOverride, durableStreamsBearerMode = `overwrite`) {
|
|
6149
6396
|
const headers = new Headers(request.headers);
|
|
6150
6397
|
headers.delete(`host`);
|
|
@@ -6178,28 +6425,32 @@ function rewriteSubscriptionBodyForBackend(payload, service, routingAdapter) {
|
|
|
6178
6425
|
return next;
|
|
6179
6426
|
});
|
|
6180
6427
|
}
|
|
6181
|
-
function rewriteSubscriptionResponseForClient(bytes, response,
|
|
6428
|
+
async function rewriteSubscriptionResponseForClient(bytes, response, ctx, routingAdapter) {
|
|
6182
6429
|
if (!response.headers.get(`content-type`)?.includes(`application/json`)) return bytes;
|
|
6183
6430
|
const payload = decodeJson(bytes);
|
|
6184
6431
|
if (!payload) return bytes;
|
|
6185
|
-
if (typeof payload.pattern === `string`) payload.pattern = routingAdapter.toRuntimeStreamPath(service, payload.pattern);
|
|
6432
|
+
if (typeof payload.pattern === `string`) payload.pattern = routingAdapter.toRuntimeStreamPath(ctx.service, payload.pattern);
|
|
6186
6433
|
if (Array.isArray(payload.streams)) payload.streams = payload.streams.map((stream) => {
|
|
6187
|
-
if (typeof stream === `string`) return routingAdapter.toRuntimeStreamPath(service, stream);
|
|
6434
|
+
if (typeof stream === `string`) return routingAdapter.toRuntimeStreamPath(ctx.service, stream);
|
|
6188
6435
|
if (stream && typeof stream === `object` && typeof stream.path === `string`) return {
|
|
6189
6436
|
...stream,
|
|
6190
|
-
path: routingAdapter.toRuntimeStreamPath(service, stream.path)
|
|
6437
|
+
path: routingAdapter.toRuntimeStreamPath(ctx.service, stream.path)
|
|
6191
6438
|
};
|
|
6192
6439
|
return stream;
|
|
6193
6440
|
});
|
|
6194
|
-
if (typeof payload.wake_stream === `string`) payload.wake_stream = routingAdapter.toRuntimeStreamPath(service, payload.wake_stream);
|
|
6195
|
-
if (typeof payload.stream === `string`) payload.stream = routingAdapter.toRuntimeStreamPath(service, payload.stream);
|
|
6441
|
+
if (typeof payload.wake_stream === `string`) payload.wake_stream = routingAdapter.toRuntimeStreamPath(ctx.service, payload.wake_stream);
|
|
6442
|
+
if (typeof payload.stream === `string`) payload.stream = routingAdapter.toRuntimeStreamPath(ctx.service, payload.stream);
|
|
6196
6443
|
if (Array.isArray(payload.acks)) payload.acks = payload.acks.map((ack) => {
|
|
6197
6444
|
if (!ack || typeof ack !== `object`) return ack;
|
|
6198
6445
|
const next = { ...ack };
|
|
6199
|
-
if (typeof next.stream === `string`) next.stream = routingAdapter.toRuntimeStreamPath(service, next.stream);
|
|
6200
|
-
if (typeof next.path === `string`) next.path = routingAdapter.toRuntimeStreamPath(service, next.path);
|
|
6446
|
+
if (typeof next.stream === `string`) next.stream = routingAdapter.toRuntimeStreamPath(ctx.service, next.stream);
|
|
6447
|
+
if (typeof next.path === `string`) next.path = routingAdapter.toRuntimeStreamPath(ctx.service, next.path);
|
|
6201
6448
|
return next;
|
|
6202
6449
|
});
|
|
6450
|
+
if (payload.webhook && typeof payload.webhook === `object` && !Array.isArray(payload.webhook)) {
|
|
6451
|
+
const webhook = payload.webhook;
|
|
6452
|
+
webhook.signing = await webhookSigningMetadata(resolveWebhookSigner$1(ctx), ctx.publicUrl);
|
|
6453
|
+
}
|
|
6203
6454
|
return new TextEncoder().encode(JSON.stringify(payload));
|
|
6204
6455
|
}
|
|
6205
6456
|
function decodeJson(bytes) {
|
|
@@ -6218,6 +6469,9 @@ function routeParam$2(request, name) {
|
|
|
6218
6469
|
function subscriptionRoutingAdapter(ctx) {
|
|
6219
6470
|
return resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl);
|
|
6220
6471
|
}
|
|
6472
|
+
function resolveWebhookSigner$1(ctx) {
|
|
6473
|
+
return ctx.webhookSigner ?? getDefaultWebhookSigner();
|
|
6474
|
+
}
|
|
6221
6475
|
async function rewriteSubscriptionRequestBody(request, ctx, subscriptionId, routingAdapter) {
|
|
6222
6476
|
const body = await readRequestBody(request);
|
|
6223
6477
|
if (body.length === 0) return {
|
|
@@ -6246,7 +6500,7 @@ async function rewriteSubscriptionRequestBody(request, ctx, subscriptionId, rout
|
|
|
6246
6500
|
async function forwardSubscriptionRequest(request, ctx, routingAdapter, opts = {}) {
|
|
6247
6501
|
const upstream = await forwardToDurableStreams(ctx, request, opts.body, `control`, opts.requestUrl, opts.bearerMode ?? `overwrite`);
|
|
6248
6502
|
let responseBytes = upstream.body ? new Uint8Array(await upstream.arrayBuffer()) : new Uint8Array();
|
|
6249
|
-
responseBytes = rewriteSubscriptionResponseForClient(responseBytes, upstream, ctx
|
|
6503
|
+
responseBytes = await rewriteSubscriptionResponseForClient(responseBytes, upstream, ctx, routingAdapter);
|
|
6250
6504
|
return {
|
|
6251
6505
|
upstream,
|
|
6252
6506
|
response: responseFromUpstream$1(upstream, responseBytes)
|
|
@@ -6319,6 +6573,15 @@ async function controlPassThrough(request, ctx) {
|
|
|
6319
6573
|
const upstream = await forwardToDurableStreams(ctx, request, void 0, `control`);
|
|
6320
6574
|
return responseFromUpstream$1(upstream);
|
|
6321
6575
|
}
|
|
6576
|
+
async function webhookJwks(_request, ctx) {
|
|
6577
|
+
return new Response(JSON.stringify(await resolveWebhookSigner$1(ctx).jwks()), {
|
|
6578
|
+
status: 200,
|
|
6579
|
+
headers: {
|
|
6580
|
+
"content-type": `application/jwk-set+json`,
|
|
6581
|
+
"cache-control": `public, max-age=300`
|
|
6582
|
+
}
|
|
6583
|
+
});
|
|
6584
|
+
}
|
|
6322
6585
|
async function streamAppend(request, ctx) {
|
|
6323
6586
|
return await electricAgentsStreamAppendRouter.fetch(createStreamAppendRouteRequest(request), ctx.runtime, (req, body) => forwardFetchRequest({
|
|
6324
6587
|
request: {
|
|
@@ -6458,6 +6721,20 @@ const forkBodySchema = Type.Object({
|
|
|
6458
6721
|
waitTimeoutMs: Type.Optional(Type.Number())
|
|
6459
6722
|
});
|
|
6460
6723
|
const setTagBodySchema = Type.Object({ value: Type.String() });
|
|
6724
|
+
const entitySignalSchema = Type.Union([
|
|
6725
|
+
Type.Literal(`SIGINT`),
|
|
6726
|
+
Type.Literal(`SIGHUP`),
|
|
6727
|
+
Type.Literal(`SIGTERM`),
|
|
6728
|
+
Type.Literal(`SIGKILL`),
|
|
6729
|
+
Type.Literal(`SIGSTOP`),
|
|
6730
|
+
Type.Literal(`SIGCONT`),
|
|
6731
|
+
Type.Literal(`SIGUSR`)
|
|
6732
|
+
]);
|
|
6733
|
+
const signalBodySchema = Type.Object({
|
|
6734
|
+
signal: entitySignalSchema,
|
|
6735
|
+
reason: Type.Optional(Type.String()),
|
|
6736
|
+
payload: Type.Optional(Type.Unknown())
|
|
6737
|
+
});
|
|
6461
6738
|
const scheduleBodySchema = Type.Union([Type.Object({
|
|
6462
6739
|
scheduleType: Type.Literal(`cron`),
|
|
6463
6740
|
expression: Type.String(),
|
|
@@ -6481,6 +6758,7 @@ entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spa
|
|
|
6481
6758
|
entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
|
|
6482
6759
|
entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
|
|
6483
6760
|
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
|
|
6761
|
+
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
|
|
6484
6762
|
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
|
|
6485
6763
|
entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
|
|
6486
6764
|
entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
|
|
@@ -6684,11 +6962,13 @@ async function spawnEntity(request, ctx) {
|
|
|
6684
6962
|
wake: parsed.wake,
|
|
6685
6963
|
created_by: principal.url
|
|
6686
6964
|
});
|
|
6687
|
-
|
|
6965
|
+
const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
|
|
6966
|
+
if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
|
|
6688
6967
|
if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
|
|
6689
6968
|
from: principal.url,
|
|
6690
6969
|
payload: parsed.initialMessage
|
|
6691
6970
|
});
|
|
6971
|
+
if (!linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
|
|
6692
6972
|
return json({
|
|
6693
6973
|
...toPublicEntity(entity),
|
|
6694
6974
|
txid: entity.txid
|
|
@@ -6712,6 +6992,22 @@ async function killEntity(request, ctx) {
|
|
|
6712
6992
|
ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
|
|
6713
6993
|
return json(result);
|
|
6714
6994
|
}
|
|
6995
|
+
async function signalEntity(request, ctx) {
|
|
6996
|
+
const principalMutationError = rejectPrincipalEntityMutation(request, `signaled`);
|
|
6997
|
+
if (principalMutationError) return principalMutationError;
|
|
6998
|
+
const parsed = routeBody(request);
|
|
6999
|
+
const { entityUrl, entity } = requireExistingEntityRoute(request);
|
|
7000
|
+
const result = await ctx.entityManager.signal(entityUrl, {
|
|
7001
|
+
signal: parsed.signal,
|
|
7002
|
+
reason: parsed.reason,
|
|
7003
|
+
payload: parsed.payload
|
|
7004
|
+
});
|
|
7005
|
+
if (result.new_state === `stopped` || result.new_state === `killed`) {
|
|
7006
|
+
await unlinkEntityDispatchSubscription(ctx, entity);
|
|
7007
|
+
ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
|
|
7008
|
+
}
|
|
7009
|
+
return json(result);
|
|
7010
|
+
}
|
|
6715
7011
|
|
|
6716
7012
|
//#endregion
|
|
6717
7013
|
//#region src/routing/entity-types-router.ts
|
|
@@ -7192,7 +7488,7 @@ async function notificationFromClaim(ctx, input) {
|
|
|
7192
7488
|
const primaryStream = withLeadingSlash(primary.path);
|
|
7193
7489
|
const entity = await ctx.entityManager.registry.getEntityByStream(primaryStream);
|
|
7194
7490
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Claim stream is not attached to an entity`, 404);
|
|
7195
|
-
if (entity.status === `stopped`) {
|
|
7491
|
+
if (entity.status === `stopped` || entity.status === `paused`) {
|
|
7196
7492
|
await ctx.streamClient.releaseSubscription(input.subscriptionId, input.claim.token, {
|
|
7197
7493
|
wake_id: input.claim.wake_id,
|
|
7198
7494
|
generation: input.claim.generation
|
|
@@ -7309,12 +7605,16 @@ function bodyFromBytes(body) {
|
|
|
7309
7605
|
return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
|
|
7310
7606
|
}
|
|
7311
7607
|
function responseFromUpstream(response, body) {
|
|
7312
|
-
|
|
7608
|
+
const responseBody = forbidsResponseBody(response.status) ? null : body !== void 0 ? bodyFromBytes(body) : response.body;
|
|
7609
|
+
return new Response(responseBody, {
|
|
7313
7610
|
status: response.status,
|
|
7314
7611
|
statusText: response.statusText,
|
|
7315
7612
|
headers: responseHeaders(response)
|
|
7316
7613
|
});
|
|
7317
7614
|
}
|
|
7615
|
+
function forbidsResponseBody(status$1) {
|
|
7616
|
+
return status$1 === 204 || status$1 === 205 || status$1 === 304;
|
|
7617
|
+
}
|
|
7318
7618
|
function forwardHeadersFromRequest(request) {
|
|
7319
7619
|
const headers = new Headers(request.headers);
|
|
7320
7620
|
headers.delete(`host`);
|
|
@@ -7323,6 +7623,45 @@ function forwardHeadersFromRequest(request) {
|
|
|
7323
7623
|
function durableStreamsSubscriptionCallback(value) {
|
|
7324
7624
|
return value.startsWith(DS_SUBSCRIPTION_CALLBACK_PREFIX) ? value.slice(DS_SUBSCRIPTION_CALLBACK_PREFIX.length) : null;
|
|
7325
7625
|
}
|
|
7626
|
+
function resolveWebhookSigner(ctx) {
|
|
7627
|
+
return ctx.webhookSigner ?? getDefaultWebhookSigner();
|
|
7628
|
+
}
|
|
7629
|
+
function durableStreamsWebhookJwksUrl(ctx) {
|
|
7630
|
+
if (!ctx.durableStreamsRouting) return appendPathToUrl(ctx.durableStreamsUrl, `/__ds/jwks.json`);
|
|
7631
|
+
return resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting, ctx.durableStreamsUrl).controlUrl({
|
|
7632
|
+
durableStreamsUrl: ctx.durableStreamsUrl,
|
|
7633
|
+
serviceId: ctx.service,
|
|
7634
|
+
requestUrl: appendPathToUrl(ctx.publicUrl, `/__ds/jwks.json`)
|
|
7635
|
+
}).toString();
|
|
7636
|
+
}
|
|
7637
|
+
function durableStreamsJwksFetchClient(ctx) {
|
|
7638
|
+
return async (input, init) => {
|
|
7639
|
+
const headers = new Headers(init?.headers);
|
|
7640
|
+
await applyDurableStreamsBearer(headers, ctx.durableStreamsBearer, { overwrite: false });
|
|
7641
|
+
const nextInit = {
|
|
7642
|
+
...init ?? {},
|
|
7643
|
+
headers
|
|
7644
|
+
};
|
|
7645
|
+
if (ctx.durableStreamsDispatcher) nextInit.dispatcher = ctx.durableStreamsDispatcher;
|
|
7646
|
+
return await fetch(input, nextInit);
|
|
7647
|
+
};
|
|
7648
|
+
}
|
|
7649
|
+
function resolveDurableStreamsWebhookSignature(ctx) {
|
|
7650
|
+
if (ctx.durableStreamsWebhookSignature === false) return false;
|
|
7651
|
+
return {
|
|
7652
|
+
jwksUrl: ctx.durableStreamsWebhookSignature?.jwksUrl ?? durableStreamsWebhookJwksUrl(ctx),
|
|
7653
|
+
toleranceSeconds: ctx.durableStreamsWebhookSignature?.toleranceSeconds,
|
|
7654
|
+
cacheTtlMs: ctx.durableStreamsWebhookSignature?.cacheTtlMs,
|
|
7655
|
+
fetchClient: ctx.durableStreamsWebhookSignature?.fetchClient ?? durableStreamsJwksFetchClient(ctx)
|
|
7656
|
+
};
|
|
7657
|
+
}
|
|
7658
|
+
async function verifyDurableStreamsWebhook(request, ctx, body) {
|
|
7659
|
+
const config = resolveDurableStreamsWebhookSignature(ctx);
|
|
7660
|
+
if (config === false) return null;
|
|
7661
|
+
const verification = await verifyWebhookSignature(body, request.headers.get(`webhook-signature`), config);
|
|
7662
|
+
if (verification.ok) return null;
|
|
7663
|
+
return apiError(verification.status, verification.status === 401 ? ErrCodeUnauthorized : `WEBHOOK_SIGNATURE_UNAVAILABLE`, verification.error);
|
|
7664
|
+
}
|
|
7326
7665
|
function claimTokenFromRequest(request) {
|
|
7327
7666
|
const electricClaimToken = request.headers.get(`electric-claim-token`)?.trim();
|
|
7328
7667
|
if (electricClaimToken) return electricClaimToken;
|
|
@@ -7356,7 +7695,10 @@ async function webhookForward(request, ctx) {
|
|
|
7356
7695
|
const rootSpan = getRequestSpan(request);
|
|
7357
7696
|
rootSpan?.updateName(`webhook-forward`);
|
|
7358
7697
|
rootSpan?.setAttribute(`electric_agents.webhook.subscription_id`, subscriptionId);
|
|
7359
|
-
const
|
|
7698
|
+
const body = await readRequestBody(request);
|
|
7699
|
+
const signatureError = await verifyDurableStreamsWebhook(request, ctx, body);
|
|
7700
|
+
if (signatureError) return signatureError;
|
|
7701
|
+
const targetWebhookUrl = await tracer.startActiveSpan(`db.lookupSubscription`, async (span) => {
|
|
7360
7702
|
try {
|
|
7361
7703
|
const rows = await ctx.pgDb.select().from(subscriptionWebhooks).where(and(eq(subscriptionWebhooks.tenantId, ctx.service), eq(subscriptionWebhooks.subscriptionId, subscriptionId))).limit(1);
|
|
7362
7704
|
return rows[0]?.webhookUrl ?? null;
|
|
@@ -7364,7 +7706,6 @@ async function webhookForward(request, ctx) {
|
|
|
7364
7706
|
span.end();
|
|
7365
7707
|
}
|
|
7366
7708
|
});
|
|
7367
|
-
const [targetWebhookUrl, body] = await Promise.all([lookupPromise, readRequestBody(request)]);
|
|
7368
7709
|
if (!targetWebhookUrl) return apiError(404, ErrCodeSubscriptionNotFound, `Unknown webhook subscription`);
|
|
7369
7710
|
const parsedBodyResult = validateOptionalJsonBody(webhookForwardBodySchema, body, request.headers.get(`content-type`));
|
|
7370
7711
|
if (!parsedBodyResult.ok) return parsedBodyResult.response;
|
|
@@ -7415,7 +7756,7 @@ async function webhookForward(request, ctx) {
|
|
|
7415
7756
|
serverLog.warn(`[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
7416
7757
|
}) : void 0;
|
|
7417
7758
|
const [entity, enriched] = await Promise.all([entityPromise, enrichPromise]);
|
|
7418
|
-
if (entity?.status === `stopped`) {
|
|
7759
|
+
if (entity?.status === `stopped` || entity?.status === `paused`) {
|
|
7419
7760
|
if (upsertPromise) await upsertPromise;
|
|
7420
7761
|
return json({ done: true });
|
|
7421
7762
|
}
|
|
@@ -7453,6 +7794,7 @@ async function webhookForward(request, ctx) {
|
|
|
7453
7794
|
const headers = forwardHeadersFromRequest(request);
|
|
7454
7795
|
headers.set(`content-type`, `application/json`);
|
|
7455
7796
|
headers.delete(`content-length`);
|
|
7797
|
+
headers.set(`webhook-signature`, await resolveWebhookSigner(ctx).sign(forwardBody));
|
|
7456
7798
|
let upstream;
|
|
7457
7799
|
try {
|
|
7458
7800
|
upstream = await tracer.startActiveSpan(`fetch.agent-handler`, async (span) => {
|
|
@@ -7540,8 +7882,9 @@ async function callbackForward(request, ctx) {
|
|
|
7540
7882
|
serverLog.info(`[callback-forward] done received for stream=${target.primaryStream} consumer=${consumerId}`);
|
|
7541
7883
|
const stillOwnsClaim = ctx.runtime.claimWriteTokens.owns(ctx.service, target.primaryStream, consumerId);
|
|
7542
7884
|
const entity = await ctx.entityManager.registry.getEntityByStream(target.primaryStream);
|
|
7543
|
-
|
|
7544
|
-
|
|
7885
|
+
let entityCleared = false;
|
|
7886
|
+
if (epoch !== void 0) {
|
|
7887
|
+
const result = await ctx.entityManager.registry.materializeReleasedClaim?.({
|
|
7545
7888
|
consumerId,
|
|
7546
7889
|
epoch,
|
|
7547
7890
|
ackedStreams: Array.isArray(requestBody?.acks) ? requestBody.acks.flatMap((ack) => {
|
|
@@ -7553,13 +7896,15 @@ async function callbackForward(request, ctx) {
|
|
|
7553
7896
|
}] : [];
|
|
7554
7897
|
}) : void 0
|
|
7555
7898
|
});
|
|
7556
|
-
|
|
7557
|
-
|
|
7899
|
+
entityCleared = result?.entityCleared ?? false;
|
|
7900
|
+
}
|
|
7901
|
+
if (entity && (entityCleared || stillOwnsClaim)) {
|
|
7902
|
+
await ctx.entityManager.registry.updateStatus(entity.url, entity.status === `stopping` ? `stopped` : `idle`);
|
|
7558
7903
|
await ctx.entityBridgeManager.onEntityChanged(entity.url);
|
|
7559
|
-
serverLog.info(`[callback-forward] status updated
|
|
7560
|
-
} else if (
|
|
7561
|
-
|
|
7562
|
-
else serverLog.
|
|
7904
|
+
serverLog.info(`[callback-forward] status updated after done for ${entity.url}`);
|
|
7905
|
+
} else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
|
|
7906
|
+
if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
|
|
7907
|
+
else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
|
|
7563
7908
|
} else if (requestBody?.done === true) serverLog.warn(`[callback-forward] done received but skipped: upstream.ok=${upstream.ok} primaryStream=${target.primaryStream ?? `null`} consumer=${consumerId}`);
|
|
7564
7909
|
} catch (err) {
|
|
7565
7910
|
serverLog.error(`[callback-forward] error processing done for consumer=${consumerId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -7612,4 +7957,4 @@ globalRouter.all(`/_electric/*`, internalRouter.fetch);
|
|
|
7612
7957
|
globalRouter.all(`*`, durableStreamsRouter.fetch);
|
|
7613
7958
|
|
|
7614
7959
|
//#endregion
|
|
7615
|
-
export { AgentsHost, DEFAULT_TENANT_ID, StreamClient, UnregisteredTenantError, createDb, globalRouter, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter };
|
|
7960
|
+
export { AgentsHost, DEFAULT_TENANT_ID, StreamClient, UnregisteredTenantError, assertEntitySignal, assertEntityStatus, createDb, createEd25519WebhookSigner, expectedSignalStatus, getDefaultWebhookSigner, globalRouter, isTerminalEntityStatus, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, rejectsNormalWrites, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, toPublicEntity, webhookSigningMetadata };
|