@electric-ax/agents-server 0.3.0
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/LICENSE +177 -0
- package/dist/chunk-Cl8Af3a2.js +11 -0
- package/dist/entrypoint.js +7319 -0
- package/dist/index.cjs +7090 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4263 -0
- package/dist/index.js +7053 -0
- package/drizzle/0000_baseline.sql +97 -0
- package/drizzle/0001_entity_tags_and_bridges.sql +45 -0
- package/drizzle/0002_tag_outbox_hardening.sql +14 -0
- package/drizzle/0003_entity_manifest_sources.sql +11 -0
- package/drizzle/0004_tenant_scoping.sql +139 -0
- package/drizzle/0005_pull_wake_control_plane.sql +156 -0
- package/drizzle/meta/0000_snapshot.json +593 -0
- package/drizzle/meta/_journal.json +48 -0
- package/package.json +89 -0
- package/src/authenticated-user-format.ts +17 -0
- package/src/claim-write-token-store.ts +74 -0
- package/src/db/index.ts +53 -0
- package/src/db/schema.ts +490 -0
- package/src/dev-asserted-auth.ts +46 -0
- package/src/dispatch-policy-schema.ts +52 -0
- package/src/electric-agents/adapter-types.ts +70 -0
- package/src/electric-agents/default-entity-schemas.ts +1 -0
- package/src/electric-agents/schema-validator.ts +143 -0
- package/src/electric-agents-http.ts +46 -0
- package/src/electric-agents-types.ts +335 -0
- package/src/entity-bridge-manager.ts +694 -0
- package/src/entity-manager.ts +2601 -0
- package/src/entity-projector.ts +765 -0
- package/src/entity-registry.ts +1162 -0
- package/src/entrypoint-lib.ts +295 -0
- package/src/entrypoint.ts +11 -0
- package/src/host.ts +323 -0
- package/src/index.ts +49 -0
- package/src/manifest-side-effects.ts +183 -0
- package/src/routing/agent-ui-router.ts +81 -0
- package/src/routing/context.ts +35 -0
- package/src/routing/cron-router.ts +45 -0
- package/src/routing/dispatch-policy.ts +248 -0
- package/src/routing/durable-streams-router.ts +407 -0
- package/src/routing/durable-streams-routing-adapter.ts +96 -0
- package/src/routing/electric-proxy-router.ts +61 -0
- package/src/routing/entities-router.ts +484 -0
- package/src/routing/entity-types-router.ts +229 -0
- package/src/routing/global-router.ts +33 -0
- package/src/routing/hooks.ts +123 -0
- package/src/routing/internal-router.ts +741 -0
- package/src/routing/oss-server-router.ts +56 -0
- package/src/routing/runners-router.ts +416 -0
- package/src/routing/schema.ts +141 -0
- package/src/routing/stream-append.ts +196 -0
- package/src/routing/tenant-stream-paths.ts +26 -0
- package/src/runtime-registry.ts +49 -0
- package/src/runtime.ts +537 -0
- package/src/scheduler.ts +788 -0
- package/src/schema-validation.ts +15 -0
- package/src/server.ts +374 -0
- package/src/standalone-runtime.ts +188 -0
- package/src/stream-client.ts +842 -0
- package/src/tag-stream-outbox-drainer.ts +188 -0
- package/src/tenant.ts +25 -0
- package/src/tracing.ts +57 -0
- package/src/utils/electric-url.ts +15 -0
- package/src/utils/log.ts +95 -0
- package/src/utils/server-utils.ts +245 -0
- package/src/utils/webhook-url.ts +33 -0
- package/src/wake-registry.ts +946 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCronStreamPathFromSpec,
|
|
3
|
+
getSharedStateStreamPath,
|
|
4
|
+
resolveCronScheduleSpec,
|
|
5
|
+
} from '@electric-ax/agents-runtime'
|
|
6
|
+
import type { WakeRegistration } from './wake-registry.js'
|
|
7
|
+
|
|
8
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return typeof value === `object` && value !== null && !Array.isArray(value)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function extractManifestSourceUrl(
|
|
13
|
+
manifest: Record<string, unknown> | undefined
|
|
14
|
+
): string | undefined {
|
|
15
|
+
if (!manifest) return undefined
|
|
16
|
+
|
|
17
|
+
if (manifest.kind === `child` || manifest.kind === `observe`) {
|
|
18
|
+
return typeof manifest.entity_url === `string`
|
|
19
|
+
? manifest.entity_url
|
|
20
|
+
: undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (manifest.kind === `source`) {
|
|
24
|
+
const config = isRecord(manifest.config) ? manifest.config : undefined
|
|
25
|
+
|
|
26
|
+
if (manifest.sourceType === `entity`) {
|
|
27
|
+
return typeof config?.entityUrl === `string`
|
|
28
|
+
? config.entityUrl
|
|
29
|
+
: typeof manifest.sourceRef === `string`
|
|
30
|
+
? manifest.sourceRef
|
|
31
|
+
: undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (manifest.sourceType === `cron` && config) {
|
|
35
|
+
const expression = config.expression
|
|
36
|
+
if (typeof expression === `string`) {
|
|
37
|
+
return getCronStreamPathFromSpec(
|
|
38
|
+
resolveCronScheduleSpec(
|
|
39
|
+
expression,
|
|
40
|
+
typeof config.timezone === `string` ? config.timezone : undefined,
|
|
41
|
+
{ fallback: `utc` }
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (manifest.sourceType === `entities`) {
|
|
48
|
+
return typeof manifest.sourceRef === `string`
|
|
49
|
+
? `/_entities/${manifest.sourceRef}`
|
|
50
|
+
: undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (manifest.sourceType === `db`) {
|
|
54
|
+
return typeof manifest.sourceRef === `string`
|
|
55
|
+
? getSharedStateStreamPath(manifest.sourceRef)
|
|
56
|
+
: undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (manifest.kind === `shared-state`) {
|
|
63
|
+
return typeof manifest.id === `string`
|
|
64
|
+
? getSharedStateStreamPath(manifest.id)
|
|
65
|
+
: undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (
|
|
69
|
+
manifest.kind === `schedule` &&
|
|
70
|
+
manifest.scheduleType === `cron` &&
|
|
71
|
+
typeof manifest.expression === `string`
|
|
72
|
+
) {
|
|
73
|
+
return getCronStreamPathFromSpec(
|
|
74
|
+
resolveCronScheduleSpec(
|
|
75
|
+
manifest.expression,
|
|
76
|
+
typeof manifest.timezone === `string` ? manifest.timezone : undefined,
|
|
77
|
+
{ fallback: `utc` }
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function extractManifestCronSpec(
|
|
86
|
+
manifest: Record<string, unknown> | undefined
|
|
87
|
+
): { expression: string; timezone: string } | undefined {
|
|
88
|
+
if (!manifest) return undefined
|
|
89
|
+
|
|
90
|
+
if (manifest.kind === `source` && manifest.sourceType === `cron`) {
|
|
91
|
+
const config = isRecord(manifest.config) ? manifest.config : undefined
|
|
92
|
+
if (typeof config?.expression === `string`) {
|
|
93
|
+
return resolveCronScheduleSpec(
|
|
94
|
+
config.expression,
|
|
95
|
+
typeof config.timezone === `string` ? config.timezone : undefined,
|
|
96
|
+
{ fallback: `utc` }
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
manifest.kind === `schedule` &&
|
|
103
|
+
manifest.scheduleType === `cron` &&
|
|
104
|
+
typeof manifest.expression === `string`
|
|
105
|
+
) {
|
|
106
|
+
return resolveCronScheduleSpec(
|
|
107
|
+
manifest.expression,
|
|
108
|
+
typeof manifest.timezone === `string` ? manifest.timezone : undefined,
|
|
109
|
+
{ fallback: `utc` }
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildManifestWakeRegistration(
|
|
117
|
+
subscriberUrl: string,
|
|
118
|
+
manifest: Record<string, unknown> | undefined,
|
|
119
|
+
manifestKey?: string
|
|
120
|
+
): WakeRegistration | null {
|
|
121
|
+
if (!manifest) return null
|
|
122
|
+
|
|
123
|
+
const sourceUrl = extractManifestSourceUrl(manifest)
|
|
124
|
+
if (!sourceUrl) return null
|
|
125
|
+
|
|
126
|
+
const wake =
|
|
127
|
+
manifest.kind === `schedule` && manifest.scheduleType === `cron`
|
|
128
|
+
? (manifest.wake ?? { on: `change` })
|
|
129
|
+
: manifest.wake
|
|
130
|
+
|
|
131
|
+
if (wake === `runFinished`) {
|
|
132
|
+
return {
|
|
133
|
+
subscriberUrl,
|
|
134
|
+
sourceUrl,
|
|
135
|
+
condition: `runFinished`,
|
|
136
|
+
oneShot: false,
|
|
137
|
+
manifestKey,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!isRecord(wake)) return null
|
|
142
|
+
|
|
143
|
+
if (wake.on === `runFinished`) {
|
|
144
|
+
return {
|
|
145
|
+
subscriberUrl,
|
|
146
|
+
sourceUrl,
|
|
147
|
+
condition: `runFinished`,
|
|
148
|
+
oneShot: false,
|
|
149
|
+
includeResponse:
|
|
150
|
+
typeof wake.includeResponse === `boolean`
|
|
151
|
+
? wake.includeResponse
|
|
152
|
+
: undefined,
|
|
153
|
+
manifestKey,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (wake.on !== `change`) return null
|
|
158
|
+
|
|
159
|
+
const collections = Array.isArray(wake.collections)
|
|
160
|
+
? wake.collections.filter((c): c is string => typeof c === `string`)
|
|
161
|
+
: undefined
|
|
162
|
+
const ops = Array.isArray(wake.ops)
|
|
163
|
+
? wake.ops.filter(
|
|
164
|
+
(op): op is `insert` | `update` | `delete` =>
|
|
165
|
+
op === `insert` || op === `update` || op === `delete`
|
|
166
|
+
)
|
|
167
|
+
: undefined
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
subscriberUrl,
|
|
171
|
+
sourceUrl,
|
|
172
|
+
condition: {
|
|
173
|
+
on: `change`,
|
|
174
|
+
...(collections ? { collections } : {}),
|
|
175
|
+
...(ops ? { ops } : {}),
|
|
176
|
+
},
|
|
177
|
+
debounceMs:
|
|
178
|
+
typeof wake.debounceMs === `number` ? wake.debounceMs : undefined,
|
|
179
|
+
timeoutMs: typeof wake.timeoutMs === `number` ? wake.timeoutMs : undefined,
|
|
180
|
+
oneShot: false,
|
|
181
|
+
manifestKey,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serves the bundled agent UI from packages/agents-server-ui/dist.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
import { readFile } from 'node:fs/promises'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { Router, status } from 'itty-router'
|
|
10
|
+
import { apiError } from '../electric-agents-http.js'
|
|
11
|
+
import { ErrCodeAgentUiNotFound } from '../electric-agents-types.js'
|
|
12
|
+
import {
|
|
13
|
+
cacheControlForAgentUiFile,
|
|
14
|
+
contentTypeForStaticFile,
|
|
15
|
+
pickAgentUiFile,
|
|
16
|
+
resolveAgentUiPath,
|
|
17
|
+
} from '../utils/server-utils.js'
|
|
18
|
+
import type { IRequest, RouterType } from 'itty-router'
|
|
19
|
+
import type { TenantContext } from './context.js'
|
|
20
|
+
|
|
21
|
+
function resolveAgentUiDistDir(fromUrl = import.meta.url): string {
|
|
22
|
+
const moduleDir = path.dirname(fileURLToPath(fromUrl))
|
|
23
|
+
const candidates = [
|
|
24
|
+
path.resolve(moduleDir, `../../../agents-server-ui/dist`),
|
|
25
|
+
path.resolve(moduleDir, `../../agents-server-ui/dist`),
|
|
26
|
+
path.resolve(process.cwd(), `packages/agents-server-ui/dist`),
|
|
27
|
+
]
|
|
28
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]!
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const AGENT_UI_DIST_DIR = resolveAgentUiDistDir()
|
|
32
|
+
|
|
33
|
+
export type AgentUiRoutes = RouterType<
|
|
34
|
+
IRequest,
|
|
35
|
+
[TenantContext],
|
|
36
|
+
Response | undefined
|
|
37
|
+
>
|
|
38
|
+
|
|
39
|
+
export const agentUiRouter: AgentUiRoutes = Router<
|
|
40
|
+
IRequest,
|
|
41
|
+
[TenantContext],
|
|
42
|
+
Response | undefined
|
|
43
|
+
>({
|
|
44
|
+
base: `/__agent_ui`,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
agentUiRouter.get(`/*`, serveAgentUi)
|
|
48
|
+
agentUiRouter.head(`/*`, serveAgentUi)
|
|
49
|
+
agentUiRouter.all(`*`, () => status(404))
|
|
50
|
+
|
|
51
|
+
async function serveAgentUi(request: IRequest): Promise<Response> {
|
|
52
|
+
const requestPath = new URL(request.url).pathname
|
|
53
|
+
const relativePath = decodeURIComponent(
|
|
54
|
+
requestPath.slice(`/__agent_ui/`.length)
|
|
55
|
+
)
|
|
56
|
+
const requestedFile = relativePath.length === 0 ? `index.html` : relativePath
|
|
57
|
+
const filePath = resolveAgentUiPath(AGENT_UI_DIST_DIR, requestedFile)
|
|
58
|
+
const fallbackToIndex =
|
|
59
|
+
path.extname(requestedFile) === `` || requestedFile.endsWith(`/`)
|
|
60
|
+
const resolvedFile = await pickAgentUiFile(
|
|
61
|
+
AGENT_UI_DIST_DIR,
|
|
62
|
+
filePath,
|
|
63
|
+
fallbackToIndex
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if (!resolvedFile) {
|
|
67
|
+
return apiError(
|
|
68
|
+
404,
|
|
69
|
+
ErrCodeAgentUiNotFound,
|
|
70
|
+
`Agent UI build artifacts are missing`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const body = request.method === `HEAD` ? null : await readFile(resolvedFile)
|
|
75
|
+
return new Response(body, {
|
|
76
|
+
headers: {
|
|
77
|
+
'content-type': contentTypeForStaticFile(resolvedFile),
|
|
78
|
+
'cache-control': cacheControlForAgentUiFile(resolvedFile),
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Agent } from 'undici'
|
|
2
|
+
import type { DrizzleDB } from '../db/index.js'
|
|
3
|
+
import type { EntityBridgeCoordinator } from '../entity-bridge-manager.js'
|
|
4
|
+
import type { EntityManager } from '../entity-manager.js'
|
|
5
|
+
import type { ElectricAgentsTenantRuntime } from '../runtime.js'
|
|
6
|
+
import type { StreamClient } from '../stream-client.js'
|
|
7
|
+
import type { DurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
|
|
8
|
+
import type { AuthenticatedRequestUser } from '../electric-agents-types.js'
|
|
9
|
+
import type { DurableStreamsBearerProvider } from '../stream-client.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Per-request tenant context passed through every router and handler.
|
|
13
|
+
*
|
|
14
|
+
* The OSS server builds this from its single runtime. Library-mode callers can
|
|
15
|
+
* build one per request and call `globalRouter.fetch(request, ctx)` directly.
|
|
16
|
+
*/
|
|
17
|
+
export interface TenantContext {
|
|
18
|
+
service: string
|
|
19
|
+
authenticatedUser?: AuthenticatedRequestUser
|
|
20
|
+
publicUrl: string
|
|
21
|
+
localUrl?: string
|
|
22
|
+
durableStreamsUrl: string
|
|
23
|
+
durableStreamsBearer?: DurableStreamsBearerProvider
|
|
24
|
+
durableStreamsRouting?: DurableStreamsRoutingAdapter
|
|
25
|
+
durableStreamsDispatcher: Agent
|
|
26
|
+
electricUrl?: string
|
|
27
|
+
electricSecret?: string
|
|
28
|
+
ownAgentHandlerPaths?: ReadonlyArray<string>
|
|
29
|
+
pgDb: DrizzleDB
|
|
30
|
+
entityManager: EntityManager
|
|
31
|
+
streamClient: StreamClient
|
|
32
|
+
runtime: ElectricAgentsTenantRuntime
|
|
33
|
+
entityBridgeManager: EntityBridgeCoordinator
|
|
34
|
+
isShuttingDown: () => boolean
|
|
35
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP routes under /_electric/cron.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type, type Static } from '@sinclair/typebox'
|
|
6
|
+
import { Router, json } from 'itty-router'
|
|
7
|
+
import { routeBody, withSchema } from './schema.js'
|
|
8
|
+
import type { JsonRouteRequest } from './schema.js'
|
|
9
|
+
import type { RouterType } from 'itty-router'
|
|
10
|
+
import type { TenantContext } from './context.js'
|
|
11
|
+
|
|
12
|
+
const cronRegisterBodySchema = Type.Object({
|
|
13
|
+
expression: Type.String(),
|
|
14
|
+
timezone: Type.Optional(Type.String()),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
type CronRegisterBody = Static<typeof cronRegisterBodySchema>
|
|
18
|
+
|
|
19
|
+
export type CronRoutes = RouterType<
|
|
20
|
+
JsonRouteRequest,
|
|
21
|
+
[TenantContext],
|
|
22
|
+
Response | undefined
|
|
23
|
+
>
|
|
24
|
+
|
|
25
|
+
export const cronRouter: CronRoutes = Router<
|
|
26
|
+
JsonRouteRequest,
|
|
27
|
+
[TenantContext],
|
|
28
|
+
Response | undefined
|
|
29
|
+
>({
|
|
30
|
+
base: `/_electric/cron`,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
cronRouter.post(`/register`, withSchema(cronRegisterBodySchema), registerCron)
|
|
34
|
+
|
|
35
|
+
async function registerCron(
|
|
36
|
+
request: JsonRouteRequest,
|
|
37
|
+
ctx: TenantContext
|
|
38
|
+
): Promise<Response> {
|
|
39
|
+
const parsed = routeBody<CronRegisterBody>(request)
|
|
40
|
+
const streamPath = await ctx.entityManager.getOrCreateCronStream(
|
|
41
|
+
parsed.expression,
|
|
42
|
+
parsed.timezone
|
|
43
|
+
)
|
|
44
|
+
return json({ streamUrl: streamPath })
|
|
45
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { appendPathToUrl } from '@electric-ax/agents-runtime'
|
|
3
|
+
import { subscriptionWebhooks } from '../db/schema.js'
|
|
4
|
+
import { ElectricAgentsError } from '../entity-manager.js'
|
|
5
|
+
import {
|
|
6
|
+
ErrCodeInvalidRequest,
|
|
7
|
+
ErrCodeNotFound,
|
|
8
|
+
ErrCodeUnauthorized,
|
|
9
|
+
} from '../electric-agents-types.js'
|
|
10
|
+
import { runnerWakeStream } from '../entity-registry.js'
|
|
11
|
+
import { rewriteLoopbackWebhookUrl } from '../utils/webhook-url.js'
|
|
12
|
+
import type {
|
|
13
|
+
DispatchPolicy,
|
|
14
|
+
DispatchTarget,
|
|
15
|
+
ElectricAgentsEntity,
|
|
16
|
+
} from '../electric-agents-types.js'
|
|
17
|
+
import type { TenantContext } from './context.js'
|
|
18
|
+
|
|
19
|
+
export function subscriptionIdForDispatchTarget(
|
|
20
|
+
target: DispatchTarget
|
|
21
|
+
): string {
|
|
22
|
+
if (target.subscription_id) return target.subscription_id
|
|
23
|
+
if (target.type === `runner`) return `runner:${target.runnerId}`
|
|
24
|
+
const digest = createHash(`sha256`).update(target.url).digest(`hex`)
|
|
25
|
+
return `webhook:${digest.slice(0, 16)}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function subscriptionIdForEntityDispatchTarget(
|
|
29
|
+
target: DispatchTarget,
|
|
30
|
+
entityUrl: string
|
|
31
|
+
): string {
|
|
32
|
+
const base = subscriptionIdForDispatchTarget(target)
|
|
33
|
+
if (!target.subscription_id) return base
|
|
34
|
+
const digest = createHash(`sha256`).update(entityUrl).digest(`hex`)
|
|
35
|
+
return `${base}:${digest.slice(0, 16)}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function resolveEffectiveDispatchPolicyForSpawn(
|
|
39
|
+
ctx: TenantContext,
|
|
40
|
+
typeName: string,
|
|
41
|
+
opts: { dispatchPolicy?: DispatchPolicy; parent?: string }
|
|
42
|
+
): Promise<DispatchPolicy | undefined> {
|
|
43
|
+
if (opts.dispatchPolicy) return opts.dispatchPolicy
|
|
44
|
+
const entityType = await ctx.entityManager.registry.getEntityType(typeName)
|
|
45
|
+
if (opts.parent) {
|
|
46
|
+
const parent = await ctx.entityManager.registry.getEntity(opts.parent)
|
|
47
|
+
if (parent?.dispatch_policy) {
|
|
48
|
+
return applyTypeDefaultSubscriptionScope(
|
|
49
|
+
parent.dispatch_policy,
|
|
50
|
+
entityType?.default_dispatch_policy
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return entityType?.default_dispatch_policy
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function resolveEffectiveDispatchPolicyForEntity(
|
|
58
|
+
ctx: TenantContext,
|
|
59
|
+
entity: ElectricAgentsEntity
|
|
60
|
+
): Promise<DispatchPolicy | undefined> {
|
|
61
|
+
if (entity.dispatch_policy) return entity.dispatch_policy
|
|
62
|
+
const entityType = await ctx.entityManager.registry.getEntityType(entity.type)
|
|
63
|
+
return entityType?.default_dispatch_policy
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function backfillEntityDispatchPolicy(
|
|
67
|
+
ctx: TenantContext,
|
|
68
|
+
entity: ElectricAgentsEntity
|
|
69
|
+
): Promise<ElectricAgentsEntity> {
|
|
70
|
+
if (entity.dispatch_policy) return entity
|
|
71
|
+
const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(
|
|
72
|
+
ctx,
|
|
73
|
+
entity
|
|
74
|
+
)
|
|
75
|
+
if (!dispatchPolicy) return entity
|
|
76
|
+
return (
|
|
77
|
+
(await ctx.entityManager.registry.updateEntityDispatchPolicy(
|
|
78
|
+
entity.url,
|
|
79
|
+
dispatchPolicy
|
|
80
|
+
)) ?? { ...entity, dispatch_policy: dispatchPolicy }
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function applyTypeDefaultSubscriptionScope(
|
|
85
|
+
policy: DispatchPolicy,
|
|
86
|
+
typeDefault: DispatchPolicy | undefined
|
|
87
|
+
): DispatchPolicy {
|
|
88
|
+
const target = policy.targets[0]
|
|
89
|
+
const defaultTarget = typeDefault?.targets[0]
|
|
90
|
+
if (!target || !defaultTarget?.subscription_id) return policy
|
|
91
|
+
if (!sameDispatchDestination(target, defaultTarget)) return policy
|
|
92
|
+
if (target.subscription_id === defaultTarget.subscription_id) return policy
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
targets: [{ ...target, subscription_id: defaultTarget.subscription_id }],
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sameDispatchDestination(
|
|
100
|
+
a: DispatchTarget,
|
|
101
|
+
b: DispatchTarget
|
|
102
|
+
): boolean {
|
|
103
|
+
if (a.type !== b.type) return false
|
|
104
|
+
if (a.type === `runner` && b.type === `runner`) {
|
|
105
|
+
return a.runnerId === b.runnerId
|
|
106
|
+
}
|
|
107
|
+
if (a.type === `webhook` && b.type === `webhook`) return a.url === b.url
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function assertDispatchPolicyAllowed(
|
|
112
|
+
ctx: TenantContext,
|
|
113
|
+
policy: DispatchPolicy | undefined
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const target = policy?.targets[0]
|
|
116
|
+
if (!target || target.type !== `runner`) return
|
|
117
|
+
|
|
118
|
+
const runner = await ctx.entityManager.registry.getRunner(target.runnerId)
|
|
119
|
+
if (!runner) {
|
|
120
|
+
throw new ElectricAgentsError(
|
|
121
|
+
ErrCodeNotFound,
|
|
122
|
+
`Runner "${target.runnerId}" not found`,
|
|
123
|
+
404
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
if (!ctx.authenticatedUser) {
|
|
127
|
+
throw new ElectricAgentsError(
|
|
128
|
+
ErrCodeUnauthorized,
|
|
129
|
+
`Authentication is required for runner-targeted dispatch`,
|
|
130
|
+
401
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
if (runner.owner_user_id !== ctx.authenticatedUser.userId) {
|
|
134
|
+
throw new ElectricAgentsError(
|
|
135
|
+
ErrCodeUnauthorized,
|
|
136
|
+
`Runner dispatch requires the authenticated owner`,
|
|
137
|
+
403
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function linkEntityDispatchSubscription(
|
|
143
|
+
ctx: TenantContext,
|
|
144
|
+
entity: ElectricAgentsEntity
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(
|
|
147
|
+
ctx,
|
|
148
|
+
entity
|
|
149
|
+
)
|
|
150
|
+
const target = dispatchPolicy?.targets[0]
|
|
151
|
+
if (!target) return
|
|
152
|
+
await linkStreamToTargetSubscription(ctx, target, entity)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function unlinkEntityDispatchSubscription(
|
|
156
|
+
ctx: TenantContext,
|
|
157
|
+
entity: ElectricAgentsEntity
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
const dispatchPolicy = await resolveEffectiveDispatchPolicyForEntity(
|
|
160
|
+
ctx,
|
|
161
|
+
entity
|
|
162
|
+
)
|
|
163
|
+
const target = dispatchPolicy?.targets[0]
|
|
164
|
+
if (!target) return
|
|
165
|
+
const subscriptionId = subscriptionIdForEntityDispatchTarget(
|
|
166
|
+
target,
|
|
167
|
+
entity.url
|
|
168
|
+
)
|
|
169
|
+
await ctx.streamClient
|
|
170
|
+
.removeSubscriptionStream(subscriptionId, entity.streams.main)
|
|
171
|
+
.catch(() => {})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function linkStreamToTargetSubscription(
|
|
175
|
+
ctx: TenantContext,
|
|
176
|
+
target: DispatchTarget,
|
|
177
|
+
entity: ElectricAgentsEntity
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
const streamPath = entity.streams.main
|
|
180
|
+
const subscriptionId = subscriptionIdForEntityDispatchTarget(
|
|
181
|
+
target,
|
|
182
|
+
entity.url
|
|
183
|
+
)
|
|
184
|
+
const existing = await ctx.streamClient.getSubscription(subscriptionId)
|
|
185
|
+
|
|
186
|
+
if (target.type === `runner`) {
|
|
187
|
+
const runner = await ctx.entityManager.registry.getRunner(target.runnerId)
|
|
188
|
+
if (!runner) {
|
|
189
|
+
throw new ElectricAgentsError(
|
|
190
|
+
ErrCodeNotFound,
|
|
191
|
+
`Runner "${target.runnerId}" not found`,
|
|
192
|
+
404
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
const wakeStream = runner.wake_stream || runnerWakeStream(target.runnerId)
|
|
196
|
+
await ctx.streamClient.ensure(wakeStream, {
|
|
197
|
+
contentType: `application/json`,
|
|
198
|
+
})
|
|
199
|
+
if (!existing) {
|
|
200
|
+
await ctx.streamClient.putSubscription(subscriptionId, {
|
|
201
|
+
type: `pull-wake`,
|
|
202
|
+
streams: [streamPath],
|
|
203
|
+
wake_stream: wakeStream,
|
|
204
|
+
description: `Electric Agents runner ${target.runnerId}`,
|
|
205
|
+
})
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath])
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const webhookUrl = rewriteLoopbackWebhookUrl(target.url)
|
|
213
|
+
if (!webhookUrl) {
|
|
214
|
+
throw new ElectricAgentsError(
|
|
215
|
+
ErrCodeInvalidRequest,
|
|
216
|
+
`Webhook dispatch target must include a valid URL`,
|
|
217
|
+
400
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
const forwardUrl = appendPathToUrl(
|
|
221
|
+
ctx.publicUrl,
|
|
222
|
+
`/_electric/webhook-forward/${encodeURIComponent(subscriptionId)}`
|
|
223
|
+
)
|
|
224
|
+
if (!existing) {
|
|
225
|
+
await ctx.streamClient.putSubscription(subscriptionId, {
|
|
226
|
+
type: `webhook`,
|
|
227
|
+
streams: [streamPath],
|
|
228
|
+
webhook: { url: forwardUrl },
|
|
229
|
+
description: `Electric Agents webhook ${subscriptionId}`,
|
|
230
|
+
})
|
|
231
|
+
} else {
|
|
232
|
+
await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath])
|
|
233
|
+
}
|
|
234
|
+
await ctx.pgDb
|
|
235
|
+
.insert(subscriptionWebhooks)
|
|
236
|
+
.values({
|
|
237
|
+
tenantId: ctx.service,
|
|
238
|
+
subscriptionId,
|
|
239
|
+
webhookUrl,
|
|
240
|
+
})
|
|
241
|
+
.onConflictDoUpdate({
|
|
242
|
+
target: [
|
|
243
|
+
subscriptionWebhooks.tenantId,
|
|
244
|
+
subscriptionWebhooks.subscriptionId,
|
|
245
|
+
],
|
|
246
|
+
set: { webhookUrl },
|
|
247
|
+
})
|
|
248
|
+
}
|