@electric-ax/agents-server 0.4.16 → 0.4.18
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 +169 -12
- package/dist/index.cjs +168 -11
- package/dist/index.d.cts +304 -236
- package/dist/index.d.ts +304 -236
- package/dist/index.js +169 -12
- package/drizzle/0014_entity_type_slash_commands.sql +1 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/src/db/schema.ts +1 -0
- package/src/electric-agents-types.ts +4 -0
- package/src/entity-manager.ts +210 -12
- package/src/entity-registry.ts +7 -0
- package/src/routing/entities-router.ts +122 -1
- package/src/routing/entity-types-router.ts +23 -0
- package/src/utils/server-utils.ts +1 -1
package/src/entity-manager.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto'
|
|
2
2
|
import fastq from 'fastq'
|
|
3
3
|
import {
|
|
4
|
+
COMPOSER_INPUT_MESSAGE_TYPE,
|
|
4
5
|
assertTags,
|
|
5
6
|
entityStateSchema,
|
|
6
7
|
getCronStreamPath,
|
|
@@ -11,6 +12,8 @@ import {
|
|
|
11
12
|
manifestSharedStateKey,
|
|
12
13
|
manifestSourceKey,
|
|
13
14
|
resolveCronScheduleSpec,
|
|
15
|
+
validateComposerInputPayload,
|
|
16
|
+
validateSlashCommandDefinitions,
|
|
14
17
|
} from '@electric-ax/agents-runtime'
|
|
15
18
|
import type { EventPointer } from '@electric-ax/agents-runtime'
|
|
16
19
|
import {
|
|
@@ -172,6 +175,38 @@ type ForkSubtreeOptions = {
|
|
|
172
175
|
* `main` stream; shared-state streams clone at HEAD regardless.
|
|
173
176
|
*/
|
|
174
177
|
forkPointer?: EventPointer
|
|
178
|
+
/**
|
|
179
|
+
* Named server-resolved anchor. Resolves to a concrete `forkPointer`
|
|
180
|
+
* by reading the source root's `main` history. Mutually exclusive with
|
|
181
|
+
* `forkPointer`. Use this when the caller doesn't have access to the
|
|
182
|
+
* source's per-row pointer side-table (e.g. an agent forking a session
|
|
183
|
+
* via a tool).
|
|
184
|
+
*
|
|
185
|
+
* - `latest_completed_run` — the most recent `runs` row on the source
|
|
186
|
+
* with `status === 'completed'`. Errors if no such row exists. This
|
|
187
|
+
* is the eligibility rule the per-row "Fork from here" UI uses.
|
|
188
|
+
*/
|
|
189
|
+
anchor?: `latest_completed_run`
|
|
190
|
+
/**
|
|
191
|
+
* Optional parent URL for the new root fork entity. When set, the
|
|
192
|
+
* new fork is a CHILD of this URL — its `parent` field is overridden
|
|
193
|
+
* (rather than inherited from the source, which is `null` for the
|
|
194
|
+
* only allowed source: a top-level entity). Pairs with `wake` to give
|
|
195
|
+
* the parent reply-delivery the same way spawn does.
|
|
196
|
+
*/
|
|
197
|
+
parent?: string
|
|
198
|
+
/**
|
|
199
|
+
* Optional wake subscription registered on the new root fork at fork
|
|
200
|
+
* time. Same shape and semantics as `spawn`'s `wake` (see
|
|
201
|
+
* `TypedSpawnRequest.wake`). Typically set by the agent `fork` tool
|
|
202
|
+
* to wire reply delivery via the parent's manifest-anchored wake.
|
|
203
|
+
*/
|
|
204
|
+
wake?: TypedSpawnRequest[`wake`]
|
|
205
|
+
/**
|
|
206
|
+
* Optional tags stamped onto the new root fork entity in addition
|
|
207
|
+
* to those copied from the source. Mirrors `spawn`'s `tags`.
|
|
208
|
+
*/
|
|
209
|
+
tags?: Record<string, string>
|
|
175
210
|
}
|
|
176
211
|
|
|
177
212
|
type ForkEntityPlan = {
|
|
@@ -430,6 +465,7 @@ export class EntityManager {
|
|
|
430
465
|
this.validateSchema(req.creation_schema)
|
|
431
466
|
this.validateSchemaMap(req.inbox_schemas)
|
|
432
467
|
this.validateSchemaMap(req.state_schemas)
|
|
468
|
+
this.validateSlashCommands(req.slash_commands)
|
|
433
469
|
const defaultDispatchPolicy = req.default_dispatch_policy
|
|
434
470
|
? this.validateDispatchPolicy(req.default_dispatch_policy, {
|
|
435
471
|
label: `default_dispatch_policy`,
|
|
@@ -444,6 +480,7 @@ export class EntityManager {
|
|
|
444
480
|
creation_schema: req.creation_schema,
|
|
445
481
|
inbox_schemas: req.inbox_schemas,
|
|
446
482
|
state_schemas: req.state_schemas,
|
|
483
|
+
slash_commands: req.slash_commands,
|
|
447
484
|
serve_endpoint: req.serve_endpoint,
|
|
448
485
|
default_dispatch_policy: defaultDispatchPolicy,
|
|
449
486
|
revision: existing ? existing.revision + 1 : 1,
|
|
@@ -735,6 +772,19 @@ export class EntityManager {
|
|
|
735
772
|
createdEvent as Record<string, unknown>,
|
|
736
773
|
]
|
|
737
774
|
|
|
775
|
+
const slashCommandTimestamp = new Date().toISOString()
|
|
776
|
+
for (const command of entityType.slash_commands ?? []) {
|
|
777
|
+
const slashCommandEvent = entityStateSchema.slashCommands.insert({
|
|
778
|
+
key: command.name,
|
|
779
|
+
value: {
|
|
780
|
+
...command,
|
|
781
|
+
source: `static`,
|
|
782
|
+
updated_at: slashCommandTimestamp,
|
|
783
|
+
},
|
|
784
|
+
} as any)
|
|
785
|
+
initialEvents.push(slashCommandEvent as Record<string, unknown>)
|
|
786
|
+
}
|
|
787
|
+
|
|
738
788
|
if (req.initialMessage !== undefined) {
|
|
739
789
|
const msgNow = new Date().toISOString()
|
|
740
790
|
const inboxEvent = entityStateSchema.inbox.insert({
|
|
@@ -742,6 +792,7 @@ export class EntityManager {
|
|
|
742
792
|
value: {
|
|
743
793
|
from: req.created_by ?? req.parent ?? `spawn`,
|
|
744
794
|
payload: req.initialMessage,
|
|
795
|
+
message_type: req.initialMessageType,
|
|
745
796
|
timestamp: msgNow,
|
|
746
797
|
},
|
|
747
798
|
} as any)
|
|
@@ -890,6 +941,13 @@ export class EntityManager {
|
|
|
890
941
|
const writeEntityLocks = new Set<string>()
|
|
891
942
|
const writeStreamLocks = new Set<string>()
|
|
892
943
|
|
|
944
|
+
// Anchor-forks resolve to a concrete pointer server-side by reading the
|
|
945
|
+
// source's `main` history. Below this point the resolved pointer (or
|
|
946
|
+
// an explicit one from `opts.forkPointer`) drives the same code path —
|
|
947
|
+
// anchor handling collapses entirely into the pointer-fork flow.
|
|
948
|
+
const usePointerPath =
|
|
949
|
+
opts.forkPointer !== undefined || opts.anchor !== undefined
|
|
950
|
+
|
|
893
951
|
try {
|
|
894
952
|
// For pointer-forks we read the source root HISTORICALLY at a
|
|
895
953
|
// frozen offset, so concurrent activity on the root past the
|
|
@@ -901,7 +959,7 @@ export class EntityManager {
|
|
|
901
959
|
// those are HEAD-cloned and need a stable snapshot. For HEAD-forks
|
|
902
960
|
// the old all-idle requirement still applies.
|
|
903
961
|
let sourceTree: Array<ElectricAgentsEntity>
|
|
904
|
-
if (
|
|
962
|
+
if (usePointerPath) {
|
|
905
963
|
const rootEntity = await this.registry.getEntity(rootUrl)
|
|
906
964
|
if (!rootEntity) {
|
|
907
965
|
throw new ElectricAgentsError(
|
|
@@ -930,11 +988,11 @@ export class EntityManager {
|
|
|
930
988
|
)
|
|
931
989
|
}
|
|
932
990
|
|
|
933
|
-
// When forking at a pointer, pre-read the root's main,
|
|
934
|
-
// pointer against the source's true history, and
|
|
935
|
-
// root-at-pointer snapshot fragments. The pointer
|
|
936
|
-
// the root's `main` stream. Descendants kept by
|
|
937
|
-
// are forked at HEAD.
|
|
991
|
+
// When forking at a pointer (or anchor), pre-read the root's main,
|
|
992
|
+
// validate the pointer against the source's true history, and
|
|
993
|
+
// materialise the root-at-pointer snapshot fragments. The pointer
|
|
994
|
+
// only applies to the root's `main` stream. Descendants kept by
|
|
995
|
+
// the manifest filter are forked at HEAD.
|
|
938
996
|
//
|
|
939
997
|
// Pointer→position translation: the runtime mints pointers as
|
|
940
998
|
// `{ offset: previousBatchOffset, subOffset: itemIndex+1 }`, where
|
|
@@ -953,16 +1011,32 @@ export class EntityManager {
|
|
|
953
1011
|
sharedStateIds: Set<string>
|
|
954
1012
|
}
|
|
955
1013
|
| undefined
|
|
956
|
-
|
|
1014
|
+
let effectiveForkPointer: EventPointer | undefined = opts.forkPointer
|
|
1015
|
+
if (usePointerPath) {
|
|
957
1016
|
const sourceEvents = await this.streamClient.readJson<
|
|
958
1017
|
Record<string, unknown>
|
|
959
1018
|
>(sourceRoot.streams.main)
|
|
960
1019
|
const flat = sourceEvents.flatMap((item) =>
|
|
961
1020
|
Array.isArray(item) ? item : [item]
|
|
962
1021
|
) as Array<Record<string, unknown>>
|
|
1022
|
+
if (!effectiveForkPointer && opts.anchor === `latest_completed_run`) {
|
|
1023
|
+
effectiveForkPointer = this.resolveLatestCompletedRunPointer(
|
|
1024
|
+
flat,
|
|
1025
|
+
sourceRoot.streams.main
|
|
1026
|
+
)
|
|
1027
|
+
}
|
|
1028
|
+
if (!effectiveForkPointer) {
|
|
1029
|
+
// Defensive — would only fire on a future anchor variant we
|
|
1030
|
+
// forget to handle above.
|
|
1031
|
+
throw new ElectricAgentsError(
|
|
1032
|
+
ErrCodeInvalidRequest,
|
|
1033
|
+
`Internal: pointer-path fork with no resolved pointer`,
|
|
1034
|
+
500
|
|
1035
|
+
)
|
|
1036
|
+
}
|
|
963
1037
|
const target = this.resolveForkPointerTarget(
|
|
964
1038
|
flat,
|
|
965
|
-
|
|
1039
|
+
effectiveForkPointer,
|
|
966
1040
|
sourceRoot.streams.main
|
|
967
1041
|
)
|
|
968
1042
|
const filteredEvents = flat.slice(0, target)
|
|
@@ -994,7 +1068,7 @@ export class EntityManager {
|
|
|
994
1068
|
// subtree except the root) are HEAD-cloned, so they must be idle
|
|
995
1069
|
// before we read their snapshots. Wait+lock only those — the root
|
|
996
1070
|
// was skipped above.
|
|
997
|
-
if (
|
|
1071
|
+
if (usePointerPath) {
|
|
998
1072
|
const descendants = effectiveSubtree.filter(
|
|
999
1073
|
(entity) => entity.url !== sourceRoot.url
|
|
1000
1074
|
)
|
|
@@ -1047,6 +1121,28 @@ export class EntityManager {
|
|
|
1047
1121
|
opts.createdBy
|
|
1048
1122
|
)
|
|
1049
1123
|
|
|
1124
|
+
// Override fields on the new root fork's plan from caller opts.
|
|
1125
|
+
// Plumbed through from `forkBodySchema` — descendants in
|
|
1126
|
+
// `effectiveSubtree` keep what `buildForkEntityPlans` copied.
|
|
1127
|
+
//
|
|
1128
|
+
// - `parent`: child-fork relationship (vs the default inheritance
|
|
1129
|
+
// from the source, which is `null` for a top-level source).
|
|
1130
|
+
// - `tags`: merged on top of source tags (caller-supplied wins).
|
|
1131
|
+
const rootPlanForOverrides = entityPlans.find(
|
|
1132
|
+
(plan) => plan.source.url === rootUrl
|
|
1133
|
+
)
|
|
1134
|
+
if (rootPlanForOverrides) {
|
|
1135
|
+
if (opts.parent !== undefined) {
|
|
1136
|
+
rootPlanForOverrides.fork.parent = opts.parent
|
|
1137
|
+
}
|
|
1138
|
+
if (opts.tags !== undefined) {
|
|
1139
|
+
rootPlanForOverrides.fork.tags = {
|
|
1140
|
+
...(rootPlanForOverrides.fork.tags ?? {}),
|
|
1141
|
+
...opts.tags,
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1050
1146
|
this.addForkLocks(
|
|
1051
1147
|
this.forkWriteLockedEntities,
|
|
1052
1148
|
effectiveSubtree.map((entity) => entity.url),
|
|
@@ -1071,8 +1167,8 @@ export class EntityManager {
|
|
|
1071
1167
|
await this.streamClient.fork(
|
|
1072
1168
|
plan.fork.streams.main,
|
|
1073
1169
|
plan.source.streams.main,
|
|
1074
|
-
isRoot &&
|
|
1075
|
-
? { forkPointer:
|
|
1170
|
+
isRoot && effectiveForkPointer
|
|
1171
|
+
? { forkPointer: effectiveForkPointer }
|
|
1076
1172
|
: undefined
|
|
1077
1173
|
)
|
|
1078
1174
|
createdStreams.push(plan.fork.streams.main)
|
|
@@ -1127,6 +1223,30 @@ export class EntityManager {
|
|
|
1127
1223
|
createdEntities.push(plan.fork.url)
|
|
1128
1224
|
}
|
|
1129
1225
|
|
|
1226
|
+
// Register a wake subscription on the new root fork when the
|
|
1227
|
+
// caller asked for one. Mirrors the spawn flow's wake handling
|
|
1228
|
+
// (entity-manager.spawnInner). Rollback below already calls
|
|
1229
|
+
// `wakeRegistry.unregisterBySource(forkUrl)` for every created
|
|
1230
|
+
// entity, so this cleans up automatically on failure.
|
|
1231
|
+
if (opts.wake !== undefined) {
|
|
1232
|
+
const rootPlan = entityPlans.find(
|
|
1233
|
+
(plan) => plan.source.url === rootUrl
|
|
1234
|
+
)
|
|
1235
|
+
if (rootPlan) {
|
|
1236
|
+
await this.wakeRegistry.register({
|
|
1237
|
+
tenantId: this.tenantId,
|
|
1238
|
+
subscriberUrl: opts.wake.subscriberUrl,
|
|
1239
|
+
sourceUrl: rootPlan.fork.url,
|
|
1240
|
+
condition: opts.wake.condition,
|
|
1241
|
+
debounceMs: opts.wake.debounceMs,
|
|
1242
|
+
timeoutMs: opts.wake.timeoutMs,
|
|
1243
|
+
oneShot: false,
|
|
1244
|
+
includeResponse: opts.wake.includeResponse,
|
|
1245
|
+
manifestKey: opts.wake.manifestKey,
|
|
1246
|
+
})
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1130
1250
|
for (const plan of entityPlans) {
|
|
1131
1251
|
const manifests =
|
|
1132
1252
|
activeManifestsByEntity.get(plan.fork.url) ?? new Map()
|
|
@@ -1442,6 +1562,58 @@ export class EntityManager {
|
|
|
1442
1562
|
return positionAtAnchor + pointer.subOffset
|
|
1443
1563
|
}
|
|
1444
1564
|
|
|
1565
|
+
/**
|
|
1566
|
+
* Find an `EventPointer` that addresses the most recent `runs` row on
|
|
1567
|
+
* the source's `main` stream with `status === 'completed'`. Mirrors the
|
|
1568
|
+
* eligibility rule the per-row "Fork from here" UI applies — only
|
|
1569
|
+
* completed runs are valid fork anchors; `started`/`failed` rows are
|
|
1570
|
+
* skipped.
|
|
1571
|
+
*
|
|
1572
|
+
* The pointer is computed in the same coordinate system the runtime
|
|
1573
|
+
* mints pointers in (see entity-stream-db's onBeforeBatch): for a row
|
|
1574
|
+
* R in log entry E, the pointer is `{ offset: P, subOffset: K }` where
|
|
1575
|
+
* `P` is the END offset of the log entry preceding E (or `null` for
|
|
1576
|
+
* stream start) and `K` is R's 1-indexed position within E.
|
|
1577
|
+
*/
|
|
1578
|
+
private resolveLatestCompletedRunPointer(
|
|
1579
|
+
events: ReadonlyArray<Record<string, unknown>>,
|
|
1580
|
+
streamPath: string
|
|
1581
|
+
): EventPointer {
|
|
1582
|
+
let priorEntryOffset: string | null = null
|
|
1583
|
+
let currentEntryOffset: string | null = null
|
|
1584
|
+
let positionInEntry = 0
|
|
1585
|
+
let latest: EventPointer | null = null
|
|
1586
|
+
|
|
1587
|
+
for (const event of events) {
|
|
1588
|
+
const headers = isRecord(event.headers) ? event.headers : undefined
|
|
1589
|
+
const eventOffset =
|
|
1590
|
+
typeof headers?.offset === `string` ? headers.offset : null
|
|
1591
|
+
|
|
1592
|
+
if (eventOffset !== currentEntryOffset) {
|
|
1593
|
+
priorEntryOffset = currentEntryOffset
|
|
1594
|
+
currentEntryOffset = eventOffset
|
|
1595
|
+
positionInEntry = 0
|
|
1596
|
+
}
|
|
1597
|
+
positionInEntry++
|
|
1598
|
+
|
|
1599
|
+
if (event.type !== `run`) continue
|
|
1600
|
+
if (headers?.operation === `delete`) continue
|
|
1601
|
+
if (!isRecord(event.value)) continue
|
|
1602
|
+
if (event.value.status !== `completed`) continue
|
|
1603
|
+
|
|
1604
|
+
latest = { offset: priorEntryOffset, subOffset: positionInEntry }
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
if (!latest) {
|
|
1608
|
+
throw new ElectricAgentsError(
|
|
1609
|
+
ErrCodeInvalidRequest,
|
|
1610
|
+
`Source ${streamPath} has no completed run to fork from`,
|
|
1611
|
+
400
|
|
1612
|
+
)
|
|
1613
|
+
}
|
|
1614
|
+
return latest
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1445
1617
|
/**
|
|
1446
1618
|
* Compute the subset of `sourceTree` that survives the manifest filter
|
|
1447
1619
|
* applied at the root. After filtering the root's manifest at the fork
|
|
@@ -3512,7 +3684,9 @@ export class EntityManager {
|
|
|
3512
3684
|
creation_schema: existing.creation_schema,
|
|
3513
3685
|
inbox_schemas: mergedInbox,
|
|
3514
3686
|
state_schemas: mergedState,
|
|
3687
|
+
slash_commands: existing.slash_commands,
|
|
3515
3688
|
serve_endpoint: existing.serve_endpoint,
|
|
3689
|
+
default_dispatch_policy: existing.default_dispatch_policy,
|
|
3516
3690
|
revision: nextRevision,
|
|
3517
3691
|
created_at: existing.created_at,
|
|
3518
3692
|
updated_at: now,
|
|
@@ -3601,6 +3775,20 @@ export class EntityManager {
|
|
|
3601
3775
|
}
|
|
3602
3776
|
}
|
|
3603
3777
|
|
|
3778
|
+
private validateSlashCommands(input: unknown): void {
|
|
3779
|
+
const validationError = validateSlashCommandDefinitions(input)
|
|
3780
|
+
if (!validationError) {
|
|
3781
|
+
return
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
throw new ElectricAgentsError(
|
|
3785
|
+
ErrCodeSchemaValidationFailed,
|
|
3786
|
+
validationError.message,
|
|
3787
|
+
422,
|
|
3788
|
+
validationError.details
|
|
3789
|
+
)
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3604
3792
|
private async validateSendRequest(
|
|
3605
3793
|
entityUrl: string,
|
|
3606
3794
|
req: SendRequest
|
|
@@ -3617,7 +3805,17 @@ export class EntityManager {
|
|
|
3617
3805
|
)
|
|
3618
3806
|
}
|
|
3619
3807
|
|
|
3620
|
-
if (req.type
|
|
3808
|
+
if (req.type === COMPOSER_INPUT_MESSAGE_TYPE) {
|
|
3809
|
+
const valErr = validateComposerInputPayload(req.payload)
|
|
3810
|
+
if (valErr) {
|
|
3811
|
+
throw new ElectricAgentsError(
|
|
3812
|
+
ErrCodeSchemaValidationFailed,
|
|
3813
|
+
valErr.message,
|
|
3814
|
+
422,
|
|
3815
|
+
valErr.details
|
|
3816
|
+
)
|
|
3817
|
+
}
|
|
3818
|
+
} else if (req.type && entity.type) {
|
|
3621
3819
|
const { inboxSchemas } = await this.getEffectiveSchemas(entity)
|
|
3622
3820
|
if (inboxSchemas) {
|
|
3623
3821
|
const schema = inboxSchemas[req.type]
|
package/src/entity-registry.ts
CHANGED
|
@@ -633,6 +633,7 @@ export class PostgresRegistry {
|
|
|
633
633
|
creationSchema: et.creation_schema ?? null,
|
|
634
634
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
635
635
|
stateSchemas: et.state_schemas ?? null,
|
|
636
|
+
slashCommands: et.slash_commands ?? null,
|
|
636
637
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
637
638
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
638
639
|
revision: et.revision,
|
|
@@ -646,6 +647,7 @@ export class PostgresRegistry {
|
|
|
646
647
|
creationSchema: et.creation_schema ?? null,
|
|
647
648
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
648
649
|
stateSchemas: et.state_schemas ?? null,
|
|
650
|
+
slashCommands: et.slash_commands ?? null,
|
|
649
651
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
650
652
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
651
653
|
revision: et.revision,
|
|
@@ -668,6 +670,7 @@ export class PostgresRegistry {
|
|
|
668
670
|
creationSchema: et.creation_schema ?? null,
|
|
669
671
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
670
672
|
stateSchemas: et.state_schemas ?? null,
|
|
673
|
+
slashCommands: et.slash_commands ?? null,
|
|
671
674
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
672
675
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
673
676
|
revision: et.revision,
|
|
@@ -709,6 +712,7 @@ export class PostgresRegistry {
|
|
|
709
712
|
creationSchema: et.creation_schema ?? null,
|
|
710
713
|
inboxSchemas: et.inbox_schemas ?? null,
|
|
711
714
|
stateSchemas: et.state_schemas ?? null,
|
|
715
|
+
slashCommands: et.slash_commands ?? null,
|
|
712
716
|
serveEndpoint: et.serve_endpoint ?? null,
|
|
713
717
|
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
714
718
|
revision: et.revision,
|
|
@@ -1829,6 +1833,9 @@ export class PostgresRegistry {
|
|
|
1829
1833
|
state_schemas: row.stateSchemas as
|
|
1830
1834
|
| Record<string, Record<string, unknown>>
|
|
1831
1835
|
| undefined,
|
|
1836
|
+
slash_commands:
|
|
1837
|
+
(row.slashCommands as ElectricAgentsEntityType[`slash_commands`]) ??
|
|
1838
|
+
undefined,
|
|
1832
1839
|
serve_endpoint: row.serveEndpoint ?? undefined,
|
|
1833
1840
|
default_dispatch_policy:
|
|
1834
1841
|
(row.defaultDispatchPolicy as ElectricAgentsEntityType[`default_dispatch_policy`]) ??
|
|
@@ -135,6 +135,7 @@ const spawnBodySchema = Type.Object({
|
|
|
135
135
|
sandbox: Type.Optional(sandboxChoiceSchema),
|
|
136
136
|
initialMessage: Type.Optional(Type.Unknown()),
|
|
137
137
|
grants: Type.Optional(Type.Array(entityPermissionGrantInputSchema)),
|
|
138
|
+
initialMessageType: Type.Optional(Type.String()),
|
|
138
139
|
wake: Type.Optional(
|
|
139
140
|
Type.Object({
|
|
140
141
|
subscriberUrl: Type.String(),
|
|
@@ -186,6 +187,19 @@ function agentUrlPath(value: string): string {
|
|
|
186
187
|
}
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
async function hasValidAgentWriteToken(
|
|
191
|
+
request: AgentsRouteRequest,
|
|
192
|
+
ctx: TenantContext,
|
|
193
|
+
fromAgent: string
|
|
194
|
+
): Promise<boolean> {
|
|
195
|
+
const agentUrl = agentUrlPath(fromAgent)
|
|
196
|
+
const token = writeTokenFromRequest(request)
|
|
197
|
+
if (!token) return false
|
|
198
|
+
const agentEntity = await ctx.entityManager.registry.getEntity(agentUrl)
|
|
199
|
+
if (!agentEntity) return false
|
|
200
|
+
return ctx.entityManager.isValidWriteToken(agentEntity, token)
|
|
201
|
+
}
|
|
202
|
+
|
|
189
203
|
const inboxMessageBodySchema = Type.Object({
|
|
190
204
|
payload: Type.Optional(Type.Unknown()),
|
|
191
205
|
position: Type.Optional(Type.String()),
|
|
@@ -218,6 +232,41 @@ const forkBodySchema = Type.Object({
|
|
|
218
232
|
sub_offset: Type.Number(),
|
|
219
233
|
})
|
|
220
234
|
),
|
|
235
|
+
// Named server-resolved anchor. Resolves to a concrete fork pointer on
|
|
236
|
+
// the source root's `main` server-side, so callers don't need access
|
|
237
|
+
// to the source's per-row pointer side-table. Mutually exclusive with
|
|
238
|
+
// `fork_pointer`.
|
|
239
|
+
anchor: Type.Optional(Type.Literal(`latest_completed_run`)),
|
|
240
|
+
// Optional parent URL. When set, the new root fork is a CHILD of this
|
|
241
|
+
// URL (rather than inheriting the source's parent, which is `null`
|
|
242
|
+
// for the only allowed source — a top-level entity). Used by the
|
|
243
|
+
// agent `fork` tool to make a forking entity own its forks the same
|
|
244
|
+
// way a spawning entity owns its workers.
|
|
245
|
+
parent: Type.Optional(Type.String()),
|
|
246
|
+
// Optional wake subscription registered on the new root fork at fork
|
|
247
|
+
// time. Mirrors the `wake` field on spawn — the subscriber URL gets
|
|
248
|
+
// woken when the fork meets the named condition, with the response
|
|
249
|
+
// optionally inlined. Used to wire fork-as-child reply delivery
|
|
250
|
+
// through the same manifest-anchored mechanism spawn uses.
|
|
251
|
+
wake: Type.Optional(
|
|
252
|
+
Type.Object({
|
|
253
|
+
subscriberUrl: Type.String(),
|
|
254
|
+
condition: wakeConditionSchema,
|
|
255
|
+
debounceMs: Type.Optional(Type.Number()),
|
|
256
|
+
timeoutMs: Type.Optional(Type.Number()),
|
|
257
|
+
includeResponse: Type.Optional(Type.Boolean()),
|
|
258
|
+
manifestKey: Type.Optional(Type.String()),
|
|
259
|
+
})
|
|
260
|
+
),
|
|
261
|
+
// Optional initial inbox message for the new root fork. Folds
|
|
262
|
+
// fork+send into one round-trip for the caller. Not atomic with
|
|
263
|
+
// fork creation: the server sends it after the fork is created
|
|
264
|
+
// and dispatch-linked (see the `entityManager.send` call below),
|
|
265
|
+
// so a failed send can leave an idle dispatched fork.
|
|
266
|
+
initialMessage: Type.Optional(Type.Unknown()),
|
|
267
|
+
// Optional tags stamped on the new root fork entity in addition to
|
|
268
|
+
// those copied from the source. Mirrors spawn's `tags`.
|
|
269
|
+
tags: Type.Optional(stringRecordSchema),
|
|
221
270
|
})
|
|
222
271
|
|
|
223
272
|
const setTagBodySchema = Type.Object({
|
|
@@ -1087,8 +1136,57 @@ async function forkEntity(
|
|
|
1087
1136
|
if (principalMutationError) return principalMutationError
|
|
1088
1137
|
|
|
1089
1138
|
const parsed = routeBody<ForkBody>(request)
|
|
1139
|
+
if (parsed.fork_pointer && parsed.anchor) {
|
|
1140
|
+
return apiError(
|
|
1141
|
+
400,
|
|
1142
|
+
ErrCodeInvalidRequest,
|
|
1143
|
+
`fork_pointer and anchor are mutually exclusive`
|
|
1144
|
+
)
|
|
1145
|
+
}
|
|
1090
1146
|
const { entityUrl, entity } = requireExistingEntityRoute(request)
|
|
1091
1147
|
await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy)
|
|
1148
|
+
|
|
1149
|
+
// Validate `parent` and `wake.subscriberUrl` before forking — mirrors
|
|
1150
|
+
// the spawn route's parent-validation flow. Without these checks, a
|
|
1151
|
+
// direct HTTP caller could attach a fork under an arbitrary parent or
|
|
1152
|
+
// register a wake firing to an arbitrary subscriber.
|
|
1153
|
+
if (parsed.parent !== undefined) {
|
|
1154
|
+
const parent = await ctx.entityManager.registry.getEntity(parsed.parent)
|
|
1155
|
+
if (!parent) {
|
|
1156
|
+
return apiError(404, ErrCodeNotFound, `Parent entity not found`)
|
|
1157
|
+
}
|
|
1158
|
+
if (!(await canAccessEntity(ctx, parent, `spawn`, request as Request))) {
|
|
1159
|
+
return apiError(
|
|
1160
|
+
401,
|
|
1161
|
+
ErrCodeUnauthorized,
|
|
1162
|
+
`Principal is not allowed to spawn children from ${parent.url}`
|
|
1163
|
+
)
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
if (parsed.wake !== undefined) {
|
|
1167
|
+
// The only sensible target for a fork's wake is the new fork's
|
|
1168
|
+
// parent (so the parent gets woken when the fork's run finishes
|
|
1169
|
+
// — same model as spawn). Require parent + matching subscriber to
|
|
1170
|
+
// prevent a caller from registering a wake firing to an entity
|
|
1171
|
+
// they don't own. If subscriber-flexibility ever becomes a real
|
|
1172
|
+
// use case, replace this with a proper canAccessEntity check on
|
|
1173
|
+
// the subscriber.
|
|
1174
|
+
if (parsed.parent === undefined) {
|
|
1175
|
+
return apiError(
|
|
1176
|
+
400,
|
|
1177
|
+
ErrCodeInvalidRequest,
|
|
1178
|
+
`wake requires parent (the fork's wake fires to its parent)`
|
|
1179
|
+
)
|
|
1180
|
+
}
|
|
1181
|
+
if (parsed.wake.subscriberUrl !== parsed.parent) {
|
|
1182
|
+
return apiError(
|
|
1183
|
+
401,
|
|
1184
|
+
ErrCodeUnauthorized,
|
|
1185
|
+
`wake.subscriberUrl must match parent`
|
|
1186
|
+
)
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1092
1190
|
const result = await ctx.entityManager.forkSubtree(entityUrl, {
|
|
1093
1191
|
rootInstanceId: parsed.instance_id,
|
|
1094
1192
|
waitTimeoutMs: parsed.waitTimeoutMs,
|
|
@@ -1099,10 +1197,24 @@ async function forkEntity(
|
|
|
1099
1197
|
subOffset: parsed.fork_pointer.sub_offset,
|
|
1100
1198
|
},
|
|
1101
1199
|
}),
|
|
1200
|
+
...(parsed.anchor && { anchor: parsed.anchor }),
|
|
1201
|
+
...(parsed.parent !== undefined && { parent: parsed.parent }),
|
|
1202
|
+
...(parsed.wake !== undefined && { wake: parsed.wake }),
|
|
1203
|
+
...(parsed.tags !== undefined && { tags: parsed.tags }),
|
|
1102
1204
|
})
|
|
1103
1205
|
for (const forkedEntity of result.entities) {
|
|
1104
1206
|
await linkEntityDispatchSubscription(ctx, forkedEntity)
|
|
1105
1207
|
}
|
|
1208
|
+
// Deliver the initial message via entityManager.send AFTER the
|
|
1209
|
+
// dispatch subscription is linked — same ordering spawn uses. Sending
|
|
1210
|
+
// before linking would land the inbox row on the stream before the
|
|
1211
|
+
// dispatcher is subscribed, and the dispatcher would never pick it up.
|
|
1212
|
+
if (parsed.initialMessage !== undefined) {
|
|
1213
|
+
await ctx.entityManager.send(result.root.url, {
|
|
1214
|
+
from: parsed.parent ?? ctx.principal.url,
|
|
1215
|
+
payload: parsed.initialMessage,
|
|
1216
|
+
})
|
|
1217
|
+
}
|
|
1106
1218
|
return json(
|
|
1107
1219
|
{
|
|
1108
1220
|
root: toPublicEntity(result.root),
|
|
@@ -1137,7 +1249,14 @@ async function sendEntity(
|
|
|
1137
1249
|
}
|
|
1138
1250
|
if (parsed.from_agent !== undefined) {
|
|
1139
1251
|
const principalAgentUrl = agentUrlForPrincipal(principal)
|
|
1140
|
-
|
|
1252
|
+
const fromAgentUrl = agentUrlPath(parsed.from_agent)
|
|
1253
|
+
const matchesPrincipalAgent = fromAgentUrl === principalAgentUrl
|
|
1254
|
+
const hasAgentWriteToken = await hasValidAgentWriteToken(
|
|
1255
|
+
request,
|
|
1256
|
+
ctx,
|
|
1257
|
+
parsed.from_agent
|
|
1258
|
+
)
|
|
1259
|
+
if (!matchesPrincipalAgent && !hasAgentWriteToken) {
|
|
1141
1260
|
return apiError(
|
|
1142
1261
|
400,
|
|
1143
1262
|
ErrCodeInvalidRequest,
|
|
@@ -1293,6 +1412,7 @@ async function spawnEntity(
|
|
|
1293
1412
|
dispatch_policy: dispatchPolicy,
|
|
1294
1413
|
sandbox: parsed.sandbox,
|
|
1295
1414
|
initialMessage: undefined,
|
|
1415
|
+
initialMessageType: undefined,
|
|
1296
1416
|
wake: parsed.wake,
|
|
1297
1417
|
created_by: principal.url,
|
|
1298
1418
|
})
|
|
@@ -1325,6 +1445,7 @@ async function spawnEntity(
|
|
|
1325
1445
|
await ctx.entityManager.send(entity.url, {
|
|
1326
1446
|
from: principal.url,
|
|
1327
1447
|
payload: parsed.initialMessage,
|
|
1448
|
+
type: parsed.initialMessageType,
|
|
1328
1449
|
})
|
|
1329
1450
|
}
|
|
1330
1451
|
if (!linkBeforeInitialMessage) {
|
|
@@ -45,6 +45,27 @@ type PublicEntityTypeResponse = ElectricAgentsEntityType & {
|
|
|
45
45
|
|
|
46
46
|
const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown())
|
|
47
47
|
const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema)
|
|
48
|
+
const slashCommandArgumentSchema = Type.Object(
|
|
49
|
+
{
|
|
50
|
+
name: Type.String(),
|
|
51
|
+
type: Type.Union([
|
|
52
|
+
Type.Literal(`string`),
|
|
53
|
+
Type.Literal(`number`),
|
|
54
|
+
Type.Literal(`boolean`),
|
|
55
|
+
]),
|
|
56
|
+
required: Type.Optional(Type.Boolean()),
|
|
57
|
+
description: Type.Optional(Type.String()),
|
|
58
|
+
},
|
|
59
|
+
{ additionalProperties: false }
|
|
60
|
+
)
|
|
61
|
+
const slashCommandSchema = Type.Object(
|
|
62
|
+
{
|
|
63
|
+
name: Type.String(),
|
|
64
|
+
description: Type.Optional(Type.String()),
|
|
65
|
+
arguments: Type.Optional(Type.Array(slashCommandArgumentSchema)),
|
|
66
|
+
},
|
|
67
|
+
{ additionalProperties: false }
|
|
68
|
+
)
|
|
48
69
|
|
|
49
70
|
const typePermissionGrantInputSchema = Type.Object(
|
|
50
71
|
{
|
|
@@ -66,6 +87,7 @@ const registerEntityTypeBodySchema = Type.Object(
|
|
|
66
87
|
creation_schema: Type.Optional(jsonObjectSchema),
|
|
67
88
|
inbox_schemas: Type.Optional(schemaMapSchema),
|
|
68
89
|
state_schemas: Type.Optional(schemaMapSchema),
|
|
90
|
+
slash_commands: Type.Optional(Type.Array(slashCommandSchema)),
|
|
69
91
|
serve_endpoint: Type.Optional(Type.String()),
|
|
70
92
|
default_dispatch_policy: Type.Optional(dispatchPolicySchema),
|
|
71
93
|
permission_grants: Type.Optional(
|
|
@@ -433,6 +455,7 @@ function normalizeEntityTypeRequest(
|
|
|
433
455
|
creation_schema: parsed.creation_schema,
|
|
434
456
|
inbox_schemas: parsed.inbox_schemas,
|
|
435
457
|
state_schemas: parsed.state_schemas,
|
|
458
|
+
slash_commands: parsed.slash_commands,
|
|
436
459
|
serve_endpoint: serveEndpoint,
|
|
437
460
|
default_dispatch_policy:
|
|
438
461
|
parsed.default_dispatch_policy ??
|
|
@@ -135,7 +135,7 @@ export function buildElectricProxyTarget(options: {
|
|
|
135
135
|
} else if (table === `entity_types`) {
|
|
136
136
|
target.searchParams.set(
|
|
137
137
|
`columns`,
|
|
138
|
-
`"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`
|
|
138
|
+
`"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","slash_commands","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`
|
|
139
139
|
)
|
|
140
140
|
applyShapeWhere(
|
|
141
141
|
target,
|