@astrale-os/cli 0.6.3-alpha.0 → 0.8.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/README.md +3 -2
- package/dist/astrale.js +339 -242
- package/package.json +2 -3
- package/src/commands/__tests__/admin-instance.test.ts +49 -2
- package/src/commands/__tests__/domain-install-owned.test.ts +122 -0
- package/src/commands/__tests__/help-contract.test.ts +15 -0
- package/src/commands/domain/install.ts +15 -4
- package/src/commands/instance/active.ts +4 -7
- package/src/commands/instance/list.ts +4 -8
- package/src/commands/instance/status.ts +14 -9
- package/src/commands/instance/use.ts +13 -11
- package/src/commands/studio.ts +13 -0
- package/src/commands/view.ts +40 -13
- package/src/kernel/__tests__/client-owned-lookup.test.ts +51 -0
- package/src/kernel/client.ts +47 -20
- package/src/kernel/expand.ts +3 -22
- package/src/lib/__tests__/instance-candidates.test.ts +94 -19
- package/src/lib/__tests__/instance-target.test.ts +9 -1
- package/src/lib/__tests__/view-assets.test.ts +114 -0
- package/src/lib/__tests__/view-port-allocation.test.ts +82 -0
- package/src/lib/admin-instance.ts +27 -2
- package/src/lib/instance-candidates.ts +3 -3
- package/src/lib/instance-target.ts +1 -0
- package/src/lib/instance.ts +0 -11
- package/src/lib/login-flow.ts +4 -41
- package/src/lib/view/assets.ts +64 -0
- package/src/lib/view/port-allocation.ts +19 -0
- package/src/lib/view/server.ts +4 -37
- package/src/setup/__tests__/instance-step.test.ts +239 -0
- package/src/setup/steps/instance.ts +141 -61
- package/studio/client/dist/assets/index-BaqEuIQJ.js +109 -0
- package/studio/client/dist/assets/index-jRdExahh.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.test.ts +74 -0
- package/studio/server/agent/ask.ts +20 -22
- package/studio/server/agent/bridge/client.test.ts +49 -0
- package/studio/server/agent/bridge/client.ts +37 -0
- package/studio/server/agent/bridge/grant.test.ts +62 -0
- package/studio/server/agent/bridge/grant.ts +84 -0
- package/studio/server/agent/bridge/routes.test.ts +151 -0
- package/studio/server/agent/bridge/routes.ts +148 -0
- package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
- package/studio/server/agent/conversation.test.ts +84 -0
- package/studio/server/agent/conversation.ts +110 -0
- package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
- package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
- package/studio/server/agent/harness/claude/adapter.ts +71 -0
- package/studio/server/agent/harness/claude/ask.ts +114 -0
- package/studio/server/agent/harness/claude/capabilities.ts +27 -0
- package/studio/server/agent/harness/claude/command.ts +63 -0
- package/studio/server/agent/harness/claude/events.ts +215 -0
- package/studio/server/agent/harness/claude/loadout.ts +132 -0
- package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
- package/studio/server/agent/harness/claude/mcp.ts +41 -0
- package/studio/server/agent/harness/claude/skills.ts +43 -0
- package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
- package/studio/server/agent/harness/codex/adapter.ts +116 -0
- package/studio/server/agent/harness/codex/ask.test.ts +176 -0
- package/studio/server/agent/harness/codex/ask.ts +181 -0
- package/studio/server/agent/harness/codex/command.test.ts +69 -0
- package/studio/server/agent/harness/codex/command.ts +43 -0
- package/studio/server/agent/harness/codex/events.test.ts +94 -0
- package/studio/server/agent/harness/codex/events.ts +132 -0
- package/studio/server/agent/harness/codex/exec.ts +113 -0
- package/studio/server/agent/harness/codex/loadout.ts +63 -0
- package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
- package/studio/server/agent/harness/codex/mcp.ts +27 -0
- package/studio/server/agent/harness/codex/models.test.ts +167 -0
- package/studio/server/agent/harness/codex/models.ts +211 -0
- package/studio/server/agent/harness/codex/skills.ts +33 -0
- package/studio/server/agent/harness/gateway/config.test.ts +98 -0
- package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
- package/studio/server/agent/harness/gateway/token.test.ts +133 -0
- package/studio/server/agent/harness/gateway/token.ts +166 -0
- package/studio/server/agent/harness/mock/adapter.ts +190 -0
- package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
- package/studio/server/agent/harness/process.test.ts +46 -0
- package/studio/server/agent/harness/process.ts +104 -0
- package/studio/server/agent/harness/registry.ts +37 -0
- package/studio/server/agent/harness/selection.test.ts +75 -0
- package/studio/server/agent/harness/selection.ts +77 -0
- package/studio/server/agent/harness/skills.test.ts +95 -0
- package/studio/server/agent/harness/skills.ts +100 -0
- package/studio/server/agent/layout.test.ts +95 -0
- package/studio/server/agent/notify.ts +12 -0
- package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
- package/studio/server/agent/prompts/ask.ts +34 -0
- package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
- package/studio/server/agent/prompts/turn.ts +57 -0
- package/studio/server/agent/routes.test.ts +176 -0
- package/studio/server/agent/routes.ts +214 -0
- package/studio/server/agent/run/completion.ts +193 -0
- package/studio/server/agent/run/coordinator.test.ts +337 -0
- package/studio/server/agent/run/coordinator.ts +104 -0
- package/studio/server/agent/run/live-state.ts +73 -0
- package/studio/server/agent/run/preparation.ts +162 -0
- package/studio/server/agent/run/transcript.test.ts +47 -0
- package/studio/server/agent/run/transcript.ts +30 -0
- package/studio/server/agent/run/usage.test.ts +46 -0
- package/studio/server/{state → agent/run}/usage.ts +4 -4
- package/studio/server/agent/stream.ts +37 -0
- package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
- package/studio/server/api-agent-loadout.test.ts +69 -0
- package/studio/server/api.ts +3 -155
- package/studio/server/index.ts +6 -5
- package/studio/server/state/comments.test.ts +29 -0
- package/studio/server/state/comments.ts +3 -3
- package/studio/server/state/settings.test.ts +78 -0
- package/studio/server/state/settings.ts +28 -1
- package/studio/shared/agent-effort.test.ts +12 -0
- package/studio/shared/agent-effort.ts +15 -0
- package/studio/shared/agent-models.test.ts +9 -0
- package/studio/shared/agent-models.ts +14 -0
- package/studio/shared/settings-values.test.ts +18 -0
- package/studio/shared/settings-values.ts +20 -0
- package/studio/shared/types.ts +58 -7
- package/src/connect-core.test.ts +0 -42
- package/src/connect-core.ts +0 -53
- package/studio/client/dist/assets/index-CyN5G8IA.js +0 -109
- package/studio/client/dist/assets/index-DKKMHBBC.css +0 -1
- package/studio/server/agent/bridge.ts +0 -188
- package/studio/server/agent/claude.ts +0 -678
- package/studio/server/agent/mock.ts +0 -186
- package/studio/server/agent/registry.ts +0 -29
- package/studio/server/agent/runner.ts +0 -487
- package/studio/server/state/harness-token.ts +0 -0
package/src/kernel/client.ts
CHANGED
|
@@ -5,7 +5,12 @@ 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 {
|
|
9
|
+
callOwnedInstances,
|
|
10
|
+
findOwnedInstance,
|
|
11
|
+
type InstanceInfo,
|
|
12
|
+
type OwnedInstanceInfo,
|
|
13
|
+
} from '../lib/admin-instance'
|
|
9
14
|
import { readConfig } from '../lib/config'
|
|
10
15
|
import { resolveInstanceTarget, type ResolvedInstanceTarget } from '../lib/instance-target'
|
|
11
16
|
import { resolveCredential } from './auth'
|
|
@@ -37,18 +42,15 @@ export async function resolveKernelTarget(
|
|
|
37
42
|
// Ad-hoc `--url` — unknown kernel. Stamp the URL itself as audience,
|
|
38
43
|
// no slug for per-instance signing.
|
|
39
44
|
if (opts.url && !opts.instance) {
|
|
40
|
-
const resolved = await resolveInstanceTarget(
|
|
41
|
-
{ source: 'url', url: opts.url },
|
|
42
|
-
{ config, admin: adminLookupOpts(opts) },
|
|
43
|
-
)
|
|
45
|
+
const resolved = await resolveInstanceTarget({ source: 'url', url: opts.url }, { config })
|
|
44
46
|
return resolvedToKernelTarget(resolved)
|
|
45
47
|
}
|
|
46
48
|
const resolved = await resolveInstanceTarget(
|
|
47
49
|
opts.instance ? { source: 'name', name: opts.instance } : { source: 'active' },
|
|
48
50
|
{
|
|
49
51
|
config,
|
|
50
|
-
admin:
|
|
51
|
-
managed: (slug) =>
|
|
52
|
+
admin: {},
|
|
53
|
+
managed: (slug) => lookupImplicitOwnedInstance(slug, opts),
|
|
52
54
|
},
|
|
53
55
|
)
|
|
54
56
|
return resolvedToKernelTarget(resolved, opts.url)
|
|
@@ -68,21 +70,46 @@ export async function withKernelClient<T>(
|
|
|
68
70
|
return withResolvedKernelClient(opts, config, target, fn)
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
async function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
(await ctx.client.call(adminInstanceMethod('info'), { id: slug })) as InstanceInfo,
|
|
76
|
-
)
|
|
73
|
+
export async function listOwnedInstances(
|
|
74
|
+
opts: KernelCommandOpts & AdminTargetCommandOpts,
|
|
75
|
+
): Promise<OwnedInstanceInfo[]> {
|
|
76
|
+
return await withAdminKernelClient(opts, async (ctx) => callOwnedInstances(ctx.client))
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
79
|
+
export async function lookupOwnedInstance(
|
|
80
|
+
slug: string,
|
|
81
|
+
opts: KernelCommandOpts & AdminTargetCommandOpts,
|
|
82
|
+
): Promise<OwnedInstanceInfo> {
|
|
83
|
+
const instance = findOwnedInstance(await listOwnedInstances(opts), slug)
|
|
84
|
+
if (instance) return instance
|
|
85
|
+
throw new AstraleError('INSTANCE_NOT_FOUND', `No owned instance matches "${slug}".`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type ImplicitOwnedInstanceLookupDependencies = {
|
|
89
|
+
lookupOwned: (
|
|
90
|
+
slug: string,
|
|
91
|
+
opts: KernelCommandOpts & AdminTargetCommandOpts,
|
|
92
|
+
) => Promise<OwnedInstanceInfo>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve an implicit managed target through the caller's owner inventory.
|
|
97
|
+
*
|
|
98
|
+
* Target `--as` / `--creds` belong to the eventual child-kernel call. They
|
|
99
|
+
* must not authenticate the admin discovery request: leaving them out lets
|
|
100
|
+
* `withAdminKernelClient` use the admin bookmark's default WorkOS identity.
|
|
101
|
+
*/
|
|
102
|
+
export async function lookupImplicitOwnedInstance(
|
|
103
|
+
slug: string,
|
|
104
|
+
targetOpts: KernelCommandOpts,
|
|
105
|
+
deps: ImplicitOwnedInstanceLookupDependencies = {
|
|
106
|
+
lookupOwned: lookupOwnedInstance,
|
|
107
|
+
},
|
|
108
|
+
): Promise<InstanceInfo> {
|
|
109
|
+
return await deps.lookupOwned(slug, {
|
|
110
|
+
timeout: targetOpts.timeout,
|
|
111
|
+
debug: targetOpts.debug,
|
|
112
|
+
})
|
|
86
113
|
}
|
|
87
114
|
|
|
88
115
|
function resolvedToKernelTarget(
|
package/src/kernel/expand.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import type { InstanceInfo } from '../lib/admin-instance'
|
|
2
1
|
import type { KernelCommandOpts } from './types'
|
|
3
2
|
|
|
4
|
-
import { adminInstanceMethod } from '../lib/admin-instance'
|
|
5
3
|
/** CLI bridge for @self resolution and stale-registration error hints. */
|
|
6
4
|
import { readConfig } from '../lib/config'
|
|
7
5
|
import { getDefault, getIdentity, setRegistration } from '../lib/identity'
|
|
@@ -17,7 +15,7 @@ import {
|
|
|
17
15
|
type SelfResolverContext,
|
|
18
16
|
type SelfResolution,
|
|
19
17
|
} from '../lib/self'
|
|
20
|
-
import {
|
|
18
|
+
import { lookupImplicitOwnedInstance, withKernelClient } from './client'
|
|
21
19
|
|
|
22
20
|
/** Metadata attached to errors so the NotFoundError path can hint at stale `@self` expansions. */
|
|
23
21
|
export type SelfExpansionMeta = {
|
|
@@ -41,8 +39,8 @@ export async function buildSelfContext(opts: KernelCommandOpts): Promise<SelfRes
|
|
|
41
39
|
opts.instance ? { source: 'name', name: opts.instance } : { source: 'active' },
|
|
42
40
|
{
|
|
43
41
|
config,
|
|
44
|
-
admin:
|
|
45
|
-
managed: (instanceSlug) =>
|
|
42
|
+
admin: {},
|
|
43
|
+
managed: (instanceSlug) => lookupImplicitOwnedInstance(instanceSlug, opts),
|
|
46
44
|
},
|
|
47
45
|
)
|
|
48
46
|
slug = resolved.name
|
|
@@ -83,23 +81,6 @@ export async function buildSelfContext(opts: KernelCommandOpts): Promise<SelfRes
|
|
|
83
81
|
return { identity, instanceSlug: slug, credsJwt: opts.creds, instanceSigned, idpSubject }
|
|
84
82
|
}
|
|
85
83
|
|
|
86
|
-
function adminLookupOpts(opts: KernelCommandOpts): KernelCommandOpts {
|
|
87
|
-
return {
|
|
88
|
-
as: opts.as,
|
|
89
|
-
creds: opts.creds,
|
|
90
|
-
timeout: opts.timeout,
|
|
91
|
-
debug: opts.debug,
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function lookupManagedInstance(slug: string, opts: KernelCommandOpts): Promise<InstanceInfo> {
|
|
96
|
-
return await withAdminKernelClient(
|
|
97
|
-
adminLookupOpts(opts),
|
|
98
|
-
async (ctx) =>
|
|
99
|
-
(await ctx.client.call(adminInstanceMethod('info'), { id: slug })) as InstanceInfo,
|
|
100
|
-
)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
84
|
/**
|
|
104
85
|
* Run `fn`; if it throws a `NotFoundError`, stamp expansion metadata onto the
|
|
105
86
|
* error so `formatKernelError` can append the stale-registration hint. The
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { OwnedInstanceInfo } from '../admin-instance'
|
|
4
4
|
import type { InstanceStore } from '../instance'
|
|
5
5
|
|
|
6
6
|
import { collectInstanceCandidates, describeInstanceCandidate } from '../instance-candidates'
|
|
@@ -13,48 +13,116 @@ const store: InstanceStore = {
|
|
|
13
13
|
},
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const managedDemo:
|
|
16
|
+
const managedDemo: OwnedInstanceInfo = {
|
|
17
17
|
id: 'demo',
|
|
18
18
|
slug: 'demo',
|
|
19
19
|
url: 'https://demo.eu.astrale.ai',
|
|
20
|
+
state: 'ready',
|
|
20
21
|
region: 'eu',
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
describe('collectInstanceCandidates', () => {
|
|
24
25
|
test('bookmark and managed at the same URL collapse into one candidate', () => {
|
|
25
26
|
const candidates = collectInstanceCandidates('demo', store, [managedDemo])
|
|
26
|
-
expect(candidates).
|
|
27
|
-
|
|
27
|
+
expect(candidates).toEqual([
|
|
28
|
+
{
|
|
29
|
+
source: 'bookmark',
|
|
30
|
+
key: 'demo',
|
|
31
|
+
url: 'https://demo.eu.astrale.ai/api',
|
|
32
|
+
entry: store.instances.demo,
|
|
33
|
+
},
|
|
34
|
+
])
|
|
28
35
|
})
|
|
29
36
|
|
|
30
37
|
test('bookmark and managed at different URLs are both candidates', () => {
|
|
31
|
-
const conflicting:
|
|
38
|
+
const conflicting: OwnedInstanceInfo = {
|
|
39
|
+
...managedDemo,
|
|
40
|
+
url: 'https://demo.us.astrale.ai',
|
|
41
|
+
}
|
|
32
42
|
const candidates = collectInstanceCandidates('demo', store, [conflicting])
|
|
33
|
-
expect(
|
|
34
|
-
|
|
43
|
+
expect(
|
|
44
|
+
candidates.map((candidate) => ({
|
|
45
|
+
source: candidate.source,
|
|
46
|
+
key: candidate.key,
|
|
47
|
+
url: candidate.url,
|
|
48
|
+
id: candidate.source === 'managed' ? candidate.info.id : undefined,
|
|
49
|
+
})),
|
|
50
|
+
).toEqual([
|
|
51
|
+
{
|
|
52
|
+
source: 'bookmark',
|
|
53
|
+
key: 'demo',
|
|
54
|
+
url: 'https://demo.eu.astrale.ai/api',
|
|
55
|
+
id: undefined,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
source: 'managed',
|
|
59
|
+
key: 'demo',
|
|
60
|
+
url: 'https://demo.us.astrale.ai/api',
|
|
61
|
+
id: 'demo',
|
|
62
|
+
},
|
|
63
|
+
])
|
|
35
64
|
})
|
|
36
65
|
|
|
37
66
|
test('multiple managed instances with the same slug are all candidates', () => {
|
|
38
|
-
const a:
|
|
39
|
-
|
|
67
|
+
const a: OwnedInstanceInfo = {
|
|
68
|
+
id: 'x1',
|
|
69
|
+
slug: 'twin',
|
|
70
|
+
url: 'https://twin.eu.astrale.ai',
|
|
71
|
+
state: 'ready',
|
|
72
|
+
}
|
|
73
|
+
const b: OwnedInstanceInfo = {
|
|
74
|
+
id: 'x2',
|
|
75
|
+
slug: 'twin',
|
|
76
|
+
url: 'https://twin.us.astrale.ai',
|
|
77
|
+
state: 'ready',
|
|
78
|
+
}
|
|
40
79
|
const candidates = collectInstanceCandidates('twin', store, [a, b])
|
|
41
|
-
expect(
|
|
42
|
-
|
|
80
|
+
expect(
|
|
81
|
+
candidates.map((candidate) => ({
|
|
82
|
+
source: candidate.source,
|
|
83
|
+
key: candidate.key,
|
|
84
|
+
id: candidate.source === 'managed' ? candidate.info.id : undefined,
|
|
85
|
+
})),
|
|
86
|
+
).toEqual([
|
|
87
|
+
{ source: 'managed', key: 'twin', id: 'x1' },
|
|
88
|
+
{ source: 'managed', key: 'twin', id: 'x2' },
|
|
89
|
+
])
|
|
43
90
|
})
|
|
44
91
|
|
|
45
92
|
test('managed instances with a different slug are ignored', () => {
|
|
46
|
-
const unrelated:
|
|
47
|
-
|
|
93
|
+
const unrelated: OwnedInstanceInfo = {
|
|
94
|
+
id: 'z',
|
|
95
|
+
slug: 'zeta',
|
|
96
|
+
url: 'https://zeta.eu.astrale.ai',
|
|
97
|
+
state: 'ready',
|
|
98
|
+
}
|
|
99
|
+
expect(
|
|
100
|
+
collectInstanceCandidates('demo', store, [unrelated]).map((candidate) => ({
|
|
101
|
+
source: candidate.source,
|
|
102
|
+
key: candidate.key,
|
|
103
|
+
})),
|
|
104
|
+
).toEqual([{ source: 'bookmark', key: 'demo' }])
|
|
48
105
|
})
|
|
49
106
|
|
|
50
107
|
test('bookmark resolution honours slug aliases', () => {
|
|
51
108
|
const candidates = collectInstanceCandidates('oth', store, [])
|
|
52
|
-
expect(
|
|
53
|
-
|
|
109
|
+
expect(
|
|
110
|
+
candidates.map((candidate) => ({
|
|
111
|
+
source: candidate.source,
|
|
112
|
+
key: candidate.key,
|
|
113
|
+
url: candidate.url,
|
|
114
|
+
})),
|
|
115
|
+
).toEqual([
|
|
116
|
+
{
|
|
117
|
+
source: 'bookmark',
|
|
118
|
+
key: 'other',
|
|
119
|
+
url: 'https://other.example.com/api',
|
|
120
|
+
},
|
|
121
|
+
])
|
|
54
122
|
})
|
|
55
123
|
|
|
56
124
|
test('unknown name yields no candidates', () => {
|
|
57
|
-
expect(collectInstanceCandidates('missing', store, [])).
|
|
125
|
+
expect(collectInstanceCandidates('missing', store, [])).toEqual([])
|
|
58
126
|
})
|
|
59
127
|
})
|
|
60
128
|
|
|
@@ -65,9 +133,16 @@ describe('describeInstanceCandidate', () => {
|
|
|
65
133
|
expect(describeInstanceCandidate(bookmark!)).toContain('https://demo.eu.astrale.ai/api')
|
|
66
134
|
|
|
67
135
|
const [managed] = collectInstanceCandidates('twin', store, [
|
|
68
|
-
{
|
|
136
|
+
{
|
|
137
|
+
id: 'x',
|
|
138
|
+
slug: 'twin',
|
|
139
|
+
url: 'https://twin.eu.astrale.ai',
|
|
140
|
+
state: 'ready',
|
|
141
|
+
region: 'eu',
|
|
142
|
+
},
|
|
69
143
|
])
|
|
70
|
-
expect(describeInstanceCandidate(managed!)).
|
|
71
|
-
|
|
144
|
+
expect(describeInstanceCandidate(managed!)).toBe(
|
|
145
|
+
'twin (managed) https://twin.eu.astrale.ai/api — eu',
|
|
146
|
+
)
|
|
72
147
|
})
|
|
73
148
|
})
|
|
@@ -98,7 +98,7 @@ describe('resolveInstanceTarget', () => {
|
|
|
98
98
|
})
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
-
test('managed slug resolves through injected
|
|
101
|
+
test('managed slug resolves through the injected managed lookup', async () => {
|
|
102
102
|
await expect(
|
|
103
103
|
resolveInstanceTarget(
|
|
104
104
|
{ source: 'name', name: 'bryan' },
|
|
@@ -181,6 +181,14 @@ describe('instance target helpers', () => {
|
|
|
181
181
|
|
|
182
182
|
expect(isManagedInstanceNotFound(auth)).toBe(false)
|
|
183
183
|
})
|
|
184
|
+
|
|
185
|
+
test('recognizes an owner-scoped lookup miss', () => {
|
|
186
|
+
expect(
|
|
187
|
+
isManagedInstanceNotFound(
|
|
188
|
+
new AstraleError('INSTANCE_NOT_FOUND', 'No owned instance matches "missing".'),
|
|
189
|
+
),
|
|
190
|
+
).toBe(true)
|
|
191
|
+
})
|
|
184
192
|
})
|
|
185
193
|
|
|
186
194
|
describe('isManagedInstanceNotFound: kernel InternalKernelError wrap', () => {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { dirname, join, relative } from 'node:path'
|
|
5
|
+
import { pathToFileURL } from 'node:url'
|
|
6
|
+
|
|
7
|
+
import { ensureViewerAssets, viewerDistDir } from '../view/assets'
|
|
8
|
+
|
|
9
|
+
const temporaryDirectories: string[] = []
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await Promise.all(
|
|
13
|
+
temporaryDirectories
|
|
14
|
+
.splice(0)
|
|
15
|
+
.map((directory) => rm(directory, { recursive: true, force: true })),
|
|
16
|
+
)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('viewer asset resolution', () => {
|
|
20
|
+
test('skips partial candidates and prefers complete package-owned assets', async () => {
|
|
21
|
+
const root = await mkdtemp(join(tmpdir(), 'astrale-view-candidates-'))
|
|
22
|
+
temporaryDirectories.push(root)
|
|
23
|
+
const prefix = join(root, '.npm-global')
|
|
24
|
+
const packageRoot = join(prefix, 'lib', 'node_modules', '@astrale-os', 'cli')
|
|
25
|
+
const entry = join(prefix, 'bin', 'astrale')
|
|
26
|
+
const bundle = join(packageRoot, 'dist', 'astrale.js')
|
|
27
|
+
const published = join(packageRoot, 'viewer', 'dist')
|
|
28
|
+
const legacy = join(prefix, 'viewer', 'dist')
|
|
29
|
+
|
|
30
|
+
await mkdir(dirname(bundle), { recursive: true })
|
|
31
|
+
await mkdir(published, { recursive: true })
|
|
32
|
+
await mkdir(legacy, { recursive: true })
|
|
33
|
+
await writeFile(bundle, '')
|
|
34
|
+
await writeFile(join(published, 'main.js'), '')
|
|
35
|
+
await writeFile(join(legacy, 'main.js'), '')
|
|
36
|
+
await writeFile(join(legacy, 'index.html'), '')
|
|
37
|
+
|
|
38
|
+
expect(viewerDistDir(pathToFileURL(bundle).href, entry)).toBe(legacy)
|
|
39
|
+
|
|
40
|
+
await writeFile(join(published, 'index.html'), '')
|
|
41
|
+
expect(viewerDistDir(pathToFileURL(bundle).href, entry)).toBe(published)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('builds missing viewer assets in a clean source checkout', async () => {
|
|
45
|
+
const root = await mkdtemp(join(tmpdir(), 'astrale-view-source-'))
|
|
46
|
+
temporaryDirectories.push(root)
|
|
47
|
+
const module = join(root, 'src', 'lib', 'view', 'assets.ts')
|
|
48
|
+
const source = join(root, 'viewer')
|
|
49
|
+
const dist = join(source, 'dist')
|
|
50
|
+
|
|
51
|
+
await mkdir(dirname(module), { recursive: true })
|
|
52
|
+
await mkdir(source, { recursive: true })
|
|
53
|
+
await writeFile(module, '')
|
|
54
|
+
await writeFile(join(source, 'main.ts'), 'document.body.textContent = "viewer ready"\n')
|
|
55
|
+
await writeFile(join(source, 'index.html'), '<!doctype html><body></body>\n')
|
|
56
|
+
|
|
57
|
+
expect(await ensureViewerAssets(pathToFileURL(module).href, join(root, 'bin', 'astrale'))).toBe(
|
|
58
|
+
dist,
|
|
59
|
+
)
|
|
60
|
+
expect(await readFile(join(dist, 'index.html'), 'utf8')).toContain('<body>')
|
|
61
|
+
expect(await readFile(join(dist, 'main.js'), 'utf8')).toContain('viewer ready')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('uses the bundled module location when invoked through a global bin symlink', async () => {
|
|
65
|
+
const root = await mkdtemp(join(tmpdir(), 'astrale-view-bundle-'))
|
|
66
|
+
temporaryDirectories.push(root)
|
|
67
|
+
const prefix = join(root, '.npm-global')
|
|
68
|
+
const packageRoot = join(prefix, 'lib', 'node_modules', '@astrale-os', 'cli')
|
|
69
|
+
const entry = join(prefix, 'bin', 'astrale')
|
|
70
|
+
const sourceEntry = join(root, 'entry.ts')
|
|
71
|
+
const viewer = join(packageRoot, 'viewer', 'dist')
|
|
72
|
+
const assetsPath = join(import.meta.dirname, '..', 'view', 'assets.ts')
|
|
73
|
+
const relativeAssetsPath = relative(
|
|
74
|
+
await realpath(dirname(sourceEntry)),
|
|
75
|
+
await realpath(assetsPath),
|
|
76
|
+
)
|
|
77
|
+
const assetsModule = relativeAssetsPath.startsWith('.')
|
|
78
|
+
? relativeAssetsPath
|
|
79
|
+
: `./${relativeAssetsPath}`
|
|
80
|
+
|
|
81
|
+
await mkdir(join(prefix, 'bin'), { recursive: true })
|
|
82
|
+
await mkdir(viewer, { recursive: true })
|
|
83
|
+
await writeFile(join(viewer, 'main.js'), '')
|
|
84
|
+
await writeFile(join(viewer, 'index.html'), '')
|
|
85
|
+
await writeFile(
|
|
86
|
+
sourceEntry,
|
|
87
|
+
`import { viewerDistDir } from ${JSON.stringify(assetsModule)}\nconsole.log(viewerDistDir())\n`,
|
|
88
|
+
)
|
|
89
|
+
const build = await Bun.build({
|
|
90
|
+
entrypoints: [sourceEntry],
|
|
91
|
+
outdir: join(packageRoot, 'dist'),
|
|
92
|
+
target: 'node',
|
|
93
|
+
format: 'esm',
|
|
94
|
+
})
|
|
95
|
+
expect(build.success).toBe(true)
|
|
96
|
+
await symlink(
|
|
97
|
+
join('..', 'lib', 'node_modules', '@astrale-os', 'cli', 'dist', 'entry.js'),
|
|
98
|
+
entry,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const node = Bun.which('node')
|
|
102
|
+
expect(node).not.toBeNull()
|
|
103
|
+
const process = Bun.spawn([node!, entry], { stdout: 'pipe', stderr: 'pipe' })
|
|
104
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
105
|
+
new Response(process.stdout).text(),
|
|
106
|
+
new Response(process.stderr).text(),
|
|
107
|
+
process.exited,
|
|
108
|
+
])
|
|
109
|
+
|
|
110
|
+
expect(exitCode).toBe(0)
|
|
111
|
+
expect(stderr).toBe('')
|
|
112
|
+
expect(stdout.trim()).toBe(await realpath(viewer))
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
|
+
import net from 'node:net'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { findFreePort } from '../port'
|
|
8
|
+
import { withViewPortAllocationLock } from '../view/port-allocation'
|
|
9
|
+
|
|
10
|
+
let tmp: string
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
tmp = await mkdtemp(join(tmpdir(), 'astrale-view-port-'))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await rm(tmp, { recursive: true, force: true })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
function listen(port: number): Promise<net.Server> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const server = net.createServer()
|
|
23
|
+
server.once('error', reject)
|
|
24
|
+
server.listen({ port, host: '127.0.0.1', exclusive: true }, () => resolve(server))
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function close(server: net.Server): Promise<void> {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
server.close((error) => (error ? reject(error) : resolve()))
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('view port allocation', () => {
|
|
35
|
+
test('never overlaps concurrent allocation critical sections', async () => {
|
|
36
|
+
const lockPath = join(tmp, 'ports.lock')
|
|
37
|
+
let active = 0
|
|
38
|
+
let maxActive = 0
|
|
39
|
+
|
|
40
|
+
const contender = () =>
|
|
41
|
+
withViewPortAllocationLock(async () => {
|
|
42
|
+
active++
|
|
43
|
+
maxActive = Math.max(maxActive, active)
|
|
44
|
+
await Bun.sleep(40)
|
|
45
|
+
active--
|
|
46
|
+
}, lockPath)
|
|
47
|
+
|
|
48
|
+
await Promise.all([contender(), contender()])
|
|
49
|
+
|
|
50
|
+
// Without the view-specific lock both contenders enter before either
|
|
51
|
+
// delay completes, making this 2 and recreating the probe/bind window.
|
|
52
|
+
expect(maxActive).toBe(1)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('serializes concurrent probe-then-listen allocations', async () => {
|
|
56
|
+
const base = await findFreePort(48000, 200)
|
|
57
|
+
expect(base).not.toBeNull()
|
|
58
|
+
if (base === null) throw new Error('test port window exhausted')
|
|
59
|
+
const lockPath = join(tmp, 'ports.lock')
|
|
60
|
+
|
|
61
|
+
const allocate = () =>
|
|
62
|
+
withViewPortAllocationLock(async () => {
|
|
63
|
+
const port = await findFreePort(base, 4)
|
|
64
|
+
if (port === null) throw new Error('test port window exhausted')
|
|
65
|
+
|
|
66
|
+
// Widen the exact pre-fix race: both callers would finish the advisory
|
|
67
|
+
// probe before either one binds, then one would fail with EADDRINUSE.
|
|
68
|
+
await Bun.sleep(40)
|
|
69
|
+
const server = await listen(port)
|
|
70
|
+
return { port, server }
|
|
71
|
+
}, lockPath)
|
|
72
|
+
|
|
73
|
+
const allocations = await Promise.all([allocate(), allocate()])
|
|
74
|
+
try {
|
|
75
|
+
const ports = allocations.map(({ port }) => port).sort((a, b) => a - b)
|
|
76
|
+
expect(ports[0]).toBe(base)
|
|
77
|
+
expect(ports[1]).toBeGreaterThan(base)
|
|
78
|
+
} finally {
|
|
79
|
+
await Promise.all(allocations.map(({ server }) => close(server)))
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export const ADMIN_INSTANCE = '/:admin.astrale.ai:class.Instance'
|
|
7
7
|
|
|
8
|
-
export function adminInstanceMethod(
|
|
8
|
+
export function adminInstanceMethod(
|
|
9
|
+
method: 'alphaCreate' | 'delete' | 'info' | 'list' | 'listMine',
|
|
10
|
+
): string {
|
|
9
11
|
return `${ADMIN_INSTANCE}:${method}`
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
export type InstanceState = 'provisioning' | 'ready' | 'failed'
|
|
15
|
+
|
|
12
16
|
/** Read shape returned by `Instance.list` / `info` / `delete` (domain `InstanceInfoSchema`). */
|
|
13
17
|
export type InstanceInfo = {
|
|
14
18
|
id: string
|
|
@@ -16,10 +20,31 @@ export type InstanceInfo = {
|
|
|
16
20
|
url: string
|
|
17
21
|
hostId?: string
|
|
18
22
|
region?: string
|
|
19
|
-
state?:
|
|
23
|
+
state?: InstanceState
|
|
20
24
|
phase?: string
|
|
21
25
|
error?: string | null
|
|
22
26
|
createdAt?: string
|
|
27
|
+
organizationId?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Owner-scoped read shape returned by `Instance.listMine`. */
|
|
31
|
+
export type OwnedInstanceInfo = InstanceInfo & {
|
|
32
|
+
state: InstanceState
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Call the owner-scoped list contract through any admin client-shaped object. */
|
|
36
|
+
export async function callOwnedInstances(client: {
|
|
37
|
+
call: (path: string, params: Record<string, never>) => Promise<unknown>
|
|
38
|
+
}): Promise<OwnedInstanceInfo[]> {
|
|
39
|
+
return (await client.call(adminInstanceMethod('listMine'), {})) as OwnedInstanceInfo[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Match an owner-scoped instance by its stable node id or human slug. */
|
|
43
|
+
export function findOwnedInstance(
|
|
44
|
+
instances: readonly OwnedInstanceInfo[],
|
|
45
|
+
identifier: string,
|
|
46
|
+
): OwnedInstanceInfo | undefined {
|
|
47
|
+
return instances.find((instance) => instance.id === identifier || instance.slug === identifier)
|
|
23
48
|
}
|
|
24
49
|
|
|
25
50
|
/** Human-readable location of a managed instance ("region · hostId"). */
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { InstanceEntry, InstanceStore } from './instance'
|
|
2
2
|
|
|
3
|
-
import { formatInstanceLocation, type
|
|
3
|
+
import { formatInstanceLocation, type OwnedInstanceInfo } from './admin-instance'
|
|
4
4
|
import { normalizeInstanceKernelUrl, resolveInstanceKey } from './instance'
|
|
5
5
|
|
|
6
6
|
export type InstanceCandidate =
|
|
7
7
|
| { source: 'bookmark'; key: string; url: string; entry: InstanceEntry }
|
|
8
|
-
| { source: 'managed'; key: string; url: string; info:
|
|
8
|
+
| { source: 'managed'; key: string; url: string; info: OwnedInstanceInfo }
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Gather every instance a bare name can refer to — the local bookmark
|
|
@@ -16,7 +16,7 @@ export type InstanceCandidate =
|
|
|
16
16
|
export function collectInstanceCandidates(
|
|
17
17
|
name: string,
|
|
18
18
|
store: InstanceStore,
|
|
19
|
-
managed:
|
|
19
|
+
managed: OwnedInstanceInfo[],
|
|
20
20
|
): InstanceCandidate[] {
|
|
21
21
|
const out: InstanceCandidate[] = []
|
|
22
22
|
|
|
@@ -170,6 +170,7 @@ export function adminTargetToInstance(target: ResolvedAdminTarget): ResolvedInst
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
export function isManagedInstanceNotFound(error: unknown): boolean {
|
|
173
|
+
if (error instanceof AstraleError && error.code === 'INSTANCE_NOT_FOUND') return true
|
|
173
174
|
if (!(error instanceof Error)) return false
|
|
174
175
|
if (error.name === 'NotFoundError') return true
|
|
175
176
|
// The admin kernel reports a missing instance node as InternalKernelError
|
package/src/lib/instance.ts
CHANGED
|
@@ -113,17 +113,6 @@ export function sanitizeStore(store: InstanceStore): { store: InstanceStore; cha
|
|
|
113
113
|
|
|
114
114
|
let instancesMemo: InstanceStore | null = null
|
|
115
115
|
|
|
116
|
-
/**
|
|
117
|
-
* Drop the in-process bookmark cache so the next `readInstances` re-reads disk.
|
|
118
|
-
*
|
|
119
|
-
* A long-lived host (e.g. `@astrale-os/connect-host`) would otherwise serve a
|
|
120
|
-
* stale bookmark list after the user runs `astrale instance bookmark`. The CLI
|
|
121
|
-
* itself is one-shot, so this is a no-op for command usage.
|
|
122
|
-
*/
|
|
123
|
-
export function resetInstancesMemo(): void {
|
|
124
|
-
instancesMemo = null
|
|
125
|
-
}
|
|
126
|
-
|
|
127
116
|
export async function readInstances(
|
|
128
117
|
_config?: AstraleConfig,
|
|
129
118
|
opts: { persist?: boolean } = {},
|