@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/entrypoint.js
CHANGED
|
@@ -90,7 +90,7 @@ const entities = pgTable(`entities`, {
|
|
|
90
90
|
index(`idx_entities_parent`).on(table.tenantId, table.parent),
|
|
91
91
|
index(`idx_entities_created_by`).on(table.tenantId, table.createdBy),
|
|
92
92
|
index(`entities_tags_index_gin`).using(`gin`, table.tagsIndex),
|
|
93
|
-
check(`chk_entities_status`, sql`${table.status} IN ('spawning', 'running', 'idle', 'stopped')`)
|
|
93
|
+
check(`chk_entities_status`, sql`${table.status} IN ('spawning', 'running', 'idle', 'paused', 'stopping', 'stopped', 'killed')`)
|
|
94
94
|
]);
|
|
95
95
|
const users = pgTable(`users`, {
|
|
96
96
|
tenantId: text(`tenant_id`).notNull().default(`default`),
|
|
@@ -367,12 +367,25 @@ function responseHeaders(response) {
|
|
|
367
367
|
|
|
368
368
|
//#endregion
|
|
369
369
|
//#region src/electric-agents-types.ts
|
|
370
|
+
const ENTITY_SIGNALS = [
|
|
371
|
+
`SIGINT`,
|
|
372
|
+
`SIGHUP`,
|
|
373
|
+
`SIGTERM`,
|
|
374
|
+
`SIGKILL`,
|
|
375
|
+
`SIGSTOP`,
|
|
376
|
+
`SIGCONT`,
|
|
377
|
+
`SIGUSR`
|
|
378
|
+
];
|
|
370
379
|
const VALID_ENTITY_STATUSES = new Set([
|
|
371
380
|
`spawning`,
|
|
372
381
|
`running`,
|
|
373
382
|
`idle`,
|
|
374
|
-
`
|
|
383
|
+
`paused`,
|
|
384
|
+
`stopping`,
|
|
385
|
+
`stopped`,
|
|
386
|
+
`killed`
|
|
375
387
|
]);
|
|
388
|
+
const VALID_ENTITY_SIGNALS = new Set(ENTITY_SIGNALS);
|
|
376
389
|
function assertEntityStatus(s) {
|
|
377
390
|
if (!VALID_ENTITY_STATUSES.has(s)) throw new Error(`Invalid entity status: "${s}"`);
|
|
378
391
|
return s;
|
|
@@ -393,6 +406,12 @@ function assertRunnerAdminStatus(s) {
|
|
|
393
406
|
if (!VALID_RUNNER_ADMIN_STATUSES.has(s)) throw new Error(`Invalid runner admin status: "${s}"`);
|
|
394
407
|
return s;
|
|
395
408
|
}
|
|
409
|
+
function isTerminalEntityStatus(status$1) {
|
|
410
|
+
return status$1 === `stopped` || status$1 === `killed`;
|
|
411
|
+
}
|
|
412
|
+
function rejectsNormalWrites(status$1) {
|
|
413
|
+
return status$1 === `stopping` || isTerminalEntityStatus(status$1);
|
|
414
|
+
}
|
|
396
415
|
/** Strip internal fields (write_token, subscription_id) from an entity. */
|
|
397
416
|
function toPublicEntity(entity) {
|
|
398
417
|
return {
|
|
@@ -414,6 +433,7 @@ const ErrCodeUnauthorized = `UNAUTHORIZED`;
|
|
|
414
433
|
const ErrCodeNotFound = `NOT_FOUND`;
|
|
415
434
|
const ErrCodeNotRunning = `NOT_RUNNING`;
|
|
416
435
|
const ErrCodeInvalidRequest = `INVALID_REQUEST`;
|
|
436
|
+
const ErrCodeInvalidSignal = `INVALID_SIGNAL`;
|
|
417
437
|
const ErrCodeUnknownEntityType = `UNKNOWN_ENTITY_TYPE`;
|
|
418
438
|
const ErrCodeSchemaValidationFailed = `SCHEMA_VALIDATION_FAILED`;
|
|
419
439
|
const ErrCodeUnknownMessageType = `UNKNOWN_MESSAGE_TYPE`;
|
|
@@ -2274,7 +2294,7 @@ var PostgresRegistry = class {
|
|
|
2274
2294
|
};
|
|
2275
2295
|
}
|
|
2276
2296
|
async updateStatus(entityUrl, status$1) {
|
|
2277
|
-
const whereClause = status$1
|
|
2297
|
+
const whereClause = isTerminalEntityStatus(status$1) ? this.entityWhere(entityUrl) : and(this.entityWhere(entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`));
|
|
2278
2298
|
await this.db.update(entities).set({
|
|
2279
2299
|
status: status$1,
|
|
2280
2300
|
updatedAt: Date.now()
|
|
@@ -2282,13 +2302,17 @@ var PostgresRegistry = class {
|
|
|
2282
2302
|
}
|
|
2283
2303
|
async updateStatusWithTxid(entityUrl, status$1) {
|
|
2284
2304
|
return await this.db.transaction(async (tx) => {
|
|
2285
|
-
const
|
|
2286
|
-
await tx.update(entities).set({
|
|
2305
|
+
const rows = await tx.update(entities).set({
|
|
2287
2306
|
status: status$1,
|
|
2288
2307
|
updatedAt: Date.now()
|
|
2289
|
-
}).where(
|
|
2290
|
-
|
|
2291
|
-
|
|
2308
|
+
}).where(and(this.entityWhere(entityUrl), ne(entities.status, `stopped`), ne(entities.status, `killed`))).returning({ txid: sql`pg_current_xact_id()::xid::text` });
|
|
2309
|
+
return rows[0] ? parseInt(rows[0].txid) : null;
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
async touchEntityWithTxid(entityUrl) {
|
|
2313
|
+
return await this.db.transaction(async (tx) => {
|
|
2314
|
+
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` });
|
|
2315
|
+
return rows[0] ? parseInt(rows[0].txid) : null;
|
|
2292
2316
|
});
|
|
2293
2317
|
}
|
|
2294
2318
|
async setEntityTag(url, key, value) {
|
|
@@ -2705,6 +2729,7 @@ function createInitialQueuePosition(date) {
|
|
|
2705
2729
|
}
|
|
2706
2730
|
const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
|
|
2707
2731
|
const DEFAULT_FORK_WAIT_POLL_MS = 250;
|
|
2732
|
+
const SERVER_SIGNAL_SENDER = `/_electric/server`;
|
|
2708
2733
|
function sleep(ms) {
|
|
2709
2734
|
return new Promise((resolve$1) => setTimeout(resolve$1, ms));
|
|
2710
2735
|
}
|
|
@@ -3156,16 +3181,16 @@ var EntityManager = class {
|
|
|
3156
3181
|
if (!root) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3157
3182
|
if (root.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
|
|
3158
3183
|
const subtree = await this.listEntitySubtree(root);
|
|
3159
|
-
const stopped = subtree.find((entity) => entity.status
|
|
3160
|
-
if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork
|
|
3161
|
-
let active = subtree.filter((entity) => entity.status !== `idle`);
|
|
3184
|
+
const stopped = subtree.find((entity) => isTerminalEntityStatus(entity.status));
|
|
3185
|
+
if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
|
|
3186
|
+
let active = subtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
|
|
3162
3187
|
if (active.length === 0) {
|
|
3163
3188
|
this.addForkLocks(this.forkWorkLockedEntities, subtree.map((entity) => entity.url), workLocks);
|
|
3164
3189
|
const lockedRoot = await this.registry.getEntity(rootUrl);
|
|
3165
3190
|
if (!lockedRoot) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3166
3191
|
const lockedSubtree = await this.listEntitySubtree(lockedRoot);
|
|
3167
3192
|
this.addForkLocks(this.forkWorkLockedEntities, lockedSubtree.map((entity) => entity.url), workLocks);
|
|
3168
|
-
const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle`);
|
|
3193
|
+
const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
|
|
3169
3194
|
if (lockedActive.length === 0) return lockedSubtree;
|
|
3170
3195
|
this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
|
|
3171
3196
|
active = lockedActive;
|
|
@@ -3621,6 +3646,11 @@ var EntityManager = class {
|
|
|
3621
3646
|
if (req.position) value.position = req.position;
|
|
3622
3647
|
else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
|
|
3623
3648
|
if (value.status === `processed`) value.processed_at = now;
|
|
3649
|
+
const wakePausedEntity = entity.status === `paused` && req.mode !== `paused`;
|
|
3650
|
+
if (wakePausedEntity) {
|
|
3651
|
+
await this.registry.updateStatus(entityUrl, `idle`);
|
|
3652
|
+
await this.entityBridgeManager?.onEntityChanged(entityUrl);
|
|
3653
|
+
}
|
|
3624
3654
|
const envelope = entityStateSchema.inbox.insert({
|
|
3625
3655
|
key,
|
|
3626
3656
|
value
|
|
@@ -3648,7 +3678,7 @@ var EntityManager = class {
|
|
|
3648
3678
|
async updateInboxMessage(entityUrl, key, req) {
|
|
3649
3679
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3650
3680
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3651
|
-
if (entity.status
|
|
3681
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3652
3682
|
const now = new Date().toISOString();
|
|
3653
3683
|
const value = {};
|
|
3654
3684
|
if (`payload` in req) value.payload = req.payload;
|
|
@@ -3669,7 +3699,7 @@ var EntityManager = class {
|
|
|
3669
3699
|
async deleteInboxMessage(entityUrl, key) {
|
|
3670
3700
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3671
3701
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3672
|
-
if (entity.status
|
|
3702
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3673
3703
|
const envelope = entityStateSchema.inbox.delete({ key });
|
|
3674
3704
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
3675
3705
|
}
|
|
@@ -3677,7 +3707,7 @@ var EntityManager = class {
|
|
|
3677
3707
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3678
3708
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3679
3709
|
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
3680
|
-
if (entity.status
|
|
3710
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3681
3711
|
if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
|
|
3682
3712
|
const result = await this.registry.setEntityTag(entityUrl, key, req.value);
|
|
3683
3713
|
const updated = result.entity;
|
|
@@ -3689,7 +3719,7 @@ var EntityManager = class {
|
|
|
3689
3719
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3690
3720
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3691
3721
|
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
3692
|
-
if (entity.status
|
|
3722
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3693
3723
|
const result = await this.registry.removeEntityTag(entityUrl, key);
|
|
3694
3724
|
const updated = result.entity;
|
|
3695
3725
|
if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
|
|
@@ -3942,26 +3972,131 @@ var EntityManager = class {
|
|
|
3942
3972
|
}
|
|
3943
3973
|
};
|
|
3944
3974
|
}
|
|
3945
|
-
async
|
|
3975
|
+
async signal(entityUrl, req) {
|
|
3946
3976
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3947
3977
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
const
|
|
3951
|
-
|
|
3952
|
-
const
|
|
3953
|
-
|
|
3954
|
-
|
|
3978
|
+
if (isTerminalEntityStatus(entity.status)) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal a ${entity.status} entity`, 409);
|
|
3979
|
+
const now = new Date();
|
|
3980
|
+
const previousState = entity.status;
|
|
3981
|
+
const handling = this.serverHandlingForSignal(previousState, req.signal);
|
|
3982
|
+
const txid = handling.status === previousState ? await this.registry.touchEntityWithTxid(entityUrl) : await this.registry.updateStatusWithTxid(entityUrl, handling.status);
|
|
3983
|
+
if (txid === null) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal entity because it is already terminal`, 409);
|
|
3984
|
+
const key = `sig-${now.getTime()}-${randomUUID().slice(0, 8)}`;
|
|
3985
|
+
const signalValue = {
|
|
3986
|
+
signal: req.signal,
|
|
3987
|
+
status: handling.handled ? `handled` : `unhandled`,
|
|
3988
|
+
sender: SERVER_SIGNAL_SENDER,
|
|
3989
|
+
timestamp: now.toISOString()
|
|
3990
|
+
};
|
|
3991
|
+
if (req.reason !== void 0) signalValue.reason = req.reason;
|
|
3992
|
+
if (req.payload !== void 0) signalValue.payload = req.payload;
|
|
3993
|
+
if (handling.handled) {
|
|
3994
|
+
signalValue.handled_at = now.toISOString();
|
|
3995
|
+
signalValue.handled_by = SERVER_SIGNAL_SENDER;
|
|
3996
|
+
signalValue.outcome = handling.outcome;
|
|
3997
|
+
signalValue.previous_state = previousState;
|
|
3998
|
+
signalValue.new_state = handling.status;
|
|
3999
|
+
}
|
|
4000
|
+
const signalEvent = {
|
|
4001
|
+
type: `signal`,
|
|
4002
|
+
key,
|
|
4003
|
+
value: signalValue,
|
|
4004
|
+
headers: {
|
|
4005
|
+
operation: `insert`,
|
|
4006
|
+
timestamp: now.toISOString(),
|
|
4007
|
+
txid: String(txid)
|
|
4008
|
+
}
|
|
4009
|
+
};
|
|
4010
|
+
const shouldCloseStreams = isTerminalEntityStatus(handling.status);
|
|
4011
|
+
await this.appendSignalEvent(entity, signalEvent, shouldCloseStreams);
|
|
4012
|
+
if (!shouldCloseStreams) await this.evaluateWakes(entityUrl, signalEvent);
|
|
4013
|
+
if (handling.unregisterWakes) {
|
|
4014
|
+
await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
|
|
4015
|
+
await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
|
|
4016
|
+
}
|
|
4017
|
+
if (handling.status !== previousState && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
4018
|
+
return {
|
|
4019
|
+
url: entityUrl,
|
|
4020
|
+
signal: req.signal,
|
|
4021
|
+
previous_state: previousState,
|
|
4022
|
+
new_state: handling.status,
|
|
4023
|
+
created_at: now.getTime(),
|
|
4024
|
+
txid
|
|
4025
|
+
};
|
|
4026
|
+
}
|
|
4027
|
+
async kill(entityUrl) {
|
|
4028
|
+
const response = await this.signal(entityUrl, {
|
|
4029
|
+
signal: `SIGKILL`,
|
|
4030
|
+
reason: `Legacy kill command`
|
|
3955
4031
|
});
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
4032
|
+
return { txid: response.txid };
|
|
4033
|
+
}
|
|
4034
|
+
serverHandlingForSignal(status$1, signal) {
|
|
4035
|
+
if (signal === `SIGKILL`) return {
|
|
4036
|
+
status: `killed`,
|
|
4037
|
+
handled: true,
|
|
4038
|
+
outcome: `transitioned`,
|
|
4039
|
+
unregisterWakes: true
|
|
4040
|
+
};
|
|
4041
|
+
if (signal === `SIGTERM`) {
|
|
4042
|
+
if (status$1 === `idle` || status$1 === `paused`) return {
|
|
4043
|
+
status: `stopped`,
|
|
4044
|
+
handled: true,
|
|
4045
|
+
outcome: `transitioned`,
|
|
4046
|
+
unregisterWakes: true
|
|
4047
|
+
};
|
|
4048
|
+
if (status$1 === `running`) return {
|
|
4049
|
+
status: `stopping`,
|
|
4050
|
+
handled: false,
|
|
4051
|
+
outcome: `transitioned`,
|
|
4052
|
+
unregisterWakes: false
|
|
4053
|
+
};
|
|
4054
|
+
}
|
|
4055
|
+
if (status$1 === `paused` && signal !== `SIGCONT`) return {
|
|
4056
|
+
status: status$1,
|
|
4057
|
+
handled: true,
|
|
4058
|
+
outcome: `ignored`,
|
|
4059
|
+
unregisterWakes: false
|
|
4060
|
+
};
|
|
4061
|
+
if (signal === `SIGSTOP` && (status$1 === `idle` || status$1 === `running`)) return {
|
|
4062
|
+
status: `paused`,
|
|
4063
|
+
handled: status$1 === `idle`,
|
|
4064
|
+
outcome: `transitioned`,
|
|
4065
|
+
unregisterWakes: false
|
|
4066
|
+
};
|
|
4067
|
+
if (signal === `SIGCONT` && status$1 === `paused`) return {
|
|
4068
|
+
status: `idle`,
|
|
4069
|
+
handled: false,
|
|
4070
|
+
outcome: `transitioned`,
|
|
4071
|
+
unregisterWakes: false
|
|
4072
|
+
};
|
|
4073
|
+
return {
|
|
4074
|
+
status: status$1,
|
|
4075
|
+
handled: false,
|
|
4076
|
+
outcome: `ignored`,
|
|
4077
|
+
unregisterWakes: false
|
|
4078
|
+
};
|
|
4079
|
+
}
|
|
4080
|
+
async appendSignalEvent(entity, signalEvent, closeStreams) {
|
|
4081
|
+
const signalData = this.encodeChangeEvent(signalEvent);
|
|
4082
|
+
if (!closeStreams) {
|
|
4083
|
+
await this.streamClient.append(entity.streams.main, signalData);
|
|
4084
|
+
return;
|
|
4085
|
+
}
|
|
4086
|
+
const errorCloseEvent = {
|
|
4087
|
+
type: `signal`,
|
|
4088
|
+
key: signalEvent.key,
|
|
4089
|
+
value: signalEvent.value,
|
|
4090
|
+
headers: signalEvent.headers
|
|
4091
|
+
};
|
|
4092
|
+
const errorSignalData = this.encodeChangeEvent(errorCloseEvent);
|
|
4093
|
+
for (const [streamPath, data] of [[entity.streams.main, signalData], [entity.streams.error, errorSignalData]]) try {
|
|
4094
|
+
await this.streamClient.append(streamPath, data, { close: true });
|
|
3959
4095
|
} catch (err) {
|
|
3960
4096
|
const message = err instanceof Error ? err.message : String(err);
|
|
3961
4097
|
if (/closed/i.test(message) || /not found/i.test(message) || /404/.test(message) || /409/.test(message)) continue;
|
|
3962
4098
|
throw err;
|
|
3963
4099
|
}
|
|
3964
|
-
return { txid };
|
|
3965
4100
|
}
|
|
3966
4101
|
async validateWriteEvent(entity, event) {
|
|
3967
4102
|
if (!entity.type) return null;
|
|
@@ -4077,7 +4212,7 @@ var EntityManager = class {
|
|
|
4077
4212
|
async validateSendRequest(entityUrl, req) {
|
|
4078
4213
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4079
4214
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4080
|
-
if (entity.status
|
|
4215
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4081
4216
|
if (req.type && entity.type) {
|
|
4082
4217
|
const { inboxSchemas } = await this.getEffectiveSchemas(entity);
|
|
4083
4218
|
if (inboxSchemas) {
|
|
@@ -4250,6 +4385,9 @@ async function assertDispatchPolicyAllowed(ctx, policy) {
|
|
|
4250
4385
|
if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner "${target.runnerId}" not found`, 404);
|
|
4251
4386
|
if (runner.owner_principal !== ctx.principal.url) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner dispatch requires the authenticated owner`, 403);
|
|
4252
4387
|
}
|
|
4388
|
+
function shouldLinkDispatchBeforeInitialMessage(policy) {
|
|
4389
|
+
return policy?.targets[0] !== void 0;
|
|
4390
|
+
}
|
|
4253
4391
|
async function linkEntityDispatchSubscription(ctx, entity) {
|
|
4254
4392
|
const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(ctx, entity);
|
|
4255
4393
|
const target = dispatchPolicy?.targets[0];
|
|
@@ -4375,6 +4513,20 @@ const forkBodySchema = Type.Object({
|
|
|
4375
4513
|
waitTimeoutMs: Type.Optional(Type.Number())
|
|
4376
4514
|
});
|
|
4377
4515
|
const setTagBodySchema = Type.Object({ value: Type.String() });
|
|
4516
|
+
const entitySignalSchema = Type.Union([
|
|
4517
|
+
Type.Literal(`SIGINT`),
|
|
4518
|
+
Type.Literal(`SIGHUP`),
|
|
4519
|
+
Type.Literal(`SIGTERM`),
|
|
4520
|
+
Type.Literal(`SIGKILL`),
|
|
4521
|
+
Type.Literal(`SIGSTOP`),
|
|
4522
|
+
Type.Literal(`SIGCONT`),
|
|
4523
|
+
Type.Literal(`SIGUSR`)
|
|
4524
|
+
]);
|
|
4525
|
+
const signalBodySchema = Type.Object({
|
|
4526
|
+
signal: entitySignalSchema,
|
|
4527
|
+
reason: Type.Optional(Type.String()),
|
|
4528
|
+
payload: Type.Optional(Type.Unknown())
|
|
4529
|
+
});
|
|
4378
4530
|
const scheduleBodySchema = Type.Union([Type.Object({
|
|
4379
4531
|
scheduleType: Type.Literal(`cron`),
|
|
4380
4532
|
expression: Type.String(),
|
|
@@ -4398,6 +4550,7 @@ entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spa
|
|
|
4398
4550
|
entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
|
|
4399
4551
|
entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
|
|
4400
4552
|
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
|
|
4553
|
+
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
|
|
4401
4554
|
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
|
|
4402
4555
|
entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
|
|
4403
4556
|
entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
|
|
@@ -4601,11 +4754,13 @@ async function spawnEntity(request, ctx) {
|
|
|
4601
4754
|
wake: parsed.wake,
|
|
4602
4755
|
created_by: principal.url
|
|
4603
4756
|
});
|
|
4604
|
-
|
|
4757
|
+
const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
|
|
4758
|
+
if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
|
|
4605
4759
|
if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
|
|
4606
4760
|
from: principal.url,
|
|
4607
4761
|
payload: parsed.initialMessage
|
|
4608
4762
|
});
|
|
4763
|
+
if (!linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
|
|
4609
4764
|
return json({
|
|
4610
4765
|
...toPublicEntity(entity),
|
|
4611
4766
|
txid: entity.txid
|
|
@@ -4629,6 +4784,22 @@ async function killEntity(request, ctx) {
|
|
|
4629
4784
|
ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
|
|
4630
4785
|
return json(result);
|
|
4631
4786
|
}
|
|
4787
|
+
async function signalEntity(request, ctx) {
|
|
4788
|
+
const principalMutationError = rejectPrincipalEntityMutation(request, `signaled`);
|
|
4789
|
+
if (principalMutationError) return principalMutationError;
|
|
4790
|
+
const parsed = routeBody(request);
|
|
4791
|
+
const { entityUrl, entity } = requireExistingEntityRoute(request);
|
|
4792
|
+
const result = await ctx.entityManager.signal(entityUrl, {
|
|
4793
|
+
signal: parsed.signal,
|
|
4794
|
+
reason: parsed.reason,
|
|
4795
|
+
payload: parsed.payload
|
|
4796
|
+
});
|
|
4797
|
+
if (result.new_state === `stopped` || result.new_state === `killed`) {
|
|
4798
|
+
await unlinkEntityDispatchSubscription(ctx, entity);
|
|
4799
|
+
ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
|
|
4800
|
+
}
|
|
4801
|
+
return json(result);
|
|
4802
|
+
}
|
|
4632
4803
|
|
|
4633
4804
|
//#endregion
|
|
4634
4805
|
//#region src/routing/entity-types-router.ts
|
|
@@ -5109,7 +5280,7 @@ async function notificationFromClaim(ctx, input) {
|
|
|
5109
5280
|
const primaryStream = withLeadingSlash(primary.path);
|
|
5110
5281
|
const entity = await ctx.entityManager.registry.getEntityByStream(primaryStream);
|
|
5111
5282
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Claim stream is not attached to an entity`, 404);
|
|
5112
|
-
if (entity.status === `stopped`) {
|
|
5283
|
+
if (entity.status === `stopped` || entity.status === `paused`) {
|
|
5113
5284
|
await ctx.streamClient.releaseSubscription(input.subscriptionId, input.claim.token, {
|
|
5114
5285
|
wake_id: input.claim.wake_id,
|
|
5115
5286
|
generation: input.claim.generation
|
|
@@ -5377,7 +5548,7 @@ async function webhookForward(request, ctx) {
|
|
|
5377
5548
|
serverLog.warn(`[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
5378
5549
|
}) : void 0;
|
|
5379
5550
|
const [entity, enriched] = await Promise.all([entityPromise, enrichPromise]);
|
|
5380
|
-
if (entity?.status === `stopped`) {
|
|
5551
|
+
if (entity?.status === `stopped` || entity?.status === `paused`) {
|
|
5381
5552
|
if (upsertPromise) await upsertPromise;
|
|
5382
5553
|
return json({ done: true });
|
|
5383
5554
|
}
|
|
@@ -5520,9 +5691,9 @@ async function callbackForward(request, ctx) {
|
|
|
5520
5691
|
entityCleared = result?.entityCleared ?? false;
|
|
5521
5692
|
}
|
|
5522
5693
|
if (entity && (entityCleared || stillOwnsClaim)) {
|
|
5523
|
-
await ctx.entityManager.registry.updateStatus(entity.url, `idle`);
|
|
5694
|
+
await ctx.entityManager.registry.updateStatus(entity.url, entity.status === `stopping` ? `stopped` : `idle`);
|
|
5524
5695
|
await ctx.entityBridgeManager.onEntityChanged(entity.url);
|
|
5525
|
-
serverLog.info(`[callback-forward] status updated
|
|
5696
|
+
serverLog.info(`[callback-forward] status updated after done for ${entity.url}`);
|
|
5526
5697
|
} else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
|
|
5527
5698
|
if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
|
|
5528
5699
|
else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
|
|
@@ -6875,7 +7046,8 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
6875
7046
|
const primaryStream = `${entityUrl}/main`;
|
|
6876
7047
|
const callbacks = await this.db.select().from(consumerCallbacks).where(and(eq(consumerCallbacks.tenantId, this.serviceId), eq(consumerCallbacks.primaryStream, primaryStream))).limit(1);
|
|
6877
7048
|
if (callbacks.length > 0) return;
|
|
6878
|
-
await this.manager.registry.
|
|
7049
|
+
const entity = await this.manager.registry.getEntity(entityUrl);
|
|
7050
|
+
await this.manager.registry.updateStatus(entityUrl, entity?.status === `stopping` ? `stopped` : `idle`);
|
|
6879
7051
|
await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
6880
7052
|
}
|
|
6881
7053
|
};
|