@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.cjs
CHANGED
|
@@ -104,7 +104,7 @@ const entities = (0, drizzle_orm_pg_core.pgTable)(`entities`, {
|
|
|
104
104
|
(0, drizzle_orm_pg_core.index)(`idx_entities_parent`).on(table.tenantId, table.parent),
|
|
105
105
|
(0, drizzle_orm_pg_core.index)(`idx_entities_created_by`).on(table.tenantId, table.createdBy),
|
|
106
106
|
(0, drizzle_orm_pg_core.index)(`entities_tags_index_gin`).using(`gin`, table.tagsIndex),
|
|
107
|
-
(0, drizzle_orm_pg_core.check)(`chk_entities_status`, drizzle_orm.sql`${table.status} IN ('spawning', 'running', 'idle', 'stopped')`)
|
|
107
|
+
(0, drizzle_orm_pg_core.check)(`chk_entities_status`, drizzle_orm.sql`${table.status} IN ('spawning', 'running', 'idle', 'paused', 'stopping', 'stopped', 'killed')`)
|
|
108
108
|
]);
|
|
109
109
|
const users = (0, drizzle_orm_pg_core.pgTable)(`users`, {
|
|
110
110
|
tenantId: (0, drizzle_orm_pg_core.text)(`tenant_id`).notNull().default(`default`),
|
|
@@ -358,12 +358,25 @@ async function runMigrations(postgresUrl) {
|
|
|
358
358
|
|
|
359
359
|
//#endregion
|
|
360
360
|
//#region src/electric-agents-types.ts
|
|
361
|
+
const ENTITY_SIGNALS = [
|
|
362
|
+
`SIGINT`,
|
|
363
|
+
`SIGHUP`,
|
|
364
|
+
`SIGTERM`,
|
|
365
|
+
`SIGKILL`,
|
|
366
|
+
`SIGSTOP`,
|
|
367
|
+
`SIGCONT`,
|
|
368
|
+
`SIGUSR`
|
|
369
|
+
];
|
|
361
370
|
const VALID_ENTITY_STATUSES = new Set([
|
|
362
371
|
`spawning`,
|
|
363
372
|
`running`,
|
|
364
373
|
`idle`,
|
|
365
|
-
`
|
|
374
|
+
`paused`,
|
|
375
|
+
`stopping`,
|
|
376
|
+
`stopped`,
|
|
377
|
+
`killed`
|
|
366
378
|
]);
|
|
379
|
+
const VALID_ENTITY_SIGNALS = new Set(ENTITY_SIGNALS);
|
|
367
380
|
function assertEntityStatus(s) {
|
|
368
381
|
if (!VALID_ENTITY_STATUSES.has(s)) throw new Error(`Invalid entity status: "${s}"`);
|
|
369
382
|
return s;
|
|
@@ -384,6 +397,27 @@ function assertRunnerAdminStatus(s) {
|
|
|
384
397
|
if (!VALID_RUNNER_ADMIN_STATUSES.has(s)) throw new Error(`Invalid runner admin status: "${s}"`);
|
|
385
398
|
return s;
|
|
386
399
|
}
|
|
400
|
+
function assertEntitySignal(s) {
|
|
401
|
+
if (!VALID_ENTITY_SIGNALS.has(s)) throw new Error(`Invalid entity signal: "${s}"`);
|
|
402
|
+
return s;
|
|
403
|
+
}
|
|
404
|
+
function isTerminalEntityStatus(status$4) {
|
|
405
|
+
return status$4 === `stopped` || status$4 === `killed`;
|
|
406
|
+
}
|
|
407
|
+
function rejectsNormalWrites(status$4) {
|
|
408
|
+
return status$4 === `stopping` || isTerminalEntityStatus(status$4);
|
|
409
|
+
}
|
|
410
|
+
function expectedSignalStatus(status$4, signal) {
|
|
411
|
+
switch (signal) {
|
|
412
|
+
case `SIGKILL`: return `killed`;
|
|
413
|
+
case `SIGTERM`: return status$4 === `idle` ? `stopped` : `stopping`;
|
|
414
|
+
case `SIGSTOP`: return status$4 === `idle` ? `paused` : status$4;
|
|
415
|
+
case `SIGCONT`: return status$4 === `paused` ? `idle` : status$4;
|
|
416
|
+
case `SIGINT`:
|
|
417
|
+
case `SIGHUP`:
|
|
418
|
+
case `SIGUSR`: return status$4;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
387
421
|
/** Strip internal fields (write_token, subscription_id) from an entity. */
|
|
388
422
|
function toPublicEntity(entity) {
|
|
389
423
|
return {
|
|
@@ -405,6 +439,7 @@ const ErrCodeUnauthorized = `UNAUTHORIZED`;
|
|
|
405
439
|
const ErrCodeNotFound = `NOT_FOUND`;
|
|
406
440
|
const ErrCodeNotRunning = `NOT_RUNNING`;
|
|
407
441
|
const ErrCodeInvalidRequest = `INVALID_REQUEST`;
|
|
442
|
+
const ErrCodeInvalidSignal = `INVALID_SIGNAL`;
|
|
408
443
|
const ErrCodeUnknownEntityType = `UNKNOWN_ENTITY_TYPE`;
|
|
409
444
|
const ErrCodeSchemaValidationFailed = `SCHEMA_VALIDATION_FAILED`;
|
|
410
445
|
const ErrCodeUnknownMessageType = `UNKNOWN_MESSAGE_TYPE`;
|
|
@@ -806,7 +841,7 @@ var PostgresRegistry = class {
|
|
|
806
841
|
};
|
|
807
842
|
}
|
|
808
843
|
async updateStatus(entityUrl, status$4) {
|
|
809
|
-
const whereClause = status$4
|
|
844
|
+
const whereClause = isTerminalEntityStatus(status$4) ? this.entityWhere(entityUrl) : (0, drizzle_orm.and)(this.entityWhere(entityUrl), (0, drizzle_orm.ne)(entities.status, `stopped`), (0, drizzle_orm.ne)(entities.status, `killed`));
|
|
810
845
|
await this.db.update(entities).set({
|
|
811
846
|
status: status$4,
|
|
812
847
|
updatedAt: Date.now()
|
|
@@ -814,13 +849,17 @@ var PostgresRegistry = class {
|
|
|
814
849
|
}
|
|
815
850
|
async updateStatusWithTxid(entityUrl, status$4) {
|
|
816
851
|
return await this.db.transaction(async (tx) => {
|
|
817
|
-
const
|
|
818
|
-
await tx.update(entities).set({
|
|
852
|
+
const rows = await tx.update(entities).set({
|
|
819
853
|
status: status$4,
|
|
820
854
|
updatedAt: Date.now()
|
|
821
|
-
}).where(
|
|
822
|
-
|
|
823
|
-
|
|
855
|
+
}).where((0, drizzle_orm.and)(this.entityWhere(entityUrl), (0, drizzle_orm.ne)(entities.status, `stopped`), (0, drizzle_orm.ne)(entities.status, `killed`))).returning({ txid: drizzle_orm.sql`pg_current_xact_id()::xid::text` });
|
|
856
|
+
return rows[0] ? parseInt(rows[0].txid) : null;
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
async touchEntityWithTxid(entityUrl) {
|
|
860
|
+
return await this.db.transaction(async (tx) => {
|
|
861
|
+
const rows = await tx.update(entities).set({ updatedAt: Date.now() }).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(entities.url, entityUrl), (0, drizzle_orm.ne)(entities.status, `stopped`), (0, drizzle_orm.ne)(entities.status, `killed`))).returning({ txid: drizzle_orm.sql`pg_current_xact_id()::xid::text` });
|
|
862
|
+
return rows[0] ? parseInt(rows[0].txid) : null;
|
|
824
863
|
});
|
|
825
864
|
}
|
|
826
865
|
async setEntityTag(url, key, value) {
|
|
@@ -2447,6 +2486,9 @@ async function assertDispatchPolicyAllowed(ctx, policy) {
|
|
|
2447
2486
|
if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner "${target.runnerId}" not found`, 404);
|
|
2448
2487
|
if (runner.owner_principal !== ctx.principal.url) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner dispatch requires the authenticated owner`, 403);
|
|
2449
2488
|
}
|
|
2489
|
+
function shouldLinkDispatchBeforeInitialMessage(policy) {
|
|
2490
|
+
return policy?.targets[0] !== void 0;
|
|
2491
|
+
}
|
|
2450
2492
|
async function linkEntityDispatchSubscription(ctx, entity) {
|
|
2451
2493
|
const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(ctx, entity);
|
|
2452
2494
|
const target = dispatchPolicy?.targets[0];
|
|
@@ -2672,6 +2714,7 @@ function createInitialQueuePosition(date) {
|
|
|
2672
2714
|
}
|
|
2673
2715
|
const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
|
|
2674
2716
|
const DEFAULT_FORK_WAIT_POLL_MS = 250;
|
|
2717
|
+
const SERVER_SIGNAL_SENDER = `/_electric/server`;
|
|
2675
2718
|
function sleep(ms) {
|
|
2676
2719
|
return new Promise((resolve$1) => setTimeout(resolve$1, ms));
|
|
2677
2720
|
}
|
|
@@ -3123,16 +3166,16 @@ var EntityManager = class {
|
|
|
3123
3166
|
if (!root) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3124
3167
|
if (root.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
|
|
3125
3168
|
const subtree = await this.listEntitySubtree(root);
|
|
3126
|
-
const stopped = subtree.find((entity) => entity.status
|
|
3127
|
-
if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork
|
|
3128
|
-
let active = subtree.filter((entity) => entity.status !== `idle`);
|
|
3169
|
+
const stopped = subtree.find((entity) => isTerminalEntityStatus(entity.status));
|
|
3170
|
+
if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
|
|
3171
|
+
let active = subtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
|
|
3129
3172
|
if (active.length === 0) {
|
|
3130
3173
|
this.addForkLocks(this.forkWorkLockedEntities, subtree.map((entity) => entity.url), workLocks);
|
|
3131
3174
|
const lockedRoot = await this.registry.getEntity(rootUrl);
|
|
3132
3175
|
if (!lockedRoot) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3133
3176
|
const lockedSubtree = await this.listEntitySubtree(lockedRoot);
|
|
3134
3177
|
this.addForkLocks(this.forkWorkLockedEntities, lockedSubtree.map((entity) => entity.url), workLocks);
|
|
3135
|
-
const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle`);
|
|
3178
|
+
const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
|
|
3136
3179
|
if (lockedActive.length === 0) return lockedSubtree;
|
|
3137
3180
|
this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
|
|
3138
3181
|
active = lockedActive;
|
|
@@ -3588,6 +3631,11 @@ var EntityManager = class {
|
|
|
3588
3631
|
if (req.position) value.position = req.position;
|
|
3589
3632
|
else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
|
|
3590
3633
|
if (value.status === `processed`) value.processed_at = now;
|
|
3634
|
+
const wakePausedEntity = entity.status === `paused` && req.mode !== `paused`;
|
|
3635
|
+
if (wakePausedEntity) {
|
|
3636
|
+
await this.registry.updateStatus(entityUrl, `idle`);
|
|
3637
|
+
await this.entityBridgeManager?.onEntityChanged(entityUrl);
|
|
3638
|
+
}
|
|
3591
3639
|
const envelope = __electric_ax_agents_runtime.entityStateSchema.inbox.insert({
|
|
3592
3640
|
key,
|
|
3593
3641
|
value
|
|
@@ -3615,7 +3663,7 @@ var EntityManager = class {
|
|
|
3615
3663
|
async updateInboxMessage(entityUrl, key, req) {
|
|
3616
3664
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3617
3665
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3618
|
-
if (entity.status
|
|
3666
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3619
3667
|
const now = new Date().toISOString();
|
|
3620
3668
|
const value = {};
|
|
3621
3669
|
if (`payload` in req) value.payload = req.payload;
|
|
@@ -3636,7 +3684,7 @@ var EntityManager = class {
|
|
|
3636
3684
|
async deleteInboxMessage(entityUrl, key) {
|
|
3637
3685
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3638
3686
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3639
|
-
if (entity.status
|
|
3687
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3640
3688
|
const envelope = __electric_ax_agents_runtime.entityStateSchema.inbox.delete({ key });
|
|
3641
3689
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
3642
3690
|
}
|
|
@@ -3644,7 +3692,7 @@ var EntityManager = class {
|
|
|
3644
3692
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3645
3693
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3646
3694
|
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
3647
|
-
if (entity.status
|
|
3695
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3648
3696
|
if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
|
|
3649
3697
|
const result = await this.registry.setEntityTag(entityUrl, key, req.value);
|
|
3650
3698
|
const updated = result.entity;
|
|
@@ -3656,7 +3704,7 @@ var EntityManager = class {
|
|
|
3656
3704
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3657
3705
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3658
3706
|
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
3659
|
-
if (entity.status
|
|
3707
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3660
3708
|
const result = await this.registry.removeEntityTag(entityUrl, key);
|
|
3661
3709
|
const updated = result.entity;
|
|
3662
3710
|
if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
|
|
@@ -3909,26 +3957,131 @@ var EntityManager = class {
|
|
|
3909
3957
|
}
|
|
3910
3958
|
};
|
|
3911
3959
|
}
|
|
3912
|
-
async
|
|
3960
|
+
async signal(entityUrl, req) {
|
|
3913
3961
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3914
3962
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
const
|
|
3918
|
-
|
|
3919
|
-
const
|
|
3920
|
-
|
|
3921
|
-
|
|
3963
|
+
if (isTerminalEntityStatus(entity.status)) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal a ${entity.status} entity`, 409);
|
|
3964
|
+
const now = new Date();
|
|
3965
|
+
const previousState = entity.status;
|
|
3966
|
+
const handling = this.serverHandlingForSignal(previousState, req.signal);
|
|
3967
|
+
const txid = handling.status === previousState ? await this.registry.touchEntityWithTxid(entityUrl) : await this.registry.updateStatusWithTxid(entityUrl, handling.status);
|
|
3968
|
+
if (txid === null) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal entity because it is already terminal`, 409);
|
|
3969
|
+
const key = `sig-${now.getTime()}-${(0, node_crypto.randomUUID)().slice(0, 8)}`;
|
|
3970
|
+
const signalValue = {
|
|
3971
|
+
signal: req.signal,
|
|
3972
|
+
status: handling.handled ? `handled` : `unhandled`,
|
|
3973
|
+
sender: SERVER_SIGNAL_SENDER,
|
|
3974
|
+
timestamp: now.toISOString()
|
|
3975
|
+
};
|
|
3976
|
+
if (req.reason !== void 0) signalValue.reason = req.reason;
|
|
3977
|
+
if (req.payload !== void 0) signalValue.payload = req.payload;
|
|
3978
|
+
if (handling.handled) {
|
|
3979
|
+
signalValue.handled_at = now.toISOString();
|
|
3980
|
+
signalValue.handled_by = SERVER_SIGNAL_SENDER;
|
|
3981
|
+
signalValue.outcome = handling.outcome;
|
|
3982
|
+
signalValue.previous_state = previousState;
|
|
3983
|
+
signalValue.new_state = handling.status;
|
|
3984
|
+
}
|
|
3985
|
+
const signalEvent = {
|
|
3986
|
+
type: `signal`,
|
|
3987
|
+
key,
|
|
3988
|
+
value: signalValue,
|
|
3989
|
+
headers: {
|
|
3990
|
+
operation: `insert`,
|
|
3991
|
+
timestamp: now.toISOString(),
|
|
3992
|
+
txid: String(txid)
|
|
3993
|
+
}
|
|
3994
|
+
};
|
|
3995
|
+
const shouldCloseStreams = isTerminalEntityStatus(handling.status);
|
|
3996
|
+
await this.appendSignalEvent(entity, signalEvent, shouldCloseStreams);
|
|
3997
|
+
if (!shouldCloseStreams) await this.evaluateWakes(entityUrl, signalEvent);
|
|
3998
|
+
if (handling.unregisterWakes) {
|
|
3999
|
+
await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
|
|
4000
|
+
await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
|
|
4001
|
+
}
|
|
4002
|
+
if (handling.status !== previousState && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
4003
|
+
return {
|
|
4004
|
+
url: entityUrl,
|
|
4005
|
+
signal: req.signal,
|
|
4006
|
+
previous_state: previousState,
|
|
4007
|
+
new_state: handling.status,
|
|
4008
|
+
created_at: now.getTime(),
|
|
4009
|
+
txid
|
|
4010
|
+
};
|
|
4011
|
+
}
|
|
4012
|
+
async kill(entityUrl) {
|
|
4013
|
+
const response = await this.signal(entityUrl, {
|
|
4014
|
+
signal: `SIGKILL`,
|
|
4015
|
+
reason: `Legacy kill command`
|
|
3922
4016
|
});
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
4017
|
+
return { txid: response.txid };
|
|
4018
|
+
}
|
|
4019
|
+
serverHandlingForSignal(status$4, signal) {
|
|
4020
|
+
if (signal === `SIGKILL`) return {
|
|
4021
|
+
status: `killed`,
|
|
4022
|
+
handled: true,
|
|
4023
|
+
outcome: `transitioned`,
|
|
4024
|
+
unregisterWakes: true
|
|
4025
|
+
};
|
|
4026
|
+
if (signal === `SIGTERM`) {
|
|
4027
|
+
if (status$4 === `idle` || status$4 === `paused`) return {
|
|
4028
|
+
status: `stopped`,
|
|
4029
|
+
handled: true,
|
|
4030
|
+
outcome: `transitioned`,
|
|
4031
|
+
unregisterWakes: true
|
|
4032
|
+
};
|
|
4033
|
+
if (status$4 === `running`) return {
|
|
4034
|
+
status: `stopping`,
|
|
4035
|
+
handled: false,
|
|
4036
|
+
outcome: `transitioned`,
|
|
4037
|
+
unregisterWakes: false
|
|
4038
|
+
};
|
|
4039
|
+
}
|
|
4040
|
+
if (status$4 === `paused` && signal !== `SIGCONT`) return {
|
|
4041
|
+
status: status$4,
|
|
4042
|
+
handled: true,
|
|
4043
|
+
outcome: `ignored`,
|
|
4044
|
+
unregisterWakes: false
|
|
4045
|
+
};
|
|
4046
|
+
if (signal === `SIGSTOP` && (status$4 === `idle` || status$4 === `running`)) return {
|
|
4047
|
+
status: `paused`,
|
|
4048
|
+
handled: status$4 === `idle`,
|
|
4049
|
+
outcome: `transitioned`,
|
|
4050
|
+
unregisterWakes: false
|
|
4051
|
+
};
|
|
4052
|
+
if (signal === `SIGCONT` && status$4 === `paused`) return {
|
|
4053
|
+
status: `idle`,
|
|
4054
|
+
handled: false,
|
|
4055
|
+
outcome: `transitioned`,
|
|
4056
|
+
unregisterWakes: false
|
|
4057
|
+
};
|
|
4058
|
+
return {
|
|
4059
|
+
status: status$4,
|
|
4060
|
+
handled: false,
|
|
4061
|
+
outcome: `ignored`,
|
|
4062
|
+
unregisterWakes: false
|
|
4063
|
+
};
|
|
4064
|
+
}
|
|
4065
|
+
async appendSignalEvent(entity, signalEvent, closeStreams) {
|
|
4066
|
+
const signalData = this.encodeChangeEvent(signalEvent);
|
|
4067
|
+
if (!closeStreams) {
|
|
4068
|
+
await this.streamClient.append(entity.streams.main, signalData);
|
|
4069
|
+
return;
|
|
4070
|
+
}
|
|
4071
|
+
const errorCloseEvent = {
|
|
4072
|
+
type: `signal`,
|
|
4073
|
+
key: signalEvent.key,
|
|
4074
|
+
value: signalEvent.value,
|
|
4075
|
+
headers: signalEvent.headers
|
|
4076
|
+
};
|
|
4077
|
+
const errorSignalData = this.encodeChangeEvent(errorCloseEvent);
|
|
4078
|
+
for (const [streamPath, data] of [[entity.streams.main, signalData], [entity.streams.error, errorSignalData]]) try {
|
|
4079
|
+
await this.streamClient.append(streamPath, data, { close: true });
|
|
3926
4080
|
} catch (err) {
|
|
3927
4081
|
const message = err instanceof Error ? err.message : String(err);
|
|
3928
4082
|
if (/closed/i.test(message) || /not found/i.test(message) || /404/.test(message) || /409/.test(message)) continue;
|
|
3929
4083
|
throw err;
|
|
3930
4084
|
}
|
|
3931
|
-
return { txid };
|
|
3932
4085
|
}
|
|
3933
4086
|
async validateWriteEvent(entity, event) {
|
|
3934
4087
|
if (!entity.type) return null;
|
|
@@ -4044,7 +4197,7 @@ var EntityManager = class {
|
|
|
4044
4197
|
async validateSendRequest(entityUrl, req) {
|
|
4045
4198
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4046
4199
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4047
|
-
if (entity.status
|
|
4200
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4048
4201
|
if (req.type && entity.type) {
|
|
4049
4202
|
const { inboxSchemas } = await this.getEffectiveSchemas(entity);
|
|
4050
4203
|
if (inboxSchemas) {
|
|
@@ -5009,7 +5162,8 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
5009
5162
|
const primaryStream = `${entityUrl}/main`;
|
|
5010
5163
|
const callbacks = await this.db.select().from(consumerCallbacks).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(consumerCallbacks.tenantId, this.serviceId), (0, drizzle_orm.eq)(consumerCallbacks.primaryStream, primaryStream))).limit(1);
|
|
5011
5164
|
if (callbacks.length > 0) return;
|
|
5012
|
-
await this.manager.registry.
|
|
5165
|
+
const entity = await this.manager.registry.getEntity(entityUrl);
|
|
5166
|
+
await this.manager.registry.updateStatus(entityUrl, entity?.status === `stopping` ? `stopped` : `idle`);
|
|
5013
5167
|
await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
5014
5168
|
}
|
|
5015
5169
|
};
|
|
@@ -6596,6 +6750,20 @@ const forkBodySchema = __sinclair_typebox.Type.Object({
|
|
|
6596
6750
|
waitTimeoutMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number())
|
|
6597
6751
|
});
|
|
6598
6752
|
const setTagBodySchema = __sinclair_typebox.Type.Object({ value: __sinclair_typebox.Type.String() });
|
|
6753
|
+
const entitySignalSchema = __sinclair_typebox.Type.Union([
|
|
6754
|
+
__sinclair_typebox.Type.Literal(`SIGINT`),
|
|
6755
|
+
__sinclair_typebox.Type.Literal(`SIGHUP`),
|
|
6756
|
+
__sinclair_typebox.Type.Literal(`SIGTERM`),
|
|
6757
|
+
__sinclair_typebox.Type.Literal(`SIGKILL`),
|
|
6758
|
+
__sinclair_typebox.Type.Literal(`SIGSTOP`),
|
|
6759
|
+
__sinclair_typebox.Type.Literal(`SIGCONT`),
|
|
6760
|
+
__sinclair_typebox.Type.Literal(`SIGUSR`)
|
|
6761
|
+
]);
|
|
6762
|
+
const signalBodySchema = __sinclair_typebox.Type.Object({
|
|
6763
|
+
signal: entitySignalSchema,
|
|
6764
|
+
reason: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
|
|
6765
|
+
payload: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Unknown())
|
|
6766
|
+
});
|
|
6599
6767
|
const scheduleBodySchema = __sinclair_typebox.Type.Union([__sinclair_typebox.Type.Object({
|
|
6600
6768
|
scheduleType: __sinclair_typebox.Type.Literal(`cron`),
|
|
6601
6769
|
expression: __sinclair_typebox.Type.String(),
|
|
@@ -6619,6 +6787,7 @@ entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spa
|
|
|
6619
6787
|
entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
|
|
6620
6788
|
entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
|
|
6621
6789
|
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
|
|
6790
|
+
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
|
|
6622
6791
|
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
|
|
6623
6792
|
entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
|
|
6624
6793
|
entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
|
|
@@ -6822,11 +6991,13 @@ async function spawnEntity(request, ctx) {
|
|
|
6822
6991
|
wake: parsed.wake,
|
|
6823
6992
|
created_by: principal.url
|
|
6824
6993
|
});
|
|
6825
|
-
|
|
6994
|
+
const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
|
|
6995
|
+
if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
|
|
6826
6996
|
if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
|
|
6827
6997
|
from: principal.url,
|
|
6828
6998
|
payload: parsed.initialMessage
|
|
6829
6999
|
});
|
|
7000
|
+
if (!linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
|
|
6830
7001
|
return (0, itty_router.json)({
|
|
6831
7002
|
...toPublicEntity(entity),
|
|
6832
7003
|
txid: entity.txid
|
|
@@ -6850,6 +7021,22 @@ async function killEntity(request, ctx) {
|
|
|
6850
7021
|
ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
|
|
6851
7022
|
return (0, itty_router.json)(result);
|
|
6852
7023
|
}
|
|
7024
|
+
async function signalEntity(request, ctx) {
|
|
7025
|
+
const principalMutationError = rejectPrincipalEntityMutation(request, `signaled`);
|
|
7026
|
+
if (principalMutationError) return principalMutationError;
|
|
7027
|
+
const parsed = routeBody(request);
|
|
7028
|
+
const { entityUrl, entity } = requireExistingEntityRoute(request);
|
|
7029
|
+
const result = await ctx.entityManager.signal(entityUrl, {
|
|
7030
|
+
signal: parsed.signal,
|
|
7031
|
+
reason: parsed.reason,
|
|
7032
|
+
payload: parsed.payload
|
|
7033
|
+
});
|
|
7034
|
+
if (result.new_state === `stopped` || result.new_state === `killed`) {
|
|
7035
|
+
await unlinkEntityDispatchSubscription(ctx, entity);
|
|
7036
|
+
ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
|
|
7037
|
+
}
|
|
7038
|
+
return (0, itty_router.json)(result);
|
|
7039
|
+
}
|
|
6853
7040
|
|
|
6854
7041
|
//#endregion
|
|
6855
7042
|
//#region src/routing/entity-types-router.ts
|
|
@@ -7330,7 +7517,7 @@ async function notificationFromClaim(ctx, input) {
|
|
|
7330
7517
|
const primaryStream = withLeadingSlash(primary.path);
|
|
7331
7518
|
const entity = await ctx.entityManager.registry.getEntityByStream(primaryStream);
|
|
7332
7519
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Claim stream is not attached to an entity`, 404);
|
|
7333
|
-
if (entity.status === `stopped`) {
|
|
7520
|
+
if (entity.status === `stopped` || entity.status === `paused`) {
|
|
7334
7521
|
await ctx.streamClient.releaseSubscription(input.subscriptionId, input.claim.token, {
|
|
7335
7522
|
wake_id: input.claim.wake_id,
|
|
7336
7523
|
generation: input.claim.generation
|
|
@@ -7598,7 +7785,7 @@ async function webhookForward(request, ctx) {
|
|
|
7598
7785
|
serverLog.warn(`[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
7599
7786
|
}) : void 0;
|
|
7600
7787
|
const [entity, enriched] = await Promise.all([entityPromise, enrichPromise]);
|
|
7601
|
-
if (entity?.status === `stopped`) {
|
|
7788
|
+
if (entity?.status === `stopped` || entity?.status === `paused`) {
|
|
7602
7789
|
if (upsertPromise) await upsertPromise;
|
|
7603
7790
|
return (0, itty_router.json)({ done: true });
|
|
7604
7791
|
}
|
|
@@ -7741,9 +7928,9 @@ async function callbackForward(request, ctx) {
|
|
|
7741
7928
|
entityCleared = result?.entityCleared ?? false;
|
|
7742
7929
|
}
|
|
7743
7930
|
if (entity && (entityCleared || stillOwnsClaim)) {
|
|
7744
|
-
await ctx.entityManager.registry.updateStatus(entity.url, `idle`);
|
|
7931
|
+
await ctx.entityManager.registry.updateStatus(entity.url, entity.status === `stopping` ? `stopped` : `idle`);
|
|
7745
7932
|
await ctx.entityBridgeManager.onEntityChanged(entity.url);
|
|
7746
|
-
serverLog.info(`[callback-forward] status updated
|
|
7933
|
+
serverLog.info(`[callback-forward] status updated after done for ${entity.url}`);
|
|
7747
7934
|
} else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
|
|
7748
7935
|
if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
|
|
7749
7936
|
else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
|
|
@@ -7803,13 +7990,19 @@ exports.AgentsHost = AgentsHost
|
|
|
7803
7990
|
exports.DEFAULT_TENANT_ID = DEFAULT_TENANT_ID
|
|
7804
7991
|
exports.StreamClient = StreamClient
|
|
7805
7992
|
exports.UnregisteredTenantError = UnregisteredTenantError
|
|
7993
|
+
exports.assertEntitySignal = assertEntitySignal
|
|
7994
|
+
exports.assertEntityStatus = assertEntityStatus
|
|
7806
7995
|
exports.createDb = createDb
|
|
7807
7996
|
exports.createEd25519WebhookSigner = createEd25519WebhookSigner
|
|
7997
|
+
exports.expectedSignalStatus = expectedSignalStatus
|
|
7808
7998
|
exports.getDefaultWebhookSigner = getDefaultWebhookSigner
|
|
7809
7999
|
exports.globalRouter = globalRouter
|
|
8000
|
+
exports.isTerminalEntityStatus = isTerminalEntityStatus
|
|
7810
8001
|
exports.isUnregisteredTenantError = isUnregisteredTenantError
|
|
7811
8002
|
exports.pathPrefixedSingleTenantDurableStreamsRoutingAdapter = pathPrefixedSingleTenantDurableStreamsRoutingAdapter
|
|
8003
|
+
exports.rejectsNormalWrites = rejectsNormalWrites
|
|
7812
8004
|
exports.runMigrations = runMigrations
|
|
7813
8005
|
exports.streamRootDurableStreamsRoutingAdapter = streamRootDurableStreamsRoutingAdapter
|
|
7814
8006
|
exports.tenantRootDurableStreamsRoutingAdapter = tenantRootDurableStreamsRoutingAdapter
|
|
8007
|
+
exports.toPublicEntity = toPublicEntity
|
|
7815
8008
|
exports.webhookSigningMetadata = webhookSigningMetadata
|
package/dist/index.d.cts
CHANGED
|
@@ -3226,7 +3226,10 @@ interface Principal {
|
|
|
3226
3226
|
type WakeNotification = WebhookNotification;
|
|
3227
3227
|
type RequestPrincipal = Principal;
|
|
3228
3228
|
type AuthenticateRequest = (request: Request) => Promise<Principal | null> | Principal | null;
|
|
3229
|
-
type EntityStatus = `spawning` | `running` | `idle` | `stopped`;
|
|
3229
|
+
type EntityStatus = `spawning` | `running` | `idle` | `paused` | `stopping` | `stopped` | `killed`;
|
|
3230
|
+
declare const ENTITY_SIGNALS: readonly ["SIGINT", "SIGHUP", "SIGTERM", "SIGKILL", "SIGSTOP", "SIGCONT", "SIGUSR"];
|
|
3231
|
+
type EntitySignal = (typeof ENTITY_SIGNALS)[number];
|
|
3232
|
+
declare function assertEntityStatus(s: string): EntityStatus;
|
|
3230
3233
|
type DispatchTarget = {
|
|
3231
3234
|
type: `webhook`;
|
|
3232
3235
|
url: string;
|
|
@@ -3353,6 +3356,10 @@ interface ConsumerClaim {
|
|
|
3353
3356
|
acked_streams?: Array<SourceStreamOffset>;
|
|
3354
3357
|
updated_at: string;
|
|
3355
3358
|
}
|
|
3359
|
+
declare function assertEntitySignal(s: string): EntitySignal;
|
|
3360
|
+
declare function isTerminalEntityStatus(status: EntityStatus): boolean;
|
|
3361
|
+
declare function rejectsNormalWrites(status: EntityStatus): boolean;
|
|
3362
|
+
declare function expectedSignalStatus(status: EntityStatus, signal: EntitySignal): EntityStatus;
|
|
3356
3363
|
interface ElectricAgentsEntity {
|
|
3357
3364
|
url: string;
|
|
3358
3365
|
type: string;
|
|
@@ -3375,7 +3382,26 @@ interface ElectricAgentsEntity {
|
|
|
3375
3382
|
updated_at: number;
|
|
3376
3383
|
}
|
|
3377
3384
|
/** Public-facing entity — internal fields stripped. Standalone type so new internal fields don't silently leak. */
|
|
3378
|
-
|
|
3385
|
+
interface PublicElectricAgentsEntity {
|
|
3386
|
+
url: string;
|
|
3387
|
+
type: string;
|
|
3388
|
+
status: EntityStatus;
|
|
3389
|
+
streams: {
|
|
3390
|
+
main: string;
|
|
3391
|
+
error: string;
|
|
3392
|
+
};
|
|
3393
|
+
dispatch_policy?: DispatchPolicy;
|
|
3394
|
+
tags: Record<string, string>;
|
|
3395
|
+
spawn_args?: Record<string, unknown>;
|
|
3396
|
+
parent?: string;
|
|
3397
|
+
created_by?: string;
|
|
3398
|
+
created_at: number;
|
|
3399
|
+
updated_at: number;
|
|
3400
|
+
}
|
|
3401
|
+
/** Entity row as stored in Postgres / returned by Electric shapes (no derived `streams` field). */
|
|
3402
|
+
type ElectricAgentsEntityRow = Omit<PublicElectricAgentsEntity, `streams`>;
|
|
3403
|
+
/** Strip internal fields (write_token, subscription_id) from an entity. */
|
|
3404
|
+
declare function toPublicEntity(entity: ElectricAgentsEntity): PublicElectricAgentsEntity;
|
|
3379
3405
|
interface ElectricAgentsEntityType {
|
|
3380
3406
|
name: string;
|
|
3381
3407
|
description: string;
|
|
@@ -3425,9 +3451,27 @@ interface SendRequest {
|
|
|
3425
3451
|
mode?: `immediate` | `queued` | `paused` | `steer`;
|
|
3426
3452
|
position?: string;
|
|
3427
3453
|
}
|
|
3454
|
+
interface SignalRequest {
|
|
3455
|
+
signal: EntitySignal;
|
|
3456
|
+
reason?: string;
|
|
3457
|
+
payload?: unknown;
|
|
3458
|
+
}
|
|
3459
|
+
interface SignalResponse {
|
|
3460
|
+
url: string;
|
|
3461
|
+
signal: EntitySignal;
|
|
3462
|
+
previous_state: EntityStatus;
|
|
3463
|
+
new_state: EntityStatus;
|
|
3464
|
+
created_at: number;
|
|
3465
|
+
txid: number;
|
|
3466
|
+
}
|
|
3428
3467
|
interface SetTagRequest {
|
|
3429
3468
|
value: string;
|
|
3430
3469
|
}
|
|
3470
|
+
interface EntityListFilter {
|
|
3471
|
+
type?: string;
|
|
3472
|
+
status?: EntityStatus;
|
|
3473
|
+
created_by?: string;
|
|
3474
|
+
}
|
|
3431
3475
|
|
|
3432
3476
|
//#endregion
|
|
3433
3477
|
//#region src/entity-registry.d.ts
|
|
@@ -3559,7 +3603,8 @@ declare class PostgresRegistry {
|
|
|
3559
3603
|
total: number;
|
|
3560
3604
|
}>;
|
|
3561
3605
|
updateStatus(entityUrl: string, status: EntityStatus): Promise<void>;
|
|
3562
|
-
updateStatusWithTxid(entityUrl: string, status: EntityStatus): Promise<number>;
|
|
3606
|
+
updateStatusWithTxid(entityUrl: string, status: EntityStatus): Promise<number | null>;
|
|
3607
|
+
touchEntityWithTxid(entityUrl: string): Promise<number | null>;
|
|
3563
3608
|
setEntityTag(url: string, key: string, value: string): Promise<{
|
|
3564
3609
|
entity: ElectricAgentsEntity | null;
|
|
3565
3610
|
changed: boolean;
|
|
@@ -4193,9 +4238,12 @@ declare class EntityManager {
|
|
|
4193
4238
|
*/
|
|
4194
4239
|
private extractRunResponse;
|
|
4195
4240
|
private buildWakeMessage;
|
|
4241
|
+
signal(entityUrl: string, req: SignalRequest): Promise<SignalResponse>;
|
|
4196
4242
|
kill(entityUrl: string): Promise<{
|
|
4197
4243
|
txid: number;
|
|
4198
4244
|
}>;
|
|
4245
|
+
private serverHandlingForSignal;
|
|
4246
|
+
private appendSignalEvent;
|
|
4199
4247
|
validateWriteEvent(entity: ElectricAgentsEntity, event: Record<string, unknown>): Promise<{
|
|
4200
4248
|
code: string;
|
|
4201
4249
|
message: string;
|
|
@@ -4451,4 +4499,4 @@ declare class UnregisteredTenantError extends Error {
|
|
|
4451
4499
|
declare function isUnregisteredTenantError(error: unknown): error is UnregisteredTenantError;
|
|
4452
4500
|
|
|
4453
4501
|
//#endregion
|
|
4454
|
-
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 };
|
|
4502
|
+
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 };
|