@electric-ax/agents-server 0.4.6 → 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 +207 -35
- package/dist/index.cjs +228 -35
- package/dist/index.d.cts +52 -4
- package/dist/index.d.ts +52 -4
- package/dist/index.js +223 -36
- package/drizzle/0009_entity_signal_statuses.sql +3 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -5
- 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 +40 -16
- package/src/index.ts +20 -0
- package/src/routing/dispatch-policy.ts +6 -0
- package/src/routing/entities-router.ts +57 -1
- package/src/routing/internal-router.ts +6 -3
- package/src/routing/runners-router.ts +1 -1
- package/src/runtime.ts +5 -1
package/dist/index.d.ts
CHANGED
|
@@ -3227,7 +3227,10 @@ interface Principal {
|
|
|
3227
3227
|
type WakeNotification = WebhookNotification;
|
|
3228
3228
|
type RequestPrincipal = Principal;
|
|
3229
3229
|
type AuthenticateRequest = (request: Request) => Promise<Principal | null> | Principal | null;
|
|
3230
|
-
type EntityStatus = `spawning` | `running` | `idle` | `stopped`;
|
|
3230
|
+
type EntityStatus = `spawning` | `running` | `idle` | `paused` | `stopping` | `stopped` | `killed`;
|
|
3231
|
+
declare const ENTITY_SIGNALS: readonly ["SIGINT", "SIGHUP", "SIGTERM", "SIGKILL", "SIGSTOP", "SIGCONT", "SIGUSR"];
|
|
3232
|
+
type EntitySignal = (typeof ENTITY_SIGNALS)[number];
|
|
3233
|
+
declare function assertEntityStatus(s: string): EntityStatus;
|
|
3231
3234
|
type DispatchTarget = {
|
|
3232
3235
|
type: `webhook`;
|
|
3233
3236
|
url: string;
|
|
@@ -3354,6 +3357,10 @@ interface ConsumerClaim {
|
|
|
3354
3357
|
acked_streams?: Array<SourceStreamOffset>;
|
|
3355
3358
|
updated_at: string;
|
|
3356
3359
|
}
|
|
3360
|
+
declare function assertEntitySignal(s: string): EntitySignal;
|
|
3361
|
+
declare function isTerminalEntityStatus(status: EntityStatus): boolean;
|
|
3362
|
+
declare function rejectsNormalWrites(status: EntityStatus): boolean;
|
|
3363
|
+
declare function expectedSignalStatus(status: EntityStatus, signal: EntitySignal): EntityStatus;
|
|
3357
3364
|
interface ElectricAgentsEntity {
|
|
3358
3365
|
url: string;
|
|
3359
3366
|
type: string;
|
|
@@ -3376,7 +3383,26 @@ interface ElectricAgentsEntity {
|
|
|
3376
3383
|
updated_at: number;
|
|
3377
3384
|
}
|
|
3378
3385
|
/** Public-facing entity — internal fields stripped. Standalone type so new internal fields don't silently leak. */
|
|
3379
|
-
|
|
3386
|
+
interface PublicElectricAgentsEntity {
|
|
3387
|
+
url: string;
|
|
3388
|
+
type: string;
|
|
3389
|
+
status: EntityStatus;
|
|
3390
|
+
streams: {
|
|
3391
|
+
main: string;
|
|
3392
|
+
error: string;
|
|
3393
|
+
};
|
|
3394
|
+
dispatch_policy?: DispatchPolicy;
|
|
3395
|
+
tags: Record<string, string>;
|
|
3396
|
+
spawn_args?: Record<string, unknown>;
|
|
3397
|
+
parent?: string;
|
|
3398
|
+
created_by?: string;
|
|
3399
|
+
created_at: number;
|
|
3400
|
+
updated_at: number;
|
|
3401
|
+
}
|
|
3402
|
+
/** Entity row as stored in Postgres / returned by Electric shapes (no derived `streams` field). */
|
|
3403
|
+
type ElectricAgentsEntityRow = Omit<PublicElectricAgentsEntity, `streams`>;
|
|
3404
|
+
/** Strip internal fields (write_token, subscription_id) from an entity. */
|
|
3405
|
+
declare function toPublicEntity(entity: ElectricAgentsEntity): PublicElectricAgentsEntity;
|
|
3380
3406
|
interface ElectricAgentsEntityType {
|
|
3381
3407
|
name: string;
|
|
3382
3408
|
description: string;
|
|
@@ -3426,9 +3452,27 @@ interface SendRequest {
|
|
|
3426
3452
|
mode?: `immediate` | `queued` | `paused` | `steer`;
|
|
3427
3453
|
position?: string;
|
|
3428
3454
|
}
|
|
3455
|
+
interface SignalRequest {
|
|
3456
|
+
signal: EntitySignal;
|
|
3457
|
+
reason?: string;
|
|
3458
|
+
payload?: unknown;
|
|
3459
|
+
}
|
|
3460
|
+
interface SignalResponse {
|
|
3461
|
+
url: string;
|
|
3462
|
+
signal: EntitySignal;
|
|
3463
|
+
previous_state: EntityStatus;
|
|
3464
|
+
new_state: EntityStatus;
|
|
3465
|
+
created_at: number;
|
|
3466
|
+
txid: number;
|
|
3467
|
+
}
|
|
3429
3468
|
interface SetTagRequest {
|
|
3430
3469
|
value: string;
|
|
3431
3470
|
}
|
|
3471
|
+
interface EntityListFilter {
|
|
3472
|
+
type?: string;
|
|
3473
|
+
status?: EntityStatus;
|
|
3474
|
+
created_by?: string;
|
|
3475
|
+
}
|
|
3432
3476
|
|
|
3433
3477
|
//#endregion
|
|
3434
3478
|
//#region src/entity-registry.d.ts
|
|
@@ -3560,7 +3604,8 @@ declare class PostgresRegistry {
|
|
|
3560
3604
|
total: number;
|
|
3561
3605
|
}>;
|
|
3562
3606
|
updateStatus(entityUrl: string, status: EntityStatus): Promise<void>;
|
|
3563
|
-
updateStatusWithTxid(entityUrl: string, status: EntityStatus): Promise<number>;
|
|
3607
|
+
updateStatusWithTxid(entityUrl: string, status: EntityStatus): Promise<number | null>;
|
|
3608
|
+
touchEntityWithTxid(entityUrl: string): Promise<number | null>;
|
|
3564
3609
|
setEntityTag(url: string, key: string, value: string): Promise<{
|
|
3565
3610
|
entity: ElectricAgentsEntity | null;
|
|
3566
3611
|
changed: boolean;
|
|
@@ -4194,9 +4239,12 @@ declare class EntityManager {
|
|
|
4194
4239
|
*/
|
|
4195
4240
|
private extractRunResponse;
|
|
4196
4241
|
private buildWakeMessage;
|
|
4242
|
+
signal(entityUrl: string, req: SignalRequest): Promise<SignalResponse>;
|
|
4197
4243
|
kill(entityUrl: string): Promise<{
|
|
4198
4244
|
txid: number;
|
|
4199
4245
|
}>;
|
|
4246
|
+
private serverHandlingForSignal;
|
|
4247
|
+
private appendSignalEvent;
|
|
4200
4248
|
validateWriteEvent(entity: ElectricAgentsEntity, event: Record<string, unknown>): Promise<{
|
|
4201
4249
|
code: string;
|
|
4202
4250
|
message: string;
|
|
@@ -4452,4 +4500,4 @@ declare class UnregisteredTenantError extends Error {
|
|
|
4452
4500
|
declare function isUnregisteredTenantError(error: unknown): error is UnregisteredTenantError;
|
|
4453
4501
|
|
|
4454
4502
|
//#endregion
|
|
4455
|
-
export { AgentsHost, AgentsHostOptions, AgentsHostTenantConfig, AgentsHostTenantRuntime, AuthenticateRequest, ConsumerClaim, DEFAULT_TENANT_ID, DispatchPolicy, DispatchTarget, DrizzleDB, DurableStreamsBearerProvider, DurableStreamsRoutingAdapter, DurableStreamsRoutingInput, Ed25519WebhookSignerOptions, ElectricAgentsRunner, ElectricAgentsUser, EntityBridgeCoordinator, EntityDispatchState, GlobalRoutes, PgClient, Principal, PrincipalKind, PublicWakeNotification, RegisterRunnerRequest, RequestPrincipal, RunnerAdminStatus, RunnerHeartbeatRequest, RunnerKind, RunnerLiveness, SourceStreamOffset, StreamClient, StreamClientOptions, SubscriptionClaimResponse, SubscriptionCreateInput, SubscriptionResponse, SubscriptionStreamInfo, TenantContext, UnregisteredTenantError, WakeNotificationRow, WebhookJwks, WebhookPublicJwk, WebhookSigner, WebhookSigningKeyInput, WebhookSigningMetadata, createDb, createEd25519WebhookSigner, getDefaultWebhookSigner, globalRouter, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, webhookSigningMetadata };
|
|
4503
|
+
export { AgentsHost, AgentsHostOptions, AgentsHostTenantConfig, AgentsHostTenantRuntime, AuthenticateRequest, ConsumerClaim, DEFAULT_TENANT_ID, DispatchPolicy, DispatchTarget, DrizzleDB, DurableStreamsBearerProvider, DurableStreamsRoutingAdapter, DurableStreamsRoutingInput, Ed25519WebhookSignerOptions, ElectricAgentsEntity, ElectricAgentsEntityRow, ElectricAgentsEntityType, ElectricAgentsRunner, ElectricAgentsUser, EntityBridgeCoordinator, EntityDispatchState, EntityListFilter, EntitySignal, EntityStatus, GlobalRoutes, PgClient, Principal, PrincipalKind, PublicElectricAgentsEntity, PublicWakeNotification, RegisterEntityTypeRequest, RegisterRunnerRequest, RequestPrincipal, RunnerAdminStatus, RunnerHeartbeatRequest, RunnerKind, RunnerLiveness, SendRequest, SignalRequest, SignalResponse, SourceStreamOffset, StreamClient, StreamClientOptions, SubscriptionClaimResponse, SubscriptionCreateInput, SubscriptionResponse, SubscriptionStreamInfo, TenantContext, TypedSpawnRequest, UnregisteredTenantError, WakeNotificationRow, WebhookJwks, WebhookPublicJwk, WebhookSigner, WebhookSigningKeyInput, WebhookSigningMetadata, assertEntitySignal, assertEntityStatus, createDb, createEd25519WebhookSigner, expectedSignalStatus, getDefaultWebhookSigner, globalRouter, isTerminalEntityStatus, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, rejectsNormalWrites, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, toPublicEntity, webhookSigningMetadata };
|
package/dist/index.js
CHANGED
|
@@ -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`;
|
|
@@ -777,7 +812,7 @@ var PostgresRegistry = class {
|
|
|
777
812
|
};
|
|
778
813
|
}
|
|
779
814
|
async updateStatus(entityUrl, status$1) {
|
|
780
|
-
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`));
|
|
781
816
|
await this.db.update(entities).set({
|
|
782
817
|
status: status$1,
|
|
783
818
|
updatedAt: Date.now()
|
|
@@ -785,13 +820,17 @@ var PostgresRegistry = class {
|
|
|
785
820
|
}
|
|
786
821
|
async updateStatusWithTxid(entityUrl, status$1) {
|
|
787
822
|
return await this.db.transaction(async (tx) => {
|
|
788
|
-
const
|
|
789
|
-
await tx.update(entities).set({
|
|
823
|
+
const rows = await tx.update(entities).set({
|
|
790
824
|
status: status$1,
|
|
791
825
|
updatedAt: Date.now()
|
|
792
|
-
}).where(
|
|
793
|
-
|
|
794
|
-
|
|
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;
|
|
795
834
|
});
|
|
796
835
|
}
|
|
797
836
|
async setEntityTag(url, key, value) {
|
|
@@ -2418,6 +2457,9 @@ async function assertDispatchPolicyAllowed(ctx, policy) {
|
|
|
2418
2457
|
if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner "${target.runnerId}" not found`, 404);
|
|
2419
2458
|
if (runner.owner_principal !== ctx.principal.url) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner dispatch requires the authenticated owner`, 403);
|
|
2420
2459
|
}
|
|
2460
|
+
function shouldLinkDispatchBeforeInitialMessage(policy) {
|
|
2461
|
+
return policy?.targets[0] !== void 0;
|
|
2462
|
+
}
|
|
2421
2463
|
async function linkEntityDispatchSubscription(ctx, entity) {
|
|
2422
2464
|
const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(ctx, entity);
|
|
2423
2465
|
const target = dispatchPolicy?.targets[0];
|
|
@@ -2643,6 +2685,7 @@ function createInitialQueuePosition(date) {
|
|
|
2643
2685
|
}
|
|
2644
2686
|
const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
|
|
2645
2687
|
const DEFAULT_FORK_WAIT_POLL_MS = 250;
|
|
2688
|
+
const SERVER_SIGNAL_SENDER = `/_electric/server`;
|
|
2646
2689
|
function sleep(ms) {
|
|
2647
2690
|
return new Promise((resolve$1) => setTimeout(resolve$1, ms));
|
|
2648
2691
|
}
|
|
@@ -3094,16 +3137,16 @@ var EntityManager = class {
|
|
|
3094
3137
|
if (!root) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3095
3138
|
if (root.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
|
|
3096
3139
|
const subtree = await this.listEntitySubtree(root);
|
|
3097
|
-
const stopped = subtree.find((entity) => entity.status
|
|
3098
|
-
if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork
|
|
3099
|
-
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`);
|
|
3100
3143
|
if (active.length === 0) {
|
|
3101
3144
|
this.addForkLocks(this.forkWorkLockedEntities, subtree.map((entity) => entity.url), workLocks);
|
|
3102
3145
|
const lockedRoot = await this.registry.getEntity(rootUrl);
|
|
3103
3146
|
if (!lockedRoot) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3104
3147
|
const lockedSubtree = await this.listEntitySubtree(lockedRoot);
|
|
3105
3148
|
this.addForkLocks(this.forkWorkLockedEntities, lockedSubtree.map((entity) => entity.url), workLocks);
|
|
3106
|
-
const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle`);
|
|
3149
|
+
const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
|
|
3107
3150
|
if (lockedActive.length === 0) return lockedSubtree;
|
|
3108
3151
|
this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
|
|
3109
3152
|
active = lockedActive;
|
|
@@ -3559,6 +3602,11 @@ var EntityManager = class {
|
|
|
3559
3602
|
if (req.position) value.position = req.position;
|
|
3560
3603
|
else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
|
|
3561
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
|
+
}
|
|
3562
3610
|
const envelope = entityStateSchema.inbox.insert({
|
|
3563
3611
|
key,
|
|
3564
3612
|
value
|
|
@@ -3586,7 +3634,7 @@ var EntityManager = class {
|
|
|
3586
3634
|
async updateInboxMessage(entityUrl, key, req) {
|
|
3587
3635
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3588
3636
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3589
|
-
if (entity.status
|
|
3637
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3590
3638
|
const now = new Date().toISOString();
|
|
3591
3639
|
const value = {};
|
|
3592
3640
|
if (`payload` in req) value.payload = req.payload;
|
|
@@ -3607,7 +3655,7 @@ var EntityManager = class {
|
|
|
3607
3655
|
async deleteInboxMessage(entityUrl, key) {
|
|
3608
3656
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3609
3657
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3610
|
-
if (entity.status
|
|
3658
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3611
3659
|
const envelope = entityStateSchema.inbox.delete({ key });
|
|
3612
3660
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
3613
3661
|
}
|
|
@@ -3615,7 +3663,7 @@ var EntityManager = class {
|
|
|
3615
3663
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3616
3664
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3617
3665
|
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
3618
|
-
if (entity.status
|
|
3666
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3619
3667
|
if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
|
|
3620
3668
|
const result = await this.registry.setEntityTag(entityUrl, key, req.value);
|
|
3621
3669
|
const updated = result.entity;
|
|
@@ -3627,7 +3675,7 @@ var EntityManager = class {
|
|
|
3627
3675
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3628
3676
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3629
3677
|
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
3630
|
-
if (entity.status
|
|
3678
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3631
3679
|
const result = await this.registry.removeEntityTag(entityUrl, key);
|
|
3632
3680
|
const updated = result.entity;
|
|
3633
3681
|
if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
|
|
@@ -3880,26 +3928,131 @@ var EntityManager = class {
|
|
|
3880
3928
|
}
|
|
3881
3929
|
};
|
|
3882
3930
|
}
|
|
3883
|
-
async
|
|
3931
|
+
async signal(entityUrl, req) {
|
|
3884
3932
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3885
3933
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
const
|
|
3889
|
-
|
|
3890
|
-
const
|
|
3891
|
-
|
|
3892
|
-
|
|
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`
|
|
3893
3987
|
});
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
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 });
|
|
3897
4051
|
} catch (err) {
|
|
3898
4052
|
const message = err instanceof Error ? err.message : String(err);
|
|
3899
4053
|
if (/closed/i.test(message) || /not found/i.test(message) || /404/.test(message) || /409/.test(message)) continue;
|
|
3900
4054
|
throw err;
|
|
3901
4055
|
}
|
|
3902
|
-
return { txid };
|
|
3903
4056
|
}
|
|
3904
4057
|
async validateWriteEvent(entity, event) {
|
|
3905
4058
|
if (!entity.type) return null;
|
|
@@ -4015,7 +4168,7 @@ var EntityManager = class {
|
|
|
4015
4168
|
async validateSendRequest(entityUrl, req) {
|
|
4016
4169
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4017
4170
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4018
|
-
if (entity.status
|
|
4171
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4019
4172
|
if (req.type && entity.type) {
|
|
4020
4173
|
const { inboxSchemas } = await this.getEffectiveSchemas(entity);
|
|
4021
4174
|
if (inboxSchemas) {
|
|
@@ -4980,7 +5133,8 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
4980
5133
|
const primaryStream = `${entityUrl}/main`;
|
|
4981
5134
|
const callbacks = await this.db.select().from(consumerCallbacks).where(and(eq(consumerCallbacks.tenantId, this.serviceId), eq(consumerCallbacks.primaryStream, primaryStream))).limit(1);
|
|
4982
5135
|
if (callbacks.length > 0) return;
|
|
4983
|
-
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`);
|
|
4984
5138
|
await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
4985
5139
|
}
|
|
4986
5140
|
};
|
|
@@ -6567,6 +6721,20 @@ const forkBodySchema = Type.Object({
|
|
|
6567
6721
|
waitTimeoutMs: Type.Optional(Type.Number())
|
|
6568
6722
|
});
|
|
6569
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
|
+
});
|
|
6570
6738
|
const scheduleBodySchema = Type.Union([Type.Object({
|
|
6571
6739
|
scheduleType: Type.Literal(`cron`),
|
|
6572
6740
|
expression: Type.String(),
|
|
@@ -6590,6 +6758,7 @@ entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spa
|
|
|
6590
6758
|
entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
|
|
6591
6759
|
entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
|
|
6592
6760
|
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
|
|
6761
|
+
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
|
|
6593
6762
|
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
|
|
6594
6763
|
entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
|
|
6595
6764
|
entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
|
|
@@ -6793,11 +6962,13 @@ async function spawnEntity(request, ctx) {
|
|
|
6793
6962
|
wake: parsed.wake,
|
|
6794
6963
|
created_by: principal.url
|
|
6795
6964
|
});
|
|
6796
|
-
|
|
6965
|
+
const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
|
|
6966
|
+
if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
|
|
6797
6967
|
if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
|
|
6798
6968
|
from: principal.url,
|
|
6799
6969
|
payload: parsed.initialMessage
|
|
6800
6970
|
});
|
|
6971
|
+
if (!linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
|
|
6801
6972
|
return json({
|
|
6802
6973
|
...toPublicEntity(entity),
|
|
6803
6974
|
txid: entity.txid
|
|
@@ -6821,6 +6992,22 @@ async function killEntity(request, ctx) {
|
|
|
6821
6992
|
ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
|
|
6822
6993
|
return json(result);
|
|
6823
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
|
+
}
|
|
6824
7011
|
|
|
6825
7012
|
//#endregion
|
|
6826
7013
|
//#region src/routing/entity-types-router.ts
|
|
@@ -7301,7 +7488,7 @@ async function notificationFromClaim(ctx, input) {
|
|
|
7301
7488
|
const primaryStream = withLeadingSlash(primary.path);
|
|
7302
7489
|
const entity = await ctx.entityManager.registry.getEntityByStream(primaryStream);
|
|
7303
7490
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Claim stream is not attached to an entity`, 404);
|
|
7304
|
-
if (entity.status === `stopped`) {
|
|
7491
|
+
if (entity.status === `stopped` || entity.status === `paused`) {
|
|
7305
7492
|
await ctx.streamClient.releaseSubscription(input.subscriptionId, input.claim.token, {
|
|
7306
7493
|
wake_id: input.claim.wake_id,
|
|
7307
7494
|
generation: input.claim.generation
|
|
@@ -7569,7 +7756,7 @@ async function webhookForward(request, ctx) {
|
|
|
7569
7756
|
serverLog.warn(`[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
7570
7757
|
}) : void 0;
|
|
7571
7758
|
const [entity, enriched] = await Promise.all([entityPromise, enrichPromise]);
|
|
7572
|
-
if (entity?.status === `stopped`) {
|
|
7759
|
+
if (entity?.status === `stopped` || entity?.status === `paused`) {
|
|
7573
7760
|
if (upsertPromise) await upsertPromise;
|
|
7574
7761
|
return json({ done: true });
|
|
7575
7762
|
}
|
|
@@ -7712,9 +7899,9 @@ async function callbackForward(request, ctx) {
|
|
|
7712
7899
|
entityCleared = result?.entityCleared ?? false;
|
|
7713
7900
|
}
|
|
7714
7901
|
if (entity && (entityCleared || stillOwnsClaim)) {
|
|
7715
|
-
await ctx.entityManager.registry.updateStatus(entity.url, `idle`);
|
|
7902
|
+
await ctx.entityManager.registry.updateStatus(entity.url, entity.status === `stopping` ? `stopped` : `idle`);
|
|
7716
7903
|
await ctx.entityBridgeManager.onEntityChanged(entity.url);
|
|
7717
|
-
serverLog.info(`[callback-forward] status updated
|
|
7904
|
+
serverLog.info(`[callback-forward] status updated after done for ${entity.url}`);
|
|
7718
7905
|
} else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
|
|
7719
7906
|
if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
|
|
7720
7907
|
else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
|
|
@@ -7770,4 +7957,4 @@ globalRouter.all(`/_electric/*`, internalRouter.fetch);
|
|
|
7770
7957
|
globalRouter.all(`*`, durableStreamsRouter.fetch);
|
|
7771
7958
|
|
|
7772
7959
|
//#endregion
|
|
7773
|
-
export { AgentsHost, DEFAULT_TENANT_ID, StreamClient, UnregisteredTenantError, createDb, createEd25519WebhookSigner, getDefaultWebhookSigner, globalRouter, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, webhookSigningMetadata };
|
|
7960
|
+
export { AgentsHost, DEFAULT_TENANT_ID, StreamClient, UnregisteredTenantError, assertEntitySignal, assertEntityStatus, createDb, createEd25519WebhookSigner, expectedSignalStatus, getDefaultWebhookSigner, globalRouter, isTerminalEntityStatus, isUnregisteredTenantError, pathPrefixedSingleTenantDurableStreamsRoutingAdapter, rejectsNormalWrites, runMigrations, streamRootDurableStreamsRoutingAdapter, tenantRootDurableStreamsRoutingAdapter, toPublicEntity, webhookSigningMetadata };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@electric-ax/agents-server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"description": "Electric Agents entity runtime server",
|
|
5
5
|
"author": "Durable Stream contributors",
|
|
6
6
|
"bin": {
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"pino-pretty": "^13.0.0",
|
|
55
55
|
"postgres": "^3.4.0",
|
|
56
56
|
"undici": "^7.24.7",
|
|
57
|
-
"@electric-ax/agents-runtime": "0.3.
|
|
57
|
+
"@electric-ax/agents-runtime": "0.3.2"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^22.19.15",
|
|
@@ -65,9 +65,9 @@
|
|
|
65
65
|
"tsx": "^4.19.0",
|
|
66
66
|
"typescript": "^5.0.0",
|
|
67
67
|
"vitest": "^4.1.0",
|
|
68
|
-
"@electric-ax/agents": "0.
|
|
69
|
-
"@electric-ax/agents
|
|
70
|
-
"@electric-ax/agents-server-ui": "0.4.
|
|
68
|
+
"@electric-ax/agents-server-conformance-tests": "0.1.7",
|
|
69
|
+
"@electric-ax/agents": "0.4.6",
|
|
70
|
+
"@electric-ax/agents-server-ui": "0.4.7"
|
|
71
71
|
},
|
|
72
72
|
"files": [
|
|
73
73
|
"dist",
|
package/src/db/schema.ts
CHANGED
|
@@ -66,7 +66,7 @@ export const entities = pgTable(
|
|
|
66
66
|
index(`entities_tags_index_gin`).using(`gin`, table.tagsIndex),
|
|
67
67
|
check(
|
|
68
68
|
`chk_entities_status`,
|
|
69
|
-
sql`${table.status} IN ('spawning', 'running', 'idle', 'stopped')`
|
|
69
|
+
sql`${table.status} IN ('spawning', 'running', 'idle', 'paused', 'stopping', 'stopped', 'killed')`
|
|
70
70
|
),
|
|
71
71
|
]
|
|
72
72
|
)
|