Skip to content

Commit 5d35ef3

Browse files
committed
Fix agents quickstart runner registration
1 parent 5c60589 commit 5d35ef3

22 files changed

Lines changed: 864 additions & 147 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"electric-ax": patch
3+
"@electric-ax/agents": patch
4+
"@electric-ax/agents-runtime": patch
5+
"@electric-ax/agents-server-ui": patch
6+
---
7+
8+
Fix Electric Agents quickstart startup by authenticating the built-in pull-wake runner with the same principal it registers as, registering built-in agent types with the local runner as their default dispatch target, and aligning the CLI's default principal with the local quickstart user. Pin the CLI-launched agents-server Docker image to the matching released agents-server version, improve registration fetch errors so startup failures include the endpoint and underlying cause, avoid the CLI observe live-query Collection boundary, and clarify browser-only credentials settings copy.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"packageManager": "pnpm@10.12.1",
99
"private": true,
1010
"scripts": {
11-
"ci:publish": "pnpm '/^ci:publish:.+/' && pnpm exec changeset tag",
11+
"ci:publish": "pnpm run -r --filter './packages/**' build && pnpm '/^ci:publish:.+/' && pnpm exec changeset tag",
1212
"ci:publish:hex-electric": "pnpm run --dir packages/sync-service publish:hex",
1313
"ci:publish:hex-electric-client": "pnpm run --dir packages/elixir-client publish:hex",
1414
"ci:publish:npm": "pnpm publish -r --filter './packages/**' --no-git-checks --access public",

packages/agents-runtime/src/create-handler.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -561,14 +561,23 @@ export function createRuntimeRouter(
561561
}
562562
}
563563

564-
const typeRes = await fetch(
565-
appendPathToUrl(baseUrl, `/_electric/entity-types`),
566-
{
564+
const registrationUrl = appendPathToUrl(
565+
baseUrl,
566+
`/_electric/entity-types`
567+
)
568+
let typeRes: Response
569+
try {
570+
typeRes = await fetch(registrationUrl, {
567571
method: `POST`,
568572
headers: await registrationHeaders(),
569573
body: JSON.stringify(body),
570-
}
571-
)
574+
})
575+
} catch (error) {
576+
const message = `Failed to register type "${name}" at ${registrationUrl}: ${formatFetchError(error)}`
577+
runtimeLog.error(`[agent-runtime]`, message)
578+
failed.push(`${name} (${message})`)
579+
return
580+
}
572581

573582
if (!typeRes.ok) {
574583
const err = await typeRes.text()
@@ -750,6 +759,17 @@ function json(body: Record<string, unknown>, status: number): Response {
750759
})
751760
}
752761

