@electric-ax/agents-server 0.4.6 → 0.4.9
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 +314 -38
- package/dist/index.cjs +332 -37
- package/dist/index.d.cts +72 -5
- package/dist/index.d.ts +72 -5
- package/dist/index.js +328 -39
- package/drizzle/0009_entity_signal_statuses.sql +3 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +6 -6
- package/src/db/schema.ts +1 -1
- package/src/electric-agents-types.ts +77 -1
- package/src/entity-manager.ts +320 -33
- package/src/entity-registry.ts +40 -16
- package/src/index.ts +29 -1
- package/src/manifest-side-effects.ts +11 -0
- package/src/routing/context.ts +18 -1
- package/src/routing/dispatch-policy.ts +6 -0
- package/src/routing/entities-router.ts +187 -1
- package/src/routing/internal-router.ts +25 -4
- package/src/routing/runners-router.ts +1 -1
- package/src/runtime.ts +5 -1
- package/src/server.ts +12 -0
package/dist/entrypoint.js
CHANGED
|
@@ -4,7 +4,7 @@ import { DurableStreamTestServer } from "@durable-streams/server";
|
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { createServerAdapter } from "@whatwg-node/server";
|
|
6
6
|
import { Agent } from "undici";
|
|
7
|
-
import { appendPathToUrl, assertTags, buildTagsIndex, createEntityRegistry, createRuntimeHandler, entityStateSchema, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
|
|
7
|
+
import { appendPathToUrl, assertTags, buildEventSourceManifestEntry, buildTagsIndex, createEntityRegistry, createRuntimeHandler, entityStateSchema, eventSourceSubscriptionManifestKey, getCronStreamPath, getCronStreamPathFromSpec, getEntitiesStreamPath, getNextCronFireAt, getSharedStateStreamPath, getWebhookStreamPath, manifestChildKey, manifestSharedStateKey, manifestSourceKey, normalizeTags, parseCronStreamPath, resolveCronScheduleSpec, resolveEventSourceSubscription, sourceRefForTags, verifyWebhookSignature } from "@electric-ax/agents-runtime";
|
|
8
8
|
import fs, { existsSync } from "node:fs";
|
|
9
9
|
import path, { dirname, resolve } from "node:path";
|
|
10
10
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
@@ -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) {
|
|
@@ -2644,6 +2668,10 @@ function extractManifestSourceUrl(manifest) {
|
|
|
2644
2668
|
}
|
|
2645
2669
|
if (manifest.sourceType === `entities`) return typeof manifest.sourceRef === `string` ? `/_entities/${manifest.sourceRef}` : void 0;
|
|
2646
2670
|
if (manifest.sourceType === `db`) return typeof manifest.sourceRef === `string` ? getSharedStateStreamPath(manifest.sourceRef) : void 0;
|
|
2671
|
+
if (manifest.sourceType === `webhook`) {
|
|
2672
|
+
if (typeof config?.streamUrl === `string`) return config.streamUrl;
|
|
2673
|
+
if (typeof config?.endpointKey === `string`) return getWebhookStreamPath(config.endpointKey, typeof config.bucket === `string` ? config.bucket : void 0);
|
|
2674
|
+
}
|
|
2647
2675
|
return void 0;
|
|
2648
2676
|
}
|
|
2649
2677
|
if (manifest.kind === `shared-state`) return typeof manifest.id === `string` ? getSharedStateStreamPath(manifest.id) : void 0;
|
|
@@ -2705,6 +2733,7 @@ function createInitialQueuePosition(date) {
|
|
|
2705
2733
|
}
|
|
2706
2734
|
const DEFAULT_FORK_WAIT_TIMEOUT_MS = 12e4;
|
|
2707
2735
|
const DEFAULT_FORK_WAIT_POLL_MS = 250;
|
|
2736
|
+
const SERVER_SIGNAL_SENDER = `/_electric/server`;
|
|
2708
2737
|
function sleep(ms) {
|
|
2709
2738
|
return new Promise((resolve$1) => setTimeout(resolve$1, ms));
|
|
2710
2739
|
}
|
|
@@ -2929,7 +2958,8 @@ var EntityManager = class {
|
|
|
2929
2958
|
debounceMs: req.wake.debounceMs,
|
|
2930
2959
|
timeoutMs: req.wake.timeoutMs,
|
|
2931
2960
|
oneShot: false,
|
|
2932
|
-
includeResponse: req.wake.includeResponse
|
|
2961
|
+
includeResponse: req.wake.includeResponse,
|
|
2962
|
+
manifestKey: req.wake.manifestKey
|
|
2933
2963
|
});
|
|
2934
2964
|
const contentType = `application/json`;
|
|
2935
2965
|
const createdEvent = entityStateSchema.entityCreated.insert({
|
|
@@ -3156,16 +3186,16 @@ var EntityManager = class {
|
|
|
3156
3186
|
if (!root) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3157
3187
|
if (root.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
|
|
3158
3188
|
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`);
|
|
3189
|
+
const stopped = subtree.find((entity) => isTerminalEntityStatus(entity.status));
|
|
3190
|
+
if (stopped) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${stopped.url}"`, 409);
|
|
3191
|
+
let active = subtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
|
|
3162
3192
|
if (active.length === 0) {
|
|
3163
3193
|
this.addForkLocks(this.forkWorkLockedEntities, subtree.map((entity) => entity.url), workLocks);
|
|
3164
3194
|
const lockedRoot = await this.registry.getEntity(rootUrl);
|
|
3165
3195
|
if (!lockedRoot) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3166
3196
|
const lockedSubtree = await this.listEntitySubtree(lockedRoot);
|
|
3167
3197
|
this.addForkLocks(this.forkWorkLockedEntities, lockedSubtree.map((entity) => entity.url), workLocks);
|
|
3168
|
-
const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle`);
|
|
3198
|
+
const lockedActive = lockedSubtree.filter((entity) => entity.status !== `idle` && entity.status !== `paused`);
|
|
3169
3199
|
if (lockedActive.length === 0) return lockedSubtree;
|
|
3170
3200
|
this.releaseForkLocks(this.forkWorkLockedEntities, workLocks);
|
|
3171
3201
|
active = lockedActive;
|
|
@@ -3621,6 +3651,11 @@ var EntityManager = class {
|
|
|
3621
3651
|
if (req.position) value.position = req.position;
|
|
3622
3652
|
else if (value.mode === `queued` || value.mode === `paused`) value.position = createInitialQueuePosition(new Date(now));
|
|
3623
3653
|
if (value.status === `processed`) value.processed_at = now;
|
|
3654
|
+
const wakePausedEntity = entity.status === `paused` && req.mode !== `paused`;
|
|
3655
|
+
if (wakePausedEntity) {
|
|
3656
|
+
await this.registry.updateStatus(entityUrl, `idle`);
|
|
3657
|
+
await this.entityBridgeManager?.onEntityChanged(entityUrl);
|
|
3658
|
+
}
|
|
3624
3659
|
const envelope = entityStateSchema.inbox.insert({
|
|
3625
3660
|
key,
|
|
3626
3661
|
value
|
|
@@ -3648,7 +3683,7 @@ var EntityManager = class {
|
|
|
3648
3683
|
async updateInboxMessage(entityUrl, key, req) {
|
|
3649
3684
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3650
3685
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3651
|
-
if (entity.status
|
|
3686
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3652
3687
|
const now = new Date().toISOString();
|
|
3653
3688
|
const value = {};
|
|
3654
3689
|
if (`payload` in req) value.payload = req.payload;
|
|
@@ -3669,7 +3704,7 @@ var EntityManager = class {
|
|
|
3669
3704
|
async deleteInboxMessage(entityUrl, key) {
|
|
3670
3705
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3671
3706
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3672
|
-
if (entity.status
|
|
3707
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3673
3708
|
const envelope = entityStateSchema.inbox.delete({ key });
|
|
3674
3709
|
await this.streamClient.append(entity.streams.main, this.encodeChangeEvent(envelope));
|
|
3675
3710
|
}
|
|
@@ -3677,7 +3712,7 @@ var EntityManager = class {
|
|
|
3677
3712
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3678
3713
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3679
3714
|
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
3680
|
-
if (entity.status
|
|
3715
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3681
3716
|
if (typeof req.value !== `string`) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Tag values must be strings`, 400);
|
|
3682
3717
|
const result = await this.registry.setEntityTag(entityUrl, key, req.value);
|
|
3683
3718
|
const updated = result.entity;
|
|
@@ -3689,7 +3724,7 @@ var EntityManager = class {
|
|
|
3689
3724
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3690
3725
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3691
3726
|
if (!this.isValidWriteToken(entity, token)) throw new ElectricAgentsError(ErrCodeUnauthorized, `Invalid write token`, 401);
|
|
3692
|
-
if (entity.status
|
|
3727
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
3693
3728
|
const result = await this.registry.removeEntityTag(entityUrl, key);
|
|
3694
3729
|
const updated = result.entity;
|
|
3695
3730
|
if (!updated) throw new ElectricAgentsError(ErrCodeEntityPersistFailed, `Entity not found after tag delete`, 500);
|
|
@@ -3818,6 +3853,35 @@ var EntityManager = class {
|
|
|
3818
3853
|
await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
|
|
3819
3854
|
return { txid };
|
|
3820
3855
|
}
|
|
3856
|
+
async upsertEventSourceSubscription(entityUrl, req) {
|
|
3857
|
+
const manifestKey = req.subscription.manifestKey;
|
|
3858
|
+
const txid = randomUUID();
|
|
3859
|
+
await this.writeManifestEntry(entityUrl, manifestKey, `upsert`, req.manifest, { txid });
|
|
3860
|
+
await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
|
|
3861
|
+
await this.wakeRegistry.register({
|
|
3862
|
+
tenantId: this.tenantId,
|
|
3863
|
+
subscriberUrl: entityUrl,
|
|
3864
|
+
sourceUrl: req.subscription.sourceUrl,
|
|
3865
|
+
condition: {
|
|
3866
|
+
on: `change`,
|
|
3867
|
+
collections: [`webhook_event`],
|
|
3868
|
+
ops: [`insert`]
|
|
3869
|
+
},
|
|
3870
|
+
oneShot: false,
|
|
3871
|
+
manifestKey
|
|
3872
|
+
});
|
|
3873
|
+
return {
|
|
3874
|
+
txid,
|
|
3875
|
+
subscription: req.subscription
|
|
3876
|
+
};
|
|
3877
|
+
}
|
|
3878
|
+
async deleteEventSourceSubscription(entityUrl, req) {
|
|
3879
|
+
const manifestKey = eventSourceSubscriptionManifestKey(req.id);
|
|
3880
|
+
const txid = randomUUID();
|
|
3881
|
+
await this.writeManifestEntry(entityUrl, manifestKey, `delete`, void 0, { txid });
|
|
3882
|
+
await this.wakeRegistry.unregisterByManifestKey(entityUrl, manifestKey, this.tenantId);
|
|
3883
|
+
return { txid };
|
|
3884
|
+
}
|
|
3821
3885
|
/**
|
|
3822
3886
|
* Register a wake subscription from a subscriber to a source entity.
|
|
3823
3887
|
*/
|
|
@@ -3942,26 +4006,131 @@ var EntityManager = class {
|
|
|
3942
4006
|
}
|
|
3943
4007
|
};
|
|
3944
4008
|
}
|
|
3945
|
-
async
|
|
4009
|
+
async signal(entityUrl, req) {
|
|
3946
4010
|
const entity = await this.registry.getEntity(entityUrl);
|
|
3947
4011
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
const
|
|
3951
|
-
|
|
3952
|
-
const
|
|
3953
|
-
|
|
3954
|
-
|
|
4012
|
+
if (isTerminalEntityStatus(entity.status)) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal a ${entity.status} entity`, 409);
|
|
4013
|
+
const now = new Date();
|
|
4014
|
+
const previousState = entity.status;
|
|
4015
|
+
const handling = this.serverHandlingForSignal(previousState, req.signal);
|
|
4016
|
+
const txid = handling.status === previousState ? await this.registry.touchEntityWithTxid(entityUrl) : await this.registry.updateStatusWithTxid(entityUrl, handling.status);
|
|
4017
|
+
if (txid === null) throw new ElectricAgentsError(ErrCodeInvalidSignal, `Cannot signal entity because it is already terminal`, 409);
|
|
4018
|
+
const key = `sig-${now.getTime()}-${randomUUID().slice(0, 8)}`;
|
|
4019
|
+
const signalValue = {
|
|
4020
|
+
signal: req.signal,
|
|
4021
|
+
status: handling.handled ? `handled` : `unhandled`,
|
|
4022
|
+
sender: SERVER_SIGNAL_SENDER,
|
|
4023
|
+
timestamp: now.toISOString()
|
|
4024
|
+
};
|
|
4025
|
+
if (req.reason !== void 0) signalValue.reason = req.reason;
|
|
4026
|
+
if (req.payload !== void 0) signalValue.payload = req.payload;
|
|
4027
|
+
if (handling.handled) {
|
|
4028
|
+
signalValue.handled_at = now.toISOString();
|
|
4029
|
+
signalValue.handled_by = SERVER_SIGNAL_SENDER;
|
|
4030
|
+
signalValue.outcome = handling.outcome;
|
|
4031
|
+
signalValue.previous_state = previousState;
|
|
4032
|
+
signalValue.new_state = handling.status;
|
|
4033
|
+
}
|
|
4034
|
+
const signalEvent = {
|
|
4035
|
+
type: `signal`,
|
|
4036
|
+
key,
|
|
4037
|
+
value: signalValue,
|
|
4038
|
+
headers: {
|
|
4039
|
+
operation: `insert`,
|
|
4040
|
+
timestamp: now.toISOString(),
|
|
4041
|
+
txid: String(txid)
|
|
4042
|
+
}
|
|
4043
|
+
};
|
|
4044
|
+
const shouldCloseStreams = isTerminalEntityStatus(handling.status);
|
|
4045
|
+
await this.appendSignalEvent(entity, signalEvent, shouldCloseStreams);
|
|
4046
|
+
if (!shouldCloseStreams) await this.evaluateWakes(entityUrl, signalEvent);
|
|
4047
|
+
if (handling.unregisterWakes) {
|
|
4048
|
+
await this.wakeRegistry.unregisterBySubscriber(entityUrl, this.tenantId);
|
|
4049
|
+
await this.wakeRegistry.unregisterBySource(entityUrl, this.tenantId);
|
|
4050
|
+
}
|
|
4051
|
+
if (handling.status !== previousState && this.entityBridgeManager) await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
4052
|
+
return {
|
|
4053
|
+
url: entityUrl,
|
|
4054
|
+
signal: req.signal,
|
|
4055
|
+
previous_state: previousState,
|
|
4056
|
+
new_state: handling.status,
|
|
4057
|
+
created_at: now.getTime(),
|
|
4058
|
+
txid
|
|
4059
|
+
};
|
|
4060
|
+
}
|
|
4061
|
+
async kill(entityUrl) {
|
|
4062
|
+
const response = await this.signal(entityUrl, {
|
|
4063
|
+
signal: `SIGKILL`,
|
|
4064
|
+
reason: `Legacy kill command`
|
|
3955
4065
|
});
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
4066
|
+
return { txid: response.txid };
|
|
4067
|
+
}
|
|
4068
|
+
serverHandlingForSignal(status$1, signal) {
|
|
4069
|
+
if (signal === `SIGKILL`) return {
|
|
4070
|
+
status: `killed`,
|
|
4071
|
+
handled: true,
|
|
4072
|
+
outcome: `transitioned`,
|
|
4073
|
+
unregisterWakes: true
|
|
4074
|
+
};
|
|
4075
|
+
if (signal === `SIGTERM`) {
|
|
4076
|
+
if (status$1 === `idle` || status$1 === `paused`) return {
|
|
4077
|
+
status: `stopped`,
|
|
4078
|
+
handled: true,
|
|
4079
|
+
outcome: `transitioned`,
|
|
4080
|
+
unregisterWakes: true
|
|
4081
|
+
};
|
|
4082
|
+
if (status$1 === `running`) return {
|
|
4083
|
+
status: `stopping`,
|
|
4084
|
+
handled: false,
|
|
4085
|
+
outcome: `transitioned`,
|
|
4086
|
+
unregisterWakes: false
|
|
4087
|
+
};
|
|
4088
|
+
}
|
|
4089
|
+
if (status$1 === `paused` && signal !== `SIGCONT`) return {
|
|
4090
|
+
status: status$1,
|
|
4091
|
+
handled: true,
|
|
4092
|
+
outcome: `ignored`,
|
|
4093
|
+
unregisterWakes: false
|
|
4094
|
+
};
|
|
4095
|
+
if (signal === `SIGSTOP` && (status$1 === `idle` || status$1 === `running`)) return {
|
|
4096
|
+
status: `paused`,
|
|
4097
|
+
handled: status$1 === `idle`,
|
|
4098
|
+
outcome: `transitioned`,
|
|
4099
|
+
unregisterWakes: false
|
|
4100
|
+
};
|
|
4101
|
+
if (signal === `SIGCONT` && status$1 === `paused`) return {
|
|
4102
|
+
status: `idle`,
|
|
4103
|
+
handled: false,
|
|
4104
|
+
outcome: `transitioned`,
|
|
4105
|
+
unregisterWakes: false
|
|
4106
|
+
};
|
|
4107
|
+
return {
|
|
4108
|
+
status: status$1,
|
|
4109
|
+
handled: false,
|
|
4110
|
+
outcome: `ignored`,
|
|
4111
|
+
unregisterWakes: false
|
|
4112
|
+
};
|
|
4113
|
+
}
|
|
4114
|
+
async appendSignalEvent(entity, signalEvent, closeStreams) {
|
|
4115
|
+
const signalData = this.encodeChangeEvent(signalEvent);
|
|
4116
|
+
if (!closeStreams) {
|
|
4117
|
+
await this.streamClient.append(entity.streams.main, signalData);
|
|
4118
|
+
return;
|
|
4119
|
+
}
|
|
4120
|
+
const errorCloseEvent = {
|
|
4121
|
+
type: `signal`,
|
|
4122
|
+
key: signalEvent.key,
|
|
4123
|
+
value: signalEvent.value,
|
|
4124
|
+
headers: signalEvent.headers
|
|
4125
|
+
};
|
|
4126
|
+
const errorSignalData = this.encodeChangeEvent(errorCloseEvent);
|
|
4127
|
+
for (const [streamPath, data] of [[entity.streams.main, signalData], [entity.streams.error, errorSignalData]]) try {
|
|
4128
|
+
await this.streamClient.append(streamPath, data, { close: true });
|
|
3959
4129
|
} catch (err) {
|
|
3960
4130
|
const message = err instanceof Error ? err.message : String(err);
|
|
3961
4131
|
if (/closed/i.test(message) || /not found/i.test(message) || /404/.test(message) || /409/.test(message)) continue;
|
|
3962
4132
|
throw err;
|
|
3963
4133
|
}
|
|
3964
|
-
return { txid };
|
|
3965
4134
|
}
|
|
3966
4135
|
async validateWriteEvent(entity, event) {
|
|
3967
4136
|
if (!entity.type) return null;
|
|
@@ -4077,7 +4246,7 @@ var EntityManager = class {
|
|
|
4077
4246
|
async validateSendRequest(entityUrl, req) {
|
|
4078
4247
|
const entity = await this.registry.getEntity(entityUrl);
|
|
4079
4248
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
|
|
4080
|
-
if (entity.status
|
|
4249
|
+
if (rejectsNormalWrites(entity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Entity is not accepting writes`, 409);
|
|
4081
4250
|
if (req.type && entity.type) {
|
|
4082
4251
|
const { inboxSchemas } = await this.getEffectiveSchemas(entity);
|
|
4083
4252
|
if (inboxSchemas) {
|
|
@@ -4250,6 +4419,9 @@ async function assertDispatchPolicyAllowed(ctx, policy) {
|
|
|
4250
4419
|
if (!runner) throw new ElectricAgentsError(ErrCodeNotFound, `Runner "${target.runnerId}" not found`, 404);
|
|
4251
4420
|
if (runner.owner_principal !== ctx.principal.url) throw new ElectricAgentsError(ErrCodeUnauthorized, `Runner dispatch requires the authenticated owner`, 403);
|
|
4252
4421
|
}
|
|
4422
|
+
function shouldLinkDispatchBeforeInitialMessage(policy) {
|
|
4423
|
+
return policy?.targets[0] !== void 0;
|
|
4424
|
+
}
|
|
4253
4425
|
async function linkEntityDispatchSubscription(ctx, entity) {
|
|
4254
4426
|
const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(ctx, entity);
|
|
4255
4427
|
const target = dispatchPolicy?.targets[0];
|
|
@@ -4338,7 +4510,8 @@ const spawnBodySchema = Type.Object({
|
|
|
4338
4510
|
condition: wakeConditionSchema,
|
|
4339
4511
|
debounceMs: Type.Optional(Type.Number()),
|
|
4340
4512
|
timeoutMs: Type.Optional(Type.Number()),
|
|
4341
|
-
includeResponse: Type.Optional(Type.Boolean())
|
|
4513
|
+
includeResponse: Type.Optional(Type.Boolean()),
|
|
4514
|
+
manifestKey: Type.Optional(Type.String())
|
|
4342
4515
|
}))
|
|
4343
4516
|
});
|
|
4344
4517
|
const sendBodySchema = Type.Object({
|
|
@@ -4375,6 +4548,20 @@ const forkBodySchema = Type.Object({
|
|
|
4375
4548
|
waitTimeoutMs: Type.Optional(Type.Number())
|
|
4376
4549
|
});
|
|
4377
4550
|
const setTagBodySchema = Type.Object({ value: Type.String() });
|
|
4551
|
+
const entitySignalSchema = Type.Union([
|
|
4552
|
+
Type.Literal(`SIGINT`),
|
|
4553
|
+
Type.Literal(`SIGHUP`),
|
|
4554
|
+
Type.Literal(`SIGTERM`),
|
|
4555
|
+
Type.Literal(`SIGKILL`),
|
|
4556
|
+
Type.Literal(`SIGSTOP`),
|
|
4557
|
+
Type.Literal(`SIGCONT`),
|
|
4558
|
+
Type.Literal(`SIGUSR`)
|
|
4559
|
+
]);
|
|
4560
|
+
const signalBodySchema = Type.Object({
|
|
4561
|
+
signal: entitySignalSchema,
|
|
4562
|
+
reason: Type.Optional(Type.String()),
|
|
4563
|
+
payload: Type.Optional(Type.Unknown())
|
|
4564
|
+
});
|
|
4378
4565
|
const scheduleBodySchema = Type.Union([Type.Object({
|
|
4379
4566
|
scheduleType: Type.Literal(`cron`),
|
|
4380
4567
|
expression: Type.String(),
|
|
@@ -4390,6 +4577,22 @@ const scheduleBodySchema = Type.Union([Type.Object({
|
|
|
4390
4577
|
messageType: Type.Optional(Type.String()),
|
|
4391
4578
|
from: Type.Optional(Type.String())
|
|
4392
4579
|
})]);
|
|
4580
|
+
const subscriptionLifetimeSchema = Type.Union([
|
|
4581
|
+
Type.Object({ kind: Type.Literal(`until_entity_stopped`) }),
|
|
4582
|
+
Type.Object({
|
|
4583
|
+
kind: Type.Literal(`expires_at`),
|
|
4584
|
+
at: Type.String()
|
|
4585
|
+
}),
|
|
4586
|
+
Type.Object({ kind: Type.Literal(`manual`) })
|
|
4587
|
+
]);
|
|
4588
|
+
const eventSourceSubscriptionBodySchema = Type.Object({
|
|
4589
|
+
sourceKey: Type.String(),
|
|
4590
|
+
bucketKey: Type.Optional(Type.String()),
|
|
4591
|
+
params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
4592
|
+
filterKey: Type.Optional(Type.String()),
|
|
4593
|
+
lifetime: Type.Optional(subscriptionLifetimeSchema),
|
|
4594
|
+
reason: Type.Optional(Type.String())
|
|
4595
|
+
});
|
|
4393
4596
|
const entitiesRegisterBodySchema = Type.Object({ tags: Type.Optional(stringRecordSchema) });
|
|
4394
4597
|
const entitiesRouter = Router({ base: `/_electric/entities` });
|
|
4395
4598
|
entitiesRouter.get(`/`, listEntities);
|
|
@@ -4398,6 +4601,7 @@ entitiesRouter.put(`/:type/:instanceId`, withSpawnableEntityType, withSchema(spa
|
|
|
4398
4601
|
entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity);
|
|
4399
4602
|
entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity);
|
|
4400
4603
|
entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity);
|
|
4604
|
+
entitiesRouter.post(`/:type/:instanceId/signal`, withExistingEntity, withSchema(signalBodySchema), signalEntity);
|
|
4401
4605
|
entitiesRouter.post(`/:type/:instanceId/send`, withExistingEntity, withSchema(sendBodySchema), sendEntity);
|
|
4402
4606
|
entitiesRouter.patch(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, withSchema(inboxMessageBodySchema), updateInboxMessage);
|
|
4403
4607
|
entitiesRouter.delete(`/:type/:instanceId/inbox/:messageKey`, withExistingEntity, deleteInboxMessage);
|
|
@@ -4406,6 +4610,8 @@ entitiesRouter.post(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, withS
|
|
|
4406
4610
|
entitiesRouter.delete(`/:type/:instanceId/tags/:tagKey`, withExistingEntity, removeTag);
|
|
4407
4611
|
entitiesRouter.put(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, withSchema(scheduleBodySchema), upsertSchedule);
|
|
4408
4612
|
entitiesRouter.delete(`/:type/:instanceId/schedules/:scheduleId`, withExistingEntity, deleteSchedule);
|
|
4613
|
+
entitiesRouter.put(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, withSchema(eventSourceSubscriptionBodySchema), upsertEventSourceSubscription);
|
|
4614
|
+
entitiesRouter.delete(`/:type/:instanceId/event-source-subscriptions/:subscriptionId`, withExistingEntity, deleteEventSourceSubscription);
|
|
4409
4615
|
function entityUrlFromSegments(type, instanceId) {
|
|
4410
4616
|
if (!type || !instanceId) return null;
|
|
4411
4617
|
if (type.startsWith(`_`) || type.includes(`*`) || instanceId.includes(`*`)) return null;
|
|
@@ -4512,6 +4718,47 @@ async function deleteSchedule(request, ctx) {
|
|
|
4512
4718
|
const result = await ctx.entityManager.deleteSchedule(entityUrl, { id: decodeURIComponent(request.params.scheduleId) });
|
|
4513
4719
|
return json(result);
|
|
4514
4720
|
}
|
|
4721
|
+
async function upsertEventSourceSubscription(request, ctx) {
|
|
4722
|
+
const principalMutationError = rejectPrincipalEntityMutation(request, `subscribed to event sources`);
|
|
4723
|
+
if (principalMutationError) return principalMutationError;
|
|
4724
|
+
const catalog = ctx.eventSources;
|
|
4725
|
+
if (!catalog) return apiError(404, ErrCodeNotFound, `No event source catalog is configured`);
|
|
4726
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
4727
|
+
const parsed = routeBody(request);
|
|
4728
|
+
const source = await catalog.getEventSource(parsed.sourceKey);
|
|
4729
|
+
if (!source) return apiError(404, ErrCodeNotFound, `Event source "${parsed.sourceKey}" not found`);
|
|
4730
|
+
if (parsed.lifetime?.kind === `expires_at`) {
|
|
4731
|
+
const expiresAt = new Date(parsed.lifetime.at);
|
|
4732
|
+
if (Number.isNaN(expiresAt.getTime())) return apiError(400, ErrCodeInvalidRequest, `Invalid expires_at lifetime timestamp`);
|
|
4733
|
+
}
|
|
4734
|
+
let resolved;
|
|
4735
|
+
try {
|
|
4736
|
+
resolved = resolveEventSourceSubscription({
|
|
4737
|
+
contract: source,
|
|
4738
|
+
entityUrl,
|
|
4739
|
+
request: {
|
|
4740
|
+
...parsed,
|
|
4741
|
+
id: decodeURIComponent(request.params.subscriptionId)
|
|
4742
|
+
},
|
|
4743
|
+
createdBy: `tool`
|
|
4744
|
+
});
|
|
4745
|
+
} catch (error) {
|
|
4746
|
+
return apiError(400, ErrCodeInvalidRequest, error instanceof Error ? error.message : String(error));
|
|
4747
|
+
}
|
|
4748
|
+
await ctx.ensureEventSourceWakeSource?.(resolved.subscription.sourceUrl);
|
|
4749
|
+
const result = await ctx.entityManager.upsertEventSourceSubscription(entityUrl, {
|
|
4750
|
+
subscription: resolved.subscription,
|
|
4751
|
+
manifest: buildEventSourceManifestEntry(resolved)
|
|
4752
|
+
});
|
|
4753
|
+
return json(result);
|
|
4754
|
+
}
|
|
4755
|
+
async function deleteEventSourceSubscription(request, ctx) {
|
|
4756
|
+
const principalMutationError = rejectPrincipalEntityMutation(request, `unsubscribed from event sources`);
|
|
4757
|
+
if (principalMutationError) return principalMutationError;
|
|
4758
|
+
const { entityUrl } = requireExistingEntityRoute(request);
|
|
4759
|
+
const result = await ctx.entityManager.deleteEventSourceSubscription(entityUrl, { id: decodeURIComponent(request.params.subscriptionId) });
|
|
4760
|
+
return json(result);
|
|
4761
|
+
}
|
|
4515
4762
|
async function setTag(request, ctx) {
|
|
4516
4763
|
const principalMutationError = rejectPrincipalEntityMutation(request, `tagged`);
|
|
4517
4764
|
if (principalMutationError) return principalMutationError;
|
|
@@ -4601,11 +4848,13 @@ async function spawnEntity(request, ctx) {
|
|
|
4601
4848
|
wake: parsed.wake,
|
|
4602
4849
|
created_by: principal.url
|
|
4603
4850
|
});
|
|
4604
|
-
|
|
4851
|
+
const linkBeforeInitialMessage = parsed.initialMessage !== void 0 && shouldLinkDispatchBeforeInitialMessage(dispatchPolicy);
|
|
4852
|
+
if (linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
|
|
4605
4853
|
if (parsed.initialMessage !== void 0) await ctx.entityManager.send(entity.url, {
|
|
4606
4854
|
from: principal.url,
|
|
4607
4855
|
payload: parsed.initialMessage
|
|
4608
4856
|
});
|
|
4857
|
+
if (!linkBeforeInitialMessage) await linkEntityDispatchSubscription(ctx, entity);
|
|
4609
4858
|
return json({
|
|
4610
4859
|
...toPublicEntity(entity),
|
|
4611
4860
|
txid: entity.txid
|
|
@@ -4629,6 +4878,22 @@ async function killEntity(request, ctx) {
|
|
|
4629
4878
|
ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
|
|
4630
4879
|
return json(result);
|
|
4631
4880
|
}
|
|
4881
|
+
async function signalEntity(request, ctx) {
|
|
4882
|
+
const principalMutationError = rejectPrincipalEntityMutation(request, `signaled`);
|
|
4883
|
+
if (principalMutationError) return principalMutationError;
|
|
4884
|
+
const parsed = routeBody(request);
|
|
4885
|
+
const { entityUrl, entity } = requireExistingEntityRoute(request);
|
|
4886
|
+
const result = await ctx.entityManager.signal(entityUrl, {
|
|
4887
|
+
signal: parsed.signal,
|
|
4888
|
+
reason: parsed.reason,
|
|
4889
|
+
payload: parsed.payload
|
|
4890
|
+
});
|
|
4891
|
+
if (result.new_state === `stopped` || result.new_state === `killed`) {
|
|
4892
|
+
await unlinkEntityDispatchSubscription(ctx, entity);
|
|
4893
|
+
ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main);
|
|
4894
|
+
}
|
|
4895
|
+
return json(result);
|
|
4896
|
+
}
|
|
4632
4897
|
|
|
4633
4898
|
//#endregion
|
|
4634
4899
|
//#region src/routing/entity-types-router.ts
|
|
@@ -5109,7 +5374,7 @@ async function notificationFromClaim(ctx, input) {
|
|
|
5109
5374
|
const primaryStream = withLeadingSlash(primary.path);
|
|
5110
5375
|
const entity = await ctx.entityManager.registry.getEntityByStream(primaryStream);
|
|
5111
5376
|
if (!entity) throw new ElectricAgentsError(ErrCodeNotFound, `Claim stream is not attached to an entity`, 404);
|
|
5112
|
-
if (entity.status === `stopped`) {
|
|
5377
|
+
if (entity.status === `stopped` || entity.status === `paused`) {
|
|
5113
5378
|
await ctx.streamClient.releaseSubscription(input.subscriptionId, input.claim.token, {
|
|
5114
5379
|
wake_id: input.claim.wake_id,
|
|
5115
5380
|
generation: input.claim.generation
|
|
@@ -5208,6 +5473,7 @@ const callbackForwardBodySchema = Type.Object({
|
|
|
5208
5473
|
const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`;
|
|
5209
5474
|
const internalRouter = Router({ base: `/_electric` });
|
|
5210
5475
|
internalRouter.get(`/health`, () => json({ status: `ok` }));
|
|
5476
|
+
internalRouter.get(`/event-sources`, listEventSources);
|
|
5211
5477
|
internalRouter.post(`/wake`, withSchema(wakeRegistrationBodySchema), registerWake);
|
|
5212
5478
|
internalRouter.post(`/webhook-forward/:subscriptionId`, webhookForward);
|
|
5213
5479
|
internalRouter.post(`/callback-forward/:consumerId`, callbackForward);
|
|
@@ -5311,6 +5577,13 @@ async function registerWake(request, ctx) {
|
|
|
5311
5577
|
await ctx.entityManager.registerWake(opts);
|
|
5312
5578
|
return status(204);
|
|
5313
5579
|
}
|
|
5580
|
+
async function listEventSources(_request, ctx) {
|
|
5581
|
+
const eventSources = ctx.eventSources ? await ctx.eventSources.listEventSources() : [];
|
|
5582
|
+
return json({ eventSources: eventSources.filter(isAgentVisibleEventSource) });
|
|
5583
|
+
}
|
|
5584
|
+
function isAgentVisibleEventSource(source) {
|
|
5585
|
+
return source.agentVisible === true && source.status === `active`;
|
|
5586
|
+
}
|
|
5314
5587
|
async function webhookForward(request, ctx) {
|
|
5315
5588
|
const subscriptionId = routeParam(request, `subscriptionId`);
|
|
5316
5589
|
const rootSpan = getRequestSpan(request);
|
|
@@ -5377,7 +5650,7 @@ async function webhookForward(request, ctx) {
|
|
|
5377
5650
|
serverLog.warn(`[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
5378
5651
|
}) : void 0;
|
|
5379
5652
|
const [entity, enriched] = await Promise.all([entityPromise, enrichPromise]);
|
|
5380
|
-
if (entity?.status === `stopped`) {
|
|
5653
|
+
if (entity?.status === `stopped` || entity?.status === `paused`) {
|
|
5381
5654
|
if (upsertPromise) await upsertPromise;
|
|
5382
5655
|
return json({ done: true });
|
|
5383
5656
|
}
|
|
@@ -5520,9 +5793,9 @@ async function callbackForward(request, ctx) {
|
|
|
5520
5793
|
entityCleared = result?.entityCleared ?? false;
|
|
5521
5794
|
}
|
|
5522
5795
|
if (entity && (entityCleared || stillOwnsClaim)) {
|
|
5523
|
-
await ctx.entityManager.registry.updateStatus(entity.url, `idle`);
|
|
5796
|
+
await ctx.entityManager.registry.updateStatus(entity.url, entity.status === `stopping` ? `stopped` : `idle`);
|
|
5524
5797
|
await ctx.entityBridgeManager.onEntityChanged(entity.url);
|
|
5525
|
-
serverLog.info(`[callback-forward] status updated
|
|
5798
|
+
serverLog.info(`[callback-forward] status updated after done for ${entity.url}`);
|
|
5526
5799
|
} else if (!entity) serverLog.warn(`[callback-forward] done received but no entity found for stream=${target.primaryStream}`);
|
|
5527
5800
|
if (stillOwnsClaim) ctx.runtime.claimWriteTokens.clearStream(ctx.service, target.primaryStream);
|
|
5528
5801
|
else if (entity) serverLog.info(`[callback-forward] done arrived after in-memory token evicted (stream=${target.primaryStream} consumer=${consumerId})`);
|
|
@@ -6875,7 +7148,8 @@ var ElectricAgentsTenantRuntime = class {
|
|
|
6875
7148
|
const primaryStream = `${entityUrl}/main`;
|
|
6876
7149
|
const callbacks = await this.db.select().from(consumerCallbacks).where(and(eq(consumerCallbacks.tenantId, this.serviceId), eq(consumerCallbacks.primaryStream, primaryStream))).limit(1);
|
|
6877
7150
|
if (callbacks.length > 0) return;
|
|
6878
|
-
await this.manager.registry.
|
|
7151
|
+
const entity = await this.manager.registry.getEntity(entityUrl);
|
|
7152
|
+
await this.manager.registry.updateStatus(entityUrl, entity?.status === `stopping` ? `stopped` : `idle`);
|
|
6879
7153
|
await this.entityBridgeManager.onEntityChanged(entityUrl);
|
|
6880
7154
|
}
|
|
6881
7155
|
};
|
|
@@ -7890,6 +8164,8 @@ var ElectricAgentsServer = class {
|
|
|
7890
8164
|
streamClient: this.streamClient,
|
|
7891
8165
|
runtime: this.standaloneRuntime.runtime,
|
|
7892
8166
|
entityBridgeManager: this.entityBridgeManager,
|
|
8167
|
+
...this.options.eventSources ? { eventSources: this.options.eventSources } : {},
|
|
8168
|
+
...this.options.ensureEventSourceWakeSource ? { ensureEventSourceWakeSource: this.options.ensureEventSourceWakeSource } : {},
|
|
7893
8169
|
isShuttingDown: () => this.shuttingDown,
|
|
7894
8170
|
mockAgent: this.mockAgentBootstrap ? { runtime: this.mockAgentBootstrap.runtime } : void 0
|
|
7895
8171
|
};
|