@astrale-os/cli 0.5.0-alpha.0 → 0.6.0-alpha.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/dist/astrale.js +12962 -487
- package/package.json +7 -3
- package/src/command.ts +2 -0
- package/src/commands/__tests__/admin-instance.test.ts +3 -2
- package/src/commands/__tests__/domain-list.test.ts +6 -2
- package/src/commands/__tests__/install-identity-override.test.ts +1 -1
- package/src/commands/__tests__/view.test.ts +100 -0
- package/src/commands/call.ts +1 -1
- package/src/commands/domain/install.ts +5 -5
- package/src/commands/domain/list.ts +2 -2
- package/src/commands/domain/publish.ts +3 -3
- package/src/commands/instance/active.ts +2 -2
- package/src/commands/instance/create.ts +1 -1
- package/src/commands/instance/delete.ts +3 -3
- package/src/commands/instance/list.ts +8 -3
- package/src/commands/instance/status.ts +4 -3
- package/src/commands/instance/use.ts +2 -2
- package/src/commands/query.ts +27 -8
- package/src/commands/session/analyze.ts +50 -0
- package/src/commands/session/list.ts +36 -0
- package/src/commands/token.ts +1 -1
- package/src/commands/view-serve.ts +26 -0
- package/src/commands/view.ts +614 -0
- package/src/connect-core.test.ts +42 -0
- package/src/connect-core.ts +53 -0
- package/src/kernel/__tests__/auth.test.ts +1 -0
- package/src/kernel/client.ts +30 -24
- package/src/kernel/expand.ts +3 -2
- package/src/kernel/index.ts +7 -1
- package/src/kernel/options.ts +1 -1
- package/src/lib/__tests__/instance-target.test.ts +34 -0
- package/src/lib/__tests__/view-open-intent.test.ts +308 -0
- package/src/lib/__tests__/view-snapshot.test.ts +77 -0
- package/src/lib/admin-domain.ts +8 -4
- package/src/lib/admin-instance.ts +5 -1
- package/src/lib/config.ts +1 -0
- package/src/lib/instance-target.ts +5 -0
- package/src/lib/instance.ts +11 -0
- package/src/lib/log.ts +12 -2
- package/src/lib/login-flow.ts +41 -4
- package/src/lib/provision-instance.ts +2 -2
- package/src/lib/view/open-intent.ts +97 -0
- package/src/lib/view/resolve.ts +104 -0
- package/src/lib/view/server.ts +294 -0
- package/src/lib/view/session.ts +123 -0
- package/src/lib/view/snapshot.ts +101 -0
- package/src/program.ts +12 -1
- package/src/registry.ts +1 -1
- package/src/setup/steps/instance.ts +2 -2
- package/src/telemetry/__tests__/gate.test.ts +63 -0
- package/src/telemetry/__tests__/recorder.test.ts +110 -0
- package/src/telemetry/__tests__/redact.test.ts +79 -0
- package/src/telemetry/__tests__/session.test.ts +166 -0
- package/src/telemetry/__tests__/trigger.test.ts +49 -0
- package/src/telemetry/adapters/__tests__/claude-code.test.ts +89 -0
- package/src/telemetry/adapters/__tests__/codex.test.ts +138 -0
- package/src/telemetry/adapters/__tests__/index.test.ts +88 -0
- package/src/telemetry/adapters/claude-code.ts +90 -0
- package/src/telemetry/adapters/codex.ts +160 -0
- package/src/telemetry/adapters/index.ts +45 -0
- package/src/telemetry/adapters/types.ts +21 -0
- package/src/telemetry/analyze.ts +227 -0
- package/src/telemetry/gate.ts +79 -0
- package/src/telemetry/recorder.ts +57 -0
- package/src/telemetry/redact.ts +53 -0
- package/src/telemetry/session.ts +88 -0
- package/src/telemetry/settings.ts +26 -0
- package/src/telemetry/store.ts +91 -0
- package/src/telemetry/trigger.ts +119 -0
- package/src/telemetry/types.ts +64 -0
- package/studio/client/dist/assets/index-CyN5G8IA.js +109 -0
- package/studio/client/dist/assets/index-DKKMHBBC.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.ts +2 -1
- package/studio/server/agent/runner.ts +4 -1
- package/studio/server/agent/session-id.ts +13 -0
- package/studio/server/api.ts +43 -25
- package/studio/server/cache.ts +17 -6
- package/studio/server/client-package.test.ts +147 -0
- package/studio/server/client-package.ts +242 -0
- package/studio/server/index.ts +13 -0
- package/studio/server/introspect/anatomy-extras.test.ts +91 -0
- package/studio/server/introspect/anatomy-extras.ts +48 -49
- package/studio/server/introspect/anatomy.ts +7 -7
- package/studio/server/introspect/overlay-tsmorph.test.ts +104 -0
- package/studio/server/introspect/overlay-tsmorph.ts +122 -22
- package/studio/server/state/views.test.ts +90 -0
- package/studio/server/state/views.ts +396 -99
- package/studio/server/state/visibility.ts +5 -5
- package/studio/server/view-dev-server.test.ts +111 -0
- package/studio/server/view-dev-server.ts +372 -0
- package/studio/shared/types.ts +57 -10
- package/viewer/dist/index.html +93 -0
- package/viewer/dist/main.js +71 -0
- package/studio/client/dist/assets/index-BcejyJpa.css +0 -1
- package/studio/client/dist/assets/index-Cqz3Oy_B.js +0 -179
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connect-core — the frozen, curated re-export surface the connect-host adapter
|
|
3
|
+
* borrows from the CLI. It is the ONE public seam between `@astrale-os/cli` and
|
|
4
|
+
* `@astrale-os/connect-host`; nothing else in cli/src is contract.
|
|
5
|
+
*
|
|
6
|
+
* Consumed by `@astrale-os/connect-host`; temps-2 seed of a future
|
|
7
|
+
* `@astrale-os/client-core`. Re-export ONLY — no logic lives here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── identity registry (~/.astrale/identities.json) ──────────────────────────
|
|
11
|
+
export { readIdentities, getDefault, getIdentity } from './lib/identity'
|
|
12
|
+
export type { Identity, IdentityStore } from './lib/identity'
|
|
13
|
+
|
|
14
|
+
// ── keypairs on disk + self-grant mint ──────────────────────────────────────
|
|
15
|
+
export { signAs, listIdentityKeys } from './lib/keys'
|
|
16
|
+
|
|
17
|
+
// ── instance bookmarks (~/.astrale/instances.json) ──────────────────────────
|
|
18
|
+
export {
|
|
19
|
+
readInstances,
|
|
20
|
+
resetInstancesMemo,
|
|
21
|
+
resolveInstance,
|
|
22
|
+
normalizeInstanceKernelUrl,
|
|
23
|
+
orgIdForAudience,
|
|
24
|
+
} from './lib/instance'
|
|
25
|
+
export type { InstanceEntry, InstanceStore, ResolvedInstance } from './lib/instance'
|
|
26
|
+
|
|
27
|
+
// ── instance-target resolution (bookmark | managed | admin | free url) ──────
|
|
28
|
+
export { resolveInstanceTarget } from './lib/instance-target'
|
|
29
|
+
export type { ResolvedInstanceTarget } from './lib/instance-target'
|
|
30
|
+
|
|
31
|
+
// ── global config (~/.astrale/config.json) ──────────────────────────────────
|
|
32
|
+
export { readConfig } from './lib/config'
|
|
33
|
+
export type { AstraleConfig } from './lib/config'
|
|
34
|
+
|
|
35
|
+
// ── credential resolution (self-grant + IdP) ────────────────────────────────
|
|
36
|
+
export { resolveCredential, IdpRefreshTransientError, IdpOrgMembershipError } from './kernel/auth'
|
|
37
|
+
|
|
38
|
+
// ── IdP login (device flow) — driven by connect-host's `auth.login` op ───────
|
|
39
|
+
export { loginViaIdp, resolveIdpName } from './lib/login-flow'
|
|
40
|
+
export type { LoginFlowOpts, LoginResult, DeviceVerification } from './lib/login-flow'
|
|
41
|
+
|
|
42
|
+
// ── IdP session state (so connect-host can flag "login required" rows) ───────
|
|
43
|
+
export { readIdpSession, isSessionExpired } from './lib/idp'
|
|
44
|
+
export type { IdpSession } from './lib/idp'
|
|
45
|
+
|
|
46
|
+
// ── typed errors ────────────────────────────────────────────────────────────
|
|
47
|
+
export { AuthError } from './errors'
|
|
48
|
+
|
|
49
|
+
// ── custom-CA fetch (private-CA local stacks) ───────────────────────────────
|
|
50
|
+
export { fetchWithCaFile } from './kernel/ca-fetch'
|
|
51
|
+
|
|
52
|
+
// ── path resolution (~/.astrale layout) ─────────────────────────────────────
|
|
53
|
+
export { paths, createPaths } from './lib/env'
|
package/src/kernel/client.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { AdminTargetCommandOpts } from '../lib/admin-target'
|
|
|
5
5
|
import type { KernelCommandOpts } from './types'
|
|
6
6
|
|
|
7
7
|
import { AstraleError } from '../errors'
|
|
8
|
-
import {
|
|
8
|
+
import { adminInstanceMethod, type InstanceInfo } from '../lib/admin-instance'
|
|
9
9
|
import { readConfig } from '../lib/config'
|
|
10
10
|
import { resolveInstanceTarget, type ResolvedInstanceTarget } from '../lib/instance-target'
|
|
11
11
|
import { resolveCredential } from './auth'
|
|
@@ -21,7 +21,7 @@ export type ClientContext = {
|
|
|
21
21
|
config: Awaited<ReturnType<typeof readConfig>>
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
type ResolvedKernelTarget = {
|
|
24
|
+
export type ResolvedKernelTarget = {
|
|
25
25
|
url: string
|
|
26
26
|
audience: string
|
|
27
27
|
slug?: string
|
|
@@ -29,44 +29,50 @@ type ResolvedKernelTarget = {
|
|
|
29
29
|
caFile?: string
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
* The new client is lazy: construction does no I/O. We only need to
|
|
35
|
-
* release sockets on the way out.
|
|
36
|
-
*/
|
|
37
|
-
export async function withKernelClient<T>(
|
|
32
|
+
/** Resolve the kernel a command targets (`--url` / `-i` / active instance). */
|
|
33
|
+
export async function resolveKernelTarget(
|
|
38
34
|
opts: KernelCommandOpts,
|
|
39
|
-
|
|
40
|
-
): Promise<
|
|
41
|
-
const config = await readConfig()
|
|
35
|
+
config: Awaited<ReturnType<typeof readConfig>>,
|
|
36
|
+
): Promise<ResolvedKernelTarget> {
|
|
42
37
|
// Ad-hoc `--url` — unknown kernel. Stamp the URL itself as audience,
|
|
43
38
|
// no slug for per-instance signing.
|
|
44
|
-
let target: ResolvedKernelTarget
|
|
45
39
|
if (opts.url && !opts.instance) {
|
|
46
40
|
const resolved = await resolveInstanceTarget(
|
|
47
41
|
{ source: 'url', url: opts.url },
|
|
48
42
|
{ config, admin: adminLookupOpts(opts) },
|
|
49
43
|
)
|
|
50
|
-
|
|
51
|
-
} else {
|
|
52
|
-
const resolved = await resolveInstanceTarget(
|
|
53
|
-
opts.instance ? { source: 'name', name: opts.instance } : { source: 'active' },
|
|
54
|
-
{
|
|
55
|
-
config,
|
|
56
|
-
admin: adminLookupOpts(opts),
|
|
57
|
-
managed: (slug) => lookupManagedInstance(slug, opts),
|
|
58
|
-
},
|
|
59
|
-
)
|
|
60
|
-
target = resolvedToKernelTarget(resolved, opts.url)
|
|
44
|
+
return resolvedToKernelTarget(resolved)
|
|
61
45
|
}
|
|
46
|
+
const resolved = await resolveInstanceTarget(
|
|
47
|
+
opts.instance ? { source: 'name', name: opts.instance } : { source: 'active' },
|
|
48
|
+
{
|
|
49
|
+
config,
|
|
50
|
+
admin: adminLookupOpts(opts),
|
|
51
|
+
managed: (slug) => lookupManagedInstance(slug, opts),
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
return resolvedToKernelTarget(resolved, opts.url)
|
|
55
|
+
}
|
|
62
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Connect to a kernel instance, run `fn`, then disconnect.
|
|
59
|
+
* The new client is lazy: construction does no I/O. We only need to
|
|
60
|
+
* release sockets on the way out.
|
|
61
|
+
*/
|
|
62
|
+
export async function withKernelClient<T>(
|
|
63
|
+
opts: KernelCommandOpts,
|
|
64
|
+
fn: (ctx: ClientContext) => Promise<T>,
|
|
65
|
+
): Promise<T> {
|
|
66
|
+
const config = await readConfig()
|
|
67
|
+
const target = await resolveKernelTarget(opts, config)
|
|
63
68
|
return withResolvedKernelClient(opts, config, target, fn)
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
async function lookupManagedInstance(slug: string, opts: KernelCommandOpts): Promise<InstanceInfo> {
|
|
67
72
|
return await withAdminKernelClient(
|
|
68
73
|
adminLookupOpts(opts),
|
|
69
|
-
async (ctx) =>
|
|
74
|
+
async (ctx) =>
|
|
75
|
+
(await ctx.client.call(adminInstanceMethod('info'), { id: slug })) as InstanceInfo,
|
|
70
76
|
)
|
|
71
77
|
}
|
|
72
78
|
|
package/src/kernel/expand.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { InstanceInfo } from '../lib/admin-instance'
|
|
2
2
|
import type { KernelCommandOpts } from './types'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { adminInstanceMethod } from '../lib/admin-instance'
|
|
5
5
|
/** CLI bridge for @self resolution and stale-registration error hints. */
|
|
6
6
|
import { readConfig } from '../lib/config'
|
|
7
7
|
import { getDefault, getIdentity, setRegistration } from '../lib/identity'
|
|
@@ -95,7 +95,8 @@ function adminLookupOpts(opts: KernelCommandOpts): KernelCommandOpts {
|
|
|
95
95
|
async function lookupManagedInstance(slug: string, opts: KernelCommandOpts): Promise<InstanceInfo> {
|
|
96
96
|
return await withAdminKernelClient(
|
|
97
97
|
adminLookupOpts(opts),
|
|
98
|
-
async (ctx) =>
|
|
98
|
+
async (ctx) =>
|
|
99
|
+
(await ctx.client.call(adminInstanceMethod('info'), { id: slug })) as InstanceInfo,
|
|
99
100
|
)
|
|
100
101
|
}
|
|
101
102
|
|
package/src/kernel/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
withKernelClient,
|
|
3
|
+
withAdminKernelClient,
|
|
4
|
+
resolveKernelTarget,
|
|
5
|
+
type ClientContext,
|
|
6
|
+
type ResolvedKernelTarget,
|
|
7
|
+
} from './client'
|
|
2
8
|
export { resolveCredential } from './auth'
|
|
3
9
|
export { formatKernelError } from './errors'
|
|
4
10
|
export {
|
package/src/kernel/options.ts
CHANGED
|
@@ -15,7 +15,7 @@ export const KERNEL_PASSTHROUGH_OPTIONS: CommandOption[] = [
|
|
|
15
15
|
flags: '-i, --instance <name>',
|
|
16
16
|
description: 'Target a specific instance (overrides active)',
|
|
17
17
|
},
|
|
18
|
-
{ flags: '--timeout <ms>', description: 'Request timeout in ms
|
|
18
|
+
{ flags: '--timeout <ms>', description: 'Request timeout in ms (default: 30000)' },
|
|
19
19
|
{ flags: '--as <identity>', description: 'Call as a specific identity' },
|
|
20
20
|
{ flags: '--creds <token>', description: 'Use a pre-signed credential (e.g. delegation token)' },
|
|
21
21
|
{ flags: '--debug', description: 'Print full error diagnostics on failure' },
|
|
@@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test'
|
|
|
3
3
|
import type { AstraleConfig } from '../config'
|
|
4
4
|
import type { InstanceStore } from '../instance'
|
|
5
5
|
|
|
6
|
+
import { AstraleError } from '../../errors'
|
|
6
7
|
import { DEFAULT_CONFIG } from '../config'
|
|
7
8
|
import {
|
|
8
9
|
couldBeConfiguredAdminInstance,
|
|
@@ -181,3 +182,36 @@ describe('instance target helpers', () => {
|
|
|
181
182
|
expect(isManagedInstanceNotFound(auth)).toBe(false)
|
|
182
183
|
})
|
|
183
184
|
})
|
|
185
|
+
|
|
186
|
+
describe('isManagedInstanceNotFound: kernel InternalKernelError wrap', () => {
|
|
187
|
+
test('NOT_FOUND-prefixed InternalKernelError is an instance miss', () => {
|
|
188
|
+
const e = new Error('NOT_FOUND: no instance node for "knowledge"')
|
|
189
|
+
e.name = 'InternalKernelError'
|
|
190
|
+
expect(isManagedInstanceNotFound(e)).toBe(true)
|
|
191
|
+
})
|
|
192
|
+
test('other InternalKernelError messages are not swallowed', () => {
|
|
193
|
+
const e = new Error('DISPATCH_FAILED: handler crashed')
|
|
194
|
+
e.name = 'InternalKernelError'
|
|
195
|
+
expect(isManagedInstanceNotFound(e)).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
test('resolveNamedInstanceTarget maps the wrap to typed INSTANCE_NOT_FOUND', async () => {
|
|
198
|
+
const managed = async () => {
|
|
199
|
+
const e = new Error('NOT_FOUND: no instance node for "ghost"')
|
|
200
|
+
e.name = 'InternalKernelError'
|
|
201
|
+
throw e
|
|
202
|
+
}
|
|
203
|
+
const opts = {
|
|
204
|
+
config: DEFAULT_CONFIG,
|
|
205
|
+
instances: { instances: {} },
|
|
206
|
+
managed,
|
|
207
|
+
} as unknown as Parameters<typeof resolveInstanceTarget>[1]
|
|
208
|
+
let caught: unknown
|
|
209
|
+
try {
|
|
210
|
+
await resolveInstanceTarget({ source: 'name', name: 'ghost' }, opts)
|
|
211
|
+
} catch (e) {
|
|
212
|
+
caught = e
|
|
213
|
+
}
|
|
214
|
+
expect(caught).toBeInstanceOf(AstraleError)
|
|
215
|
+
expect((caught as AstraleError).code).toBe('INSTANCE_NOT_FOUND')
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type { IntentMessage, MountedWindow, ResolvedView, Shell } from '@astrale-os/shell'
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
handleOpenIntent,
|
|
7
|
+
installOpenIntentHandler,
|
|
8
|
+
mountWithHandshakeFallback,
|
|
9
|
+
type OpenIntentHost,
|
|
10
|
+
} from '../view/open-intent'
|
|
11
|
+
|
|
12
|
+
const profile: ResolvedView = {
|
|
13
|
+
id: 'profile-view',
|
|
14
|
+
path: '/shell/views/profile',
|
|
15
|
+
url: 'https://shell.test/profile',
|
|
16
|
+
origin: 'class',
|
|
17
|
+
}
|
|
18
|
+
const card: ResolvedView = {
|
|
19
|
+
id: 'card-view',
|
|
20
|
+
path: '/shell/views/card',
|
|
21
|
+
url: 'https://shell.test/card',
|
|
22
|
+
handshake: 'none',
|
|
23
|
+
origin: 'class',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function openMessage(viewId?: string, correlationId?: string): IntentMessage<'open'> {
|
|
27
|
+
return {
|
|
28
|
+
type: 'intent',
|
|
29
|
+
version: 1,
|
|
30
|
+
envelope: {
|
|
31
|
+
name: 'open',
|
|
32
|
+
payload: { nodeId: 'person-1', ...(viewId ? { viewId } : {}) },
|
|
33
|
+
sender: { windowId: 'old-window' },
|
|
34
|
+
...(correlationId ? { correlationId } : {}),
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mounted(windowId: string, onClose?: () => void): MountedWindow {
|
|
40
|
+
return {
|
|
41
|
+
windowId,
|
|
42
|
+
window: {
|
|
43
|
+
windowId,
|
|
44
|
+
functionId: 'view',
|
|
45
|
+
isolation: 'shared',
|
|
46
|
+
state: 'active',
|
|
47
|
+
delegationToken: null,
|
|
48
|
+
capabilities: { intents: [] },
|
|
49
|
+
},
|
|
50
|
+
handle: { element: {} as HTMLElement },
|
|
51
|
+
close: async () => {
|
|
52
|
+
onClose?.()
|
|
53
|
+
return { kind: 'closed' }
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function harness(views: readonly ResolvedView[] = [profile, card]) {
|
|
59
|
+
const events: string[] = []
|
|
60
|
+
const sent: { windowId: string; message: IntentMessage }[] = []
|
|
61
|
+
const old = mounted('old-window', () => events.push('close:old-window'))
|
|
62
|
+
let current: MountedWindow | null = old
|
|
63
|
+
const shell = {
|
|
64
|
+
views: {
|
|
65
|
+
resolve: async (node: string) => {
|
|
66
|
+
events.push(`resolve:${node}`)
|
|
67
|
+
return views
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
} as unknown as Pick<Shell, 'views'>
|
|
71
|
+
const host: OpenIntentHost = {
|
|
72
|
+
current: () => current,
|
|
73
|
+
setCurrent: (next) => {
|
|
74
|
+
events.push(`current:${next.windowId}`)
|
|
75
|
+
current = next
|
|
76
|
+
},
|
|
77
|
+
mount: async (view, nodeId) => {
|
|
78
|
+
events.push(`mount:${view.id}:${nodeId}`)
|
|
79
|
+
return mounted('new-window')
|
|
80
|
+
},
|
|
81
|
+
opened: (view, nodeId) => events.push(`opened:${view.path}:${nodeId}`),
|
|
82
|
+
failed: (error) => events.push(`failed:${error instanceof Error ? error.message : error}`),
|
|
83
|
+
reply: (message, windowId) => {
|
|
84
|
+
if (!message.envelope.correlationId) return
|
|
85
|
+
events.push(`reply:${message.envelope.sender.windowId}`)
|
|
86
|
+
sent.push({
|
|
87
|
+
windowId: message.envelope.sender.windowId,
|
|
88
|
+
message: {
|
|
89
|
+
type: 'intent',
|
|
90
|
+
version: 1,
|
|
91
|
+
envelope: {
|
|
92
|
+
name: 'intentReply',
|
|
93
|
+
payload: {
|
|
94
|
+
correlationId: message.envelope.correlationId,
|
|
95
|
+
result: { windowId },
|
|
96
|
+
},
|
|
97
|
+
sender: { windowId: 'root' },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
},
|
|
102
|
+
reject: (message, error) => {
|
|
103
|
+
if (!message.envelope.correlationId) return
|
|
104
|
+
events.push(`reply:${message.envelope.sender.windowId}`)
|
|
105
|
+
sent.push({
|
|
106
|
+
windowId: message.envelope.sender.windowId,
|
|
107
|
+
message: {
|
|
108
|
+
type: 'intent',
|
|
109
|
+
version: 1,
|
|
110
|
+
envelope: {
|
|
111
|
+
name: 'intentReply',
|
|
112
|
+
payload: {
|
|
113
|
+
correlationId: message.envelope.correlationId,
|
|
114
|
+
error: { message: error instanceof Error ? error.message : String(error) },
|
|
115
|
+
},
|
|
116
|
+
sender: { windowId: 'root' },
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
return { events, sent, shell, host, current: () => current }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('open intent host', () => {
|
|
126
|
+
test('uses kernel preference and replies before retiring the requesting child', async () => {
|
|
127
|
+
const h = harness()
|
|
128
|
+
await handleOpenIntent(h.shell, h.host, openMessage(undefined, 'corr-1'))
|
|
129
|
+
|
|
130
|
+
expect(h.events).toEqual([
|
|
131
|
+
'resolve:person-1',
|
|
132
|
+
'mount:profile-view:person-1',
|
|
133
|
+
'current:new-window',
|
|
134
|
+
'opened:/shell/views/profile:person-1',
|
|
135
|
+
'reply:old-window',
|
|
136
|
+
'close:old-window',
|
|
137
|
+
])
|
|
138
|
+
expect(h.current()?.windowId).toBe('new-window')
|
|
139
|
+
expect(h.sent).toEqual([
|
|
140
|
+
{
|
|
141
|
+
windowId: 'old-window',
|
|
142
|
+
message: {
|
|
143
|
+
type: 'intent',
|
|
144
|
+
version: 1,
|
|
145
|
+
envelope: {
|
|
146
|
+
name: 'intentReply',
|
|
147
|
+
payload: { correlationId: 'corr-1', result: { windowId: 'new-window' } },
|
|
148
|
+
sender: { windowId: 'root' },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('honors an explicit view and keeps fire-and-forget navigation reply-free', async () => {
|
|
156
|
+
const h = harness()
|
|
157
|
+
await handleOpenIntent(h.shell, h.host, openMessage('card-view'))
|
|
158
|
+
|
|
159
|
+
expect(h.events).toContain('mount:card-view:person-1')
|
|
160
|
+
expect(h.sent).toEqual([])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('keeps the current view and rejects a correlated resolution failure', async () => {
|
|
164
|
+
const h = harness([])
|
|
165
|
+
await handleOpenIntent(h.shell, h.host, openMessage(undefined, 'corr-fail'))
|
|
166
|
+
|
|
167
|
+
expect(h.current()?.windowId).toBe('old-window')
|
|
168
|
+
expect(h.events).toEqual([
|
|
169
|
+
'resolve:person-1',
|
|
170
|
+
'reply:old-window',
|
|
171
|
+
'failed:No view resolves for this node',
|
|
172
|
+
])
|
|
173
|
+
expect(h.sent[0]!.message.envelope).toEqual({
|
|
174
|
+
name: 'intentReply',
|
|
175
|
+
payload: {
|
|
176
|
+
correlationId: 'corr-fail',
|
|
177
|
+
error: { message: 'No view resolves for this node' },
|
|
178
|
+
},
|
|
179
|
+
sender: { windowId: 'root' },
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('keeps the current view when the replacement mount fails', async () => {
|
|
184
|
+
const h = harness([profile])
|
|
185
|
+
h.host.mount = async () => {
|
|
186
|
+
h.events.push('mount:failed')
|
|
187
|
+
throw new Error('replacement failed')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await handleOpenIntent(h.shell, h.host, openMessage(undefined, 'corr-mount'))
|
|
191
|
+
|
|
192
|
+
expect(h.current()?.windowId).toBe('old-window')
|
|
193
|
+
expect(h.events).toEqual([
|
|
194
|
+
'resolve:person-1',
|
|
195
|
+
'mount:failed',
|
|
196
|
+
'reply:old-window',
|
|
197
|
+
'failed:replacement failed',
|
|
198
|
+
])
|
|
199
|
+
expect(h.sent[0]!.message.envelope.payload).toEqual({
|
|
200
|
+
correlationId: 'corr-mount',
|
|
201
|
+
error: { message: 'replacement failed' },
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('serializes overlapping opens', async () => {
|
|
206
|
+
let handler: ((message: IntentMessage<'open'>) => Promise<void>) | undefined
|
|
207
|
+
let releaseFirst!: () => void
|
|
208
|
+
const firstGate = new Promise<void>((resolve) => {
|
|
209
|
+
releaseFirst = resolve
|
|
210
|
+
})
|
|
211
|
+
let resolveCount = 0
|
|
212
|
+
const base = harness([profile])
|
|
213
|
+
const events = base.events
|
|
214
|
+
const shell = {
|
|
215
|
+
...base.shell,
|
|
216
|
+
views: {
|
|
217
|
+
resolve: async () => {
|
|
218
|
+
resolveCount += 1
|
|
219
|
+
events.push(`resolve:${resolveCount}`)
|
|
220
|
+
if (resolveCount === 1) await firstGate
|
|
221
|
+
return [profile]
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
onIntent: (_name: 'open', next: typeof handler) => {
|
|
225
|
+
handler = next
|
|
226
|
+
return () => {}
|
|
227
|
+
},
|
|
228
|
+
} as unknown as Shell
|
|
229
|
+
const host = {
|
|
230
|
+
...base.host,
|
|
231
|
+
mount: async () => {
|
|
232
|
+
const id = `new-${resolveCount}`
|
|
233
|
+
events.push(`mount:${id}`)
|
|
234
|
+
return mounted(id, () => events.push(`close:${id}`))
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
installOpenIntentHandler(shell, host)
|
|
238
|
+
|
|
239
|
+
const first = handler!(openMessage())
|
|
240
|
+
const second = handler!(openMessage())
|
|
241
|
+
await Promise.resolve()
|
|
242
|
+
expect(events).toEqual(['resolve:1'])
|
|
243
|
+
releaseFirst()
|
|
244
|
+
await Promise.all([first, second])
|
|
245
|
+
expect(events).toEqual([
|
|
246
|
+
'resolve:1',
|
|
247
|
+
'mount:new-1',
|
|
248
|
+
'current:new-1',
|
|
249
|
+
'opened:/shell/views/profile:person-1',
|
|
250
|
+
'close:old-window',
|
|
251
|
+
'resolve:2',
|
|
252
|
+
'mount:new-2',
|
|
253
|
+
'current:new-2',
|
|
254
|
+
'opened:/shell/views/profile:person-1',
|
|
255
|
+
'close:new-1',
|
|
256
|
+
])
|
|
257
|
+
expect(base.current()?.windowId).toBe('new-2')
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe('mountWithHandshakeFallback', () => {
|
|
262
|
+
test('mounts a plain view once', async () => {
|
|
263
|
+
const attempts: string[] = []
|
|
264
|
+
const result = await mountWithHandshakeFallback({
|
|
265
|
+
handshake: 'none',
|
|
266
|
+
attempts: 2,
|
|
267
|
+
mount: async (handshake) => {
|
|
268
|
+
attempts.push(handshake)
|
|
269
|
+
return 'plain-window'
|
|
270
|
+
},
|
|
271
|
+
cleanupFailedAttempt: () => attempts.push('cleanup'),
|
|
272
|
+
})
|
|
273
|
+
expect(result).toEqual({ mounted: 'plain-window', handshake: 'none' })
|
|
274
|
+
expect(attempts).toEqual(['none'])
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('retries shell mounts, cleans each failure, then falls back to plain', async () => {
|
|
278
|
+
const attempts: string[] = []
|
|
279
|
+
const result = await mountWithHandshakeFallback({
|
|
280
|
+
handshake: 'shell',
|
|
281
|
+
attempts: 2,
|
|
282
|
+
mount: async (handshake) => {
|
|
283
|
+
attempts.push(handshake)
|
|
284
|
+
if (handshake === 'shell') throw new Error('handshake failed')
|
|
285
|
+
return 'plain-window'
|
|
286
|
+
},
|
|
287
|
+
cleanupFailedAttempt: () => attempts.push('cleanup'),
|
|
288
|
+
})
|
|
289
|
+
expect(result).toEqual({ mounted: 'plain-window', handshake: 'none' })
|
|
290
|
+
expect(attempts).toEqual(['shell', 'cleanup', 'shell', 'cleanup', 'none'])
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('cleans and surfaces the decisive plain fallback failure', async () => {
|
|
294
|
+
const attempts: string[] = []
|
|
295
|
+
const run = mountWithHandshakeFallback({
|
|
296
|
+
handshake: 'shell',
|
|
297
|
+
attempts: 2,
|
|
298
|
+
mount: async (handshake) => {
|
|
299
|
+
attempts.push(handshake)
|
|
300
|
+
throw new Error(handshake === 'shell' ? 'handshake failed' : 'plain failed')
|
|
301
|
+
},
|
|
302
|
+
cleanupFailedAttempt: () => attempts.push('cleanup'),
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
await expect(run).rejects.toThrow('plain failed')
|
|
306
|
+
expect(attempts).toEqual(['shell', 'cleanup', 'shell', 'cleanup', 'none', 'cleanup'])
|
|
307
|
+
})
|
|
308
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import type { AbResult } from '../browser'
|
|
4
|
+
|
|
5
|
+
import { snapshotText, waitForSettledSnapshot } from '../view/snapshot'
|
|
6
|
+
|
|
7
|
+
const pending = result('- paragraph\n - StaticText "Opening integrations…"')
|
|
8
|
+
const settled = result('- heading "Integrations" [level=1]\n- button "Connect Google" [ref=e4]')
|
|
9
|
+
|
|
10
|
+
function result(snapshot: string): AbResult {
|
|
11
|
+
return { ok: true, data: { snapshot }, error: null }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function clock() {
|
|
15
|
+
let elapsed = 0
|
|
16
|
+
return {
|
|
17
|
+
now: () => elapsed,
|
|
18
|
+
sleep: async (ms: number) => {
|
|
19
|
+
elapsed += ms
|
|
20
|
+
},
|
|
21
|
+
elapsed: () => elapsed,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('view snapshot settling', () => {
|
|
26
|
+
test('waits through a late loading-to-content transition and a quiet window', async () => {
|
|
27
|
+
const fakeClock = clock()
|
|
28
|
+
let calls = 0
|
|
29
|
+
const takeSnapshot = async () => {
|
|
30
|
+
calls += 1
|
|
31
|
+
return fakeClock.elapsed() < 1_250 ? pending : settled
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const snapshot = await waitForSettledSnapshot(takeSnapshot, fakeClock, {
|
|
35
|
+
timeoutMs: 4_000,
|
|
36
|
+
quietWindowMs: 750,
|
|
37
|
+
pollMs: 250,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
expect(snapshotText(snapshot)).toContain('Connect Google')
|
|
41
|
+
expect(fakeClock.elapsed()).toBe(2_000)
|
|
42
|
+
expect(calls).toBe(10)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('returns the latest permanently pending snapshot at the bound', async () => {
|
|
46
|
+
const fakeClock = clock()
|
|
47
|
+
let calls = 0
|
|
48
|
+
const takeSnapshot = async () => {
|
|
49
|
+
calls += 1
|
|
50
|
+
return pending
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const snapshot = await waitForSettledSnapshot(takeSnapshot, fakeClock, {
|
|
54
|
+
timeoutMs: 1_000,
|
|
55
|
+
quietWindowMs: 500,
|
|
56
|
+
pollMs: 250,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
expect(snapshot).toBe(pending)
|
|
60
|
+
expect(fakeClock.elapsed()).toBe(1_000)
|
|
61
|
+
expect(calls).toBe(6)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('ignores changing agent-browser refs when measuring stability', async () => {
|
|
65
|
+
const fakeClock = clock()
|
|
66
|
+
let ref = 0
|
|
67
|
+
|
|
68
|
+
const snapshot = await waitForSettledSnapshot(
|
|
69
|
+
async () => result(`- button "Deploy" [ref=e${++ref}]`),
|
|
70
|
+
fakeClock,
|
|
71
|
+
{ timeoutMs: 2_000, quietWindowMs: 500, pollMs: 250 },
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
expect(snapshotText(snapshot)).toContain('Deploy')
|
|
75
|
+
expect(fakeClock.elapsed()).toBe(500)
|
|
76
|
+
})
|
|
77
|
+
})
|
package/src/lib/admin-domain.ts
CHANGED
|
@@ -7,16 +7,20 @@
|
|
|
7
7
|
* mounting it on an instance is the separate `domain install` step (or the
|
|
8
8
|
* admin's install-by-default policy).
|
|
9
9
|
*/
|
|
10
|
-
export const ADMIN_DOMAIN = '
|
|
10
|
+
export const ADMIN_DOMAIN = '/:admin.astrale.ai:class.DomainEntry'
|
|
11
|
+
|
|
12
|
+
export function adminDomainMethod(method: 'info' | 'install' | 'list' | 'publish'): string {
|
|
13
|
+
return `${ADMIN_DOMAIN}:${method}`
|
|
14
|
+
}
|
|
11
15
|
|
|
12
16
|
/** Read shape returned by `DomainEntry.publish` / `info` / `list` (domain `DomainInfoSchema`). */
|
|
13
17
|
export type DomainInfo = {
|
|
14
18
|
id: string
|
|
15
|
-
/**
|
|
19
|
+
/** shell.astrale.ai — the authority origin (== JWT aud + kernel path prefix). */
|
|
16
20
|
origin: string
|
|
17
|
-
/** '
|
|
21
|
+
/** 'shell' — the node slug + the id used in install plans / the CLI. */
|
|
18
22
|
name: string
|
|
19
|
-
/** https://
|
|
23
|
+
/** https://shell.astrale.ai — the published worker URL the kernel installs from. */
|
|
20
24
|
url?: string
|
|
21
25
|
description?: string
|
|
22
26
|
installByDefault?: boolean
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* list/status/create/delete`. The merged admin domain models instances as the
|
|
4
4
|
* `Instance` class (provisioned via `Instance.init`); these commands target it.
|
|
5
5
|
*/
|
|
6
|
-
export const ADMIN_INSTANCE = '
|
|
6
|
+
export const ADMIN_INSTANCE = '/:admin.astrale.ai:class.Instance'
|
|
7
|
+
|
|
8
|
+
export function adminInstanceMethod(method: 'alphaCreate' | 'delete' | 'info' | 'list'): string {
|
|
9
|
+
return `${ADMIN_INSTANCE}:${method}`
|
|
10
|
+
}
|
|
7
11
|
|
|
8
12
|
/** Read shape returned by `Instance.list` / `info` / `delete` (domain `InstanceInfoSchema`). */
|
|
9
13
|
export type InstanceInfo = {
|
package/src/lib/config.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { CONFIG_PATH } from './paths'
|
|
|
9
9
|
export const AstraleConfigSchema = z.object({
|
|
10
10
|
issuer: z.string().url().default('https://unregistered.invalid'),
|
|
11
11
|
admin: AdminTargetConfigSchema.default(DEFAULT_ADMIN_TARGET_CONFIG),
|
|
12
|
+
telemetry: z.object({ enabled: z.boolean().default(true) }).default({ enabled: true }),
|
|
12
13
|
})
|
|
13
14
|
|
|
14
15
|
export type AstraleConfig = z.infer<typeof AstraleConfigSchema>
|