762+
function formatFetchError(error: unknown): string {
763+
if (error instanceof Error) {
764+
const cause =
765+
error.cause instanceof Error
766+
? `: ${error.cause.message || error.cause.name}`
767+
: ``
768+
return `${error.message || error.name || `Unknown error`}${cause}`
769+
}
770+
return String(error) || `Unknown error`
771+
}
772+
753773
async function toFetchRequest(req: IncomingMessage): Promise<Request> {
754774
const body = await readBody(req)
755775
const host =

packages/agents-runtime/src/entity-timeline.ts

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
sum,
1616
toArray,
1717
} from '@durable-streams/state/db'
18-
import { caseWhen } from '@tanstack/db'
18+
import { BasicIndex, caseWhen } from '@tanstack/db'
1919
import type {
2020
Collection,
2121
InitialQueryBuilder,
@@ -1316,6 +1316,42 @@ const getEntitySignalsCollection = cachedCollectionFactory(
13161316

13171317
type EntityTimelineQueryBuilder = (q: InitialQueryBuilder) => QueryBuilder<any>
13181318

1319+
const indexedTimelineDbs = new WeakSet<object>()
1320+
1321+
function createIndexIfAvailable(
1322+
collection: unknown,
1323+
index: (row: any) => unknown
1324+
): void {
1325+
if (
1326+
collection &&
1327+
typeof (collection as { createIndex?: unknown }).createIndex === `function`
1328+
) {
1329+
const indexedCollection = collection as {
1330+
createIndex: (
1331+
index: (row: any) => unknown,
1332+
config: { indexType: typeof BasicIndex }
1333+
) => void
1334+
}
1335+
indexedCollection.createIndex(index, { indexType: BasicIndex })
1336+
}
1337+
}
1338+
1339+
export function ensureEntityTimelineIndexes(db: EntityStreamDB): void {
1340+
if (indexedTimelineDbs.has(db as object)) return
1341+
indexedTimelineDbs.add(db as object)
1342+
1343+
createIndexIfAvailable(db.collections.texts, (row) => row.run_id)
1344+
createIndexIfAvailable(db.collections.textDeltas, (row) => row.text_id)
1345+
createIndexIfAvailable(db.collections.toolCalls, (row) => row.run_id)
1346+
createIndexIfAvailable(db.collections.reasoning, (row) => row.run_id)
1347+
createIndexIfAvailable(
1348+
db.collections.reasoningDeltas,
1349+
(row) => row.reasoning_id
1350+
)
1351+
createIndexIfAvailable(db.collections.steps, (row) => row.run_id)
1352+
createIndexIfAvailable(db.collections.errors, (row) => row.run_id)
1353+
}
1354+
13191355
/**
13201356
* Builds a live timeline query for an entity stream.
13211357
*
@@ -1339,6 +1375,7 @@ export function createEntityTimelineQuery(
13391375
db: EntityStreamDB,
13401376
opts: EntityTimelineQueryOptions = {}
13411377
): EntityTimelineQueryBuilder {
1378+
ensureEntityTimelineIndexes(db)
13421379
return (q: InitialQueryBuilder) => buildEntityTimelineQuery(q, db, opts)
13431380
}
13441381

@@ -1441,18 +1478,15 @@ function buildEntityTimelineQuery(
14411478
text: caseWhen(text.key, {
14421479
key: text.key,
14431480
run_id: text.run_id,
1444-
order: coalesce(text._timeline_order, `~`),
1481+
order: coalesce(text._seq, -1),
14451482
status: text.status,
14461483
}),
14471484
textContent: concat(
14481485
toArray(
14491486
q
14501487
.from({ textChunk: db.collections.textDeltas })
14511488
.where(({ textChunk }) => eq(textChunk.text_id, text.key))
1452-
.orderBy(({ textChunk }) =>
1453-
coalesce(textChunk._timeline_order, `~`)
1454-
)
1455-
.orderBy(({ textChunk }) => textChunk.key)
1489+
.orderBy(({ textChunk }) => coalesce(textChunk._seq, -1))
14561490
.select(({ textChunk }) => textChunk.delta)
14571491
)
14581492
),
@@ -1486,7 +1520,7 @@ function buildEntityTimelineQuery(
14861520
.select(({ reasoning }) => ({
14871521
key: reasoning.key,
14881522
run_id: reasoning.run_id,
1489-
order: coalesce(reasoning._timeline_order, `~`),
1523+
order: coalesce(reasoning._seq, -1),
14901524
status: reasoning.status,
14911525
summary_title: reasoning.summary_title,
14921526
encrypted: reasoning.encrypted,
@@ -1497,10 +1531,7 @@ function buildEntityTimelineQuery(
14971531
.where(({ reasoningChunk }) =>
14981532
eq(reasoningChunk.reasoning_id, reasoning.key)
14991533
)
1500-
.orderBy(({ reasoningChunk }) =>
1501-
coalesce(reasoningChunk._timeline_order, `~`)
1502-
)
1503-
.orderBy(({ reasoningChunk }) => reasoningChunk.key)
1534+
.orderBy(({ reasoningChunk }) => coalesce(reasoningChunk._seq, -1))
15041535
.select(({ reasoningChunk }) => reasoningChunk.delta)
15051536
)
15061537
),
@@ -1667,6 +1698,7 @@ type EntityQueryBuilder = (q: InitialQueryBuilder) => QueryBuilder<any>
16671698
export function createEntityIncludesQuery(
16681699
db: EntityStreamDB
16691700
): EntityQueryBuilder {
1701+
ensureEntityTimelineIndexes(db)
16701702
const seedCollection = getTimelineSeedCollection(db)
16711703
const runsCollection = getEntityRunsCollection(db)
16721704
const inboxCollection = getEntityInboxCollection(db)

packages/agents-runtime/test/create-handler.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,23 @@ describe(`createRuntimeHandler`, () => {
648648
)
649649
})
650650

651+
it(`registerTypes includes URL and cause when registration fetch throws`, async () => {
652+
defineEntity(`network-agent`, { handler: async () => {} })
653+
654+
vi.spyOn(globalThis, `fetch`).mockRejectedValue(
655+
new Error(`fetch failed`, { cause: new Error(`ECONNREFUSED 127.0.0.1`) })
656+
)
657+
658+
const handler = createRuntimeHandler({
659+
baseUrl: `http://localhost:3000`,
660+
handlerUrl: `http://localhost:4000/electric-agents`,
661+
})
662+
663+
await expect(handler.registerTypes()).rejects.toThrow(
664+
`Failed to register type "network-agent" at http://localhost:3000/_electric/entity-types: fetch failed: ECONNREFUSED 127.0.0.1`
665+
)
666+
})
667+
651668
it(`registers entity types with a webhook default dispatch policy`, async () => {
652669
defineEntity(`schema-agent`, {
653670
description: `Schema agent`,

packages/agents-runtime/test/entity-timeline.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,6 +1835,61 @@ describe(`entity includes query`, () => {
18351835
})
18361836
})
18371837

1838+
it(`interleaves top-level live timeline rows by runtime timeline order`, async () => {
1839+
const { collections, sync } = createEntityCollections()
1840+
const liveQuery = createLiveQueryCollection({
1841+
query: createEntityTimelineQuery({ collections } as any),
1842+
startSync: true,
1843+
})
1844+
await liveQuery.preload()
1845+
1846+
sync.inbox.insert({
1847+
key: `msg-1`,
1848+
_timeline_order: order(1),
1849+
from: `user`,
1850+
payload: `first`,
1851+
timestamp: `2026-04-15T18:00:00.000Z`,
1852+
status: `processed`,
1853+
})
1854+
sync.runs.insert({
1855+
key: `run-1`,
1856+
_timeline_order: order(2),
1857+
status: `started`,
1858+
})
1859+
sync.texts.insert({
1860+
key: `text-1`,
1861+
_timeline_order: order(3),
1862+
run_id: `run-1`,
1863+
status: `streaming`,
1864+
})
1865+
sync.textDeltas.insert({
1866+
key: `td-1`,
1867+
text_id: `text-1`,
1868+
run_id: `run-1`,
1869+
delta: `First response`,
1870+
})
1871+
sync.inbox.insert({
1872+
key: `msg-2`,
1873+
_timeline_order: order(4),
1874+
from: `user`,
1875+
payload: `second`,
1876+
timestamp: `2026-04-15T18:01:00.000Z`,
1877+
status: `processed`,
1878+
})
1879+
await new Promise((r) => setTimeout(r, 50))
1880+
1881+
const rows = getData(liveQuery)
1882+
expect(
1883+
rows.map((row) =>
1884+
row.inbox
1885+
? `inbox:${row.inbox.key}`
1886+
: row.run
1887+
? `run:${row.run.key}`
1888+
: `other:${row.$key}`
1889+
)
1890+
).toEqual([`inbox:msg-1`, `run:run-1`, `inbox:msg-2`])
1891+
})
1892+
18381893
it(`reacts to toolCall updates`, async () => {
18391894
const { collections, sync } = createEntityCollections()
18401895
const queryFn = createEntityIncludesQuery({ collections } as any)
@@ -2497,6 +2552,51 @@ describe(`entity includes query`, () => {
24972552
expect(item.text?.content).toBe(`Hello world`)
24982553
})
24992554

2555+
it(`live items.text.content orders deltas by stream sequence, not key`, async () => {
2556+
// Production regression: delta keys like `msg-0:10` sort before
2557+
// `msg-0:2` lexicographically. The live query must use stream
2558+
// sequence order so streamed text stays in model output order.
2559+
const { collections, sync } = createTimelineCollections()
2560+
const liveQuery = createLiveQueryCollection({
2561+
query: createEntityTimelineQuery({ collections } as any),
2562+
startSync: true,
2563+
})
2564+
await liveQuery.preload()
2565+
2566+
sync.runs.insert({ key: `run-0`, status: `started` })
2567+
sync.texts.insert({
2568+
key: `msg-0`,
2569+
run_id: `run-0`,
2570+
status: `streaming`,
2571+
})
2572+
for (let i = 0; i <= 10; i++) {
2573+
sync.textDeltas.insert({
2574+
key: `msg-0:${i}`,
2575+
text_id: `msg-0`,
2576+
run_id: `run-0`,
2577+
delta: `[${i}]`,
2578+
})
2579+
}
2580+
sync.texts.update({
2581+
key: `msg-0`,
2582+
run_id: `run-0`,
2583+
status: `completed`,
2584+
})
2585+
sync.runs.update({
2586+
key: `run-0`,
2587+
status: `completed`,
2588+
finish_reason: `stop`,
2589+
})
2590+
await new Promise((r) => setTimeout(r, 50))
2591+
2592+
const rows = getRows(liveQuery)
2593+
const runRow = rows.find((r) => r.run?.key === `run-0`)
2594+
expect(runRow).toBeTruthy()
2595+
const items = Array.from(runRow.run.items.toArray) as Array<any>
2596+
expect(items).toHaveLength(1)
2597+
expect(items[0].text?.content).toBe(`[0][1][2][3][4][5][6][7][8][9][10]`)
2598+
})
2599+
25002600
it(`reasoning content survives multiple run-row updates in sequence`, async () => {
25012601
// Even closer to production: the run row gets updated MULTIPLE
25022602
// times (each delta + status flip), which may invalidate the

packages/agents-server-ui/src/components/settings/pages/CredentialsPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,13 @@ export function CredentialsPage(): React.ReactElement {
8181
description={
8282
isDesktop
8383
? `Configure model providers for connected local runtimes. Changes save automatically and apply on the next runtime restart.`
84-
: `Model providers are configured by the agents-server you're connected to. The web build inherits whatever providers the server was started with.`
84+
: `Model providers are configured by the runtime connected to this server. Restart that runtime with provider credentials to change the available models.`
8585
}
8686
>
8787
{!isDesktop ? (
8888
<SettingsPanel>
8989
<Text size={2} tone="muted">
90-
No editable provider keys in the web build.
90+
Provider keys are not editable from the browser UI.
9191
</Text>
9292
</SettingsPanel>
9393
) : !status ? (

packages/agents/src/server.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,9 @@ export class BuiltinAgentsServer {
309309
baseSkillsDir: this.options.baseSkillsDir,
310310
enabledModelValues: this.options.enabledModelValues,
311311
serverHeaders: pullWake.headers,
312+
defaultDispatchPolicyForType: () => ({
313+
targets: [{ type: `runner`, runnerId: pullWake.runnerId }],
314+
}),
312315
})
313316
if (!this.bootstrap) {
314317
throw new Error(
@@ -428,9 +431,13 @@ export class BuiltinAgentsServer {
428431
)
429432
headers.set(`content-type`, `application/json`)
430433
const profiles = this.bootstrap?.runtime.sandboxProfileDescriptors ?? []
431-
const response = await fetch(
432-
appendPathToUrl(this.options.agentServerUrl, `/_electric/runners`),
433-
{
434+
const registrationUrl = appendPathToUrl(
435+
this.options.agentServerUrl,
436+
`/_electric/runners`
437+
)
438+
let response: Response
439+
try {
440+
response = await fetch(registrationUrl, {
434441
method: `POST`,
435442
headers,
436443
body: JSON.stringify({
@@ -441,8 +448,12 @@ export class BuiltinAgentsServer {
441448
admin_status: `enabled`,
442449
sandbox_profiles: profiles,
443450
}),
444-
}
445-
)
451+
})
452+
} catch (error) {
453+
throw new Error(
454+
`Failed to register pull-wake runner ${pullWake.runnerId} at ${registrationUrl}: ${formatFetchError(error)}`
455+
)
456+
}
446457
if (!response.ok) {
447458
throw new Error(
448459
`Failed to register pull-wake runner ${pullWake.runnerId}: ${response.status} ${await response.text()}`
@@ -451,3 +462,14 @@ export class BuiltinAgentsServer {
451462
return (await response.json()) as { wake_stream_offset?: string }
452463
}
453464
}
465+
466+
function formatFetchError(error: unknown): string {
467+
if (error instanceof Error) {
468+
const cause =
469+
error.cause instanceof Error
470+
? `: ${error.cause.message || error.cause.name}`
471+
: ``
472+
return `${error.message || error.name || `Unknown error`}${cause}`
473+
}
474+
return String(error) || `Unknown error`
475+
}

0 commit comments

Comments
 (0)