@astrale-os/cli 1.0.0-beta.0 → 1.0.0-beta.2

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.
Files changed (180) hide show
  1. package/README.md +7 -11
  2. package/dist/astrale.js +3769 -3549
  3. package/dist/public/connect-core.js +396 -392
  4. package/dist/public/keys/index.js +371 -378
  5. package/dist/public/paths/index.js +371 -378
  6. package/dist/types/lib/idp.d.ts +6 -0
  7. package/dist/types/lib/meta.d.ts +2 -2
  8. package/package.json +6 -4
  9. package/src/admin/binding.ts +168 -0
  10. package/src/admin/catalog/__tests__/client.test.ts +2 -2
  11. package/src/admin/catalog/client.ts +4 -9
  12. package/src/admin/instance/__tests__/client.test.ts +5 -3
  13. package/src/admin/instance/client.ts +9 -27
  14. package/src/commands/__tests__/auth-login.test.ts +54 -1
  15. package/src/commands/__tests__/call.test.ts +5 -1
  16. package/src/commands/__tests__/domain-install-operation.test.ts +23 -7
  17. package/src/commands/__tests__/domain-install-owned.test.ts +5 -1
  18. package/src/commands/__tests__/domain-list.test.ts +83 -4
  19. package/src/commands/__tests__/install-identity-override.test.ts +30 -19
  20. package/src/commands/__tests__/studio-descriptor.test.ts +22 -0
  21. package/src/commands/__tests__/update.test.ts +73 -0
  22. package/src/commands/domain/install.ts +11 -15
  23. package/src/commands/domain/list.ts +17 -12
  24. package/src/commands/studio.ts +20 -0
  25. package/src/commands/update.ts +39 -13
  26. package/src/connection/__tests__/errors.test.ts +16 -12
  27. package/src/connection/__tests__/exchange.test.ts +6 -5
  28. package/src/connection/exchange.ts +5 -4
  29. package/src/lib/__tests__/domain-publication.test.ts +144 -0
  30. package/src/lib/__tests__/idp-session.test.ts +22 -0
  31. package/src/lib/__tests__/idp.test.ts +2 -0
  32. package/src/lib/__tests__/instance.test.ts +6 -0
  33. package/src/lib/__tests__/studio-server-deps.test.ts +53 -16
  34. package/src/lib/__tests__/update.test.ts +12 -0
  35. package/src/lib/domain-publication.ts +102 -0
  36. package/src/lib/idp.ts +5 -1
  37. package/src/lib/instance.ts +6 -1
  38. package/src/lib/login-flow.ts +13 -3
  39. package/src/lib/meta.ts +2 -2
  40. package/src/lib/update.ts +4 -4
  41. package/src/program/__tests__/program.test.ts +1 -1
  42. package/studio/client/dist/assets/{elk-api-D2xgMJvi.js → elk-api-lwhFo7vB.js} +1 -1
  43. package/studio/client/dist/assets/index-B-c-smo9.js +8 -0
  44. package/studio/client/dist/assets/index-BckHuAWk.js +81 -0
  45. package/studio/client/dist/assets/index-C4aNQ6dz.css +1 -0
  46. package/studio/client/dist/index.html +2 -2
  47. package/studio/package.json +3 -1
  48. package/studio/server/agent/bridge/stdio.ts +8 -1
  49. package/studio/server/agent/conversation.test.ts +10 -5
  50. package/studio/server/agent/conversation.ts +56 -8
  51. package/studio/server/agent/harness/codex/loadout.ts +18 -21
  52. package/studio/server/agent/harness/gateway/config.ts +20 -18
  53. package/studio/server/agent/harness/selection.ts +9 -1
  54. package/studio/server/agent/prompts/system.ts +3 -2
  55. package/studio/server/agent/prompts/turn.ts +13 -4
  56. package/studio/server/agent/run/completion.ts +2 -2
  57. package/studio/server/agent/run/preparation.ts +6 -5
  58. package/studio/server/agent/run/transcript.test.ts +35 -1
  59. package/studio/server/agent/run/transcript.ts +169 -3
  60. package/studio/server/agent/run/usage.test.ts +24 -0
  61. package/studio/server/agent/run/usage.ts +26 -1
  62. package/studio/server/api/canvas.ts +44 -0
  63. package/studio/server/api/comments.ts +85 -0
  64. package/studio/server/api/context.ts +61 -0
  65. package/studio/server/api/deployment.ts +39 -0
  66. package/studio/server/api/documents.ts +55 -0
  67. package/studio/server/api/domain.ts +59 -0
  68. package/studio/server/api/http.ts +45 -0
  69. package/studio/server/api/project.ts +57 -0
  70. package/studio/server/api/schema.ts +12 -0
  71. package/studio/server/api/views.ts +57 -0
  72. package/studio/server/api/workspace.ts +60 -0
  73. package/studio/server/api.test.ts +147 -0
  74. package/studio/server/api.ts +20 -422
  75. package/studio/server/cache.test.ts +111 -0
  76. package/studio/server/cache.ts +163 -9
  77. package/studio/server/cli-consumers.test.ts +494 -0
  78. package/studio/server/cli.test.ts +88 -0
  79. package/studio/server/cli.ts +240 -0
  80. package/studio/server/client-package.ts +25 -1
  81. package/studio/server/domain.test.ts +30 -2
  82. package/studio/server/domain.ts +14 -2
  83. package/studio/server/environment/dotenv-preview.test.ts +39 -0
  84. package/studio/server/environment/dotenv-preview.ts +27 -0
  85. package/studio/server/environment/files.test.ts +53 -0
  86. package/studio/server/{state/env.ts → environment/files.ts} +7 -48
  87. package/studio/server/{state → handoff}/copy.ts +21 -6
  88. package/studio/server/handoff/service.test.ts +22 -0
  89. package/studio/server/{state/handoff.ts → handoff/service.ts} +21 -14
  90. package/studio/server/instances/active.ts +72 -0
  91. package/studio/server/instances/deploy-record.test.ts +31 -0
  92. package/studio/server/instances/deploy-record.ts +37 -0
  93. package/studio/server/instances/deploy.ts +56 -0
  94. package/studio/server/{state/instance.test.ts → instances/probe.test.ts} +25 -21
  95. package/studio/server/instances/probe.ts +67 -0
  96. package/studio/server/instances/status.test.ts +12 -0
  97. package/studio/server/instances/status.ts +49 -0
  98. package/studio/server/introspect/anatomy/client-tree.ts +57 -0
  99. package/studio/server/introspect/anatomy/env-fields.ts +66 -0
  100. package/studio/server/introspect/anatomy/schema-definition.ts +53 -0
  101. package/studio/server/introspect/anatomy/source.ts +181 -0
  102. package/studio/server/introspect/anatomy/views/legacy.ts +232 -0
  103. package/studio/server/introspect/anatomy/views/routes.ts +105 -0
  104. package/studio/server/introspect/anatomy/views.ts +58 -0
  105. package/studio/server/introspect/anatomy-extras.test.ts +91 -4
  106. package/studio/server/introspect/anatomy-extras.ts +7 -726
  107. package/studio/server/introspect/anatomy.ts +46 -19
  108. package/studio/server/introspect/bundle.ts +15 -6
  109. package/studio/server/introspect/canonical-schema.test.ts +79 -0
  110. package/studio/server/introspect/canonical-schema.ts +80 -18
  111. package/studio/server/introspect/config-preview.test.ts +32 -0
  112. package/studio/server/introspect/config-preview.ts +119 -0
  113. package/studio/server/introspect/diff.test.ts +7 -11
  114. package/studio/server/introspect/diff.ts +28 -37
  115. package/studio/server/introspect/extractor.ts +8 -6
  116. package/studio/server/introspect/hash.ts +5 -2
  117. package/studio/server/introspect/overlay-tsmorph.ts +7 -1305
  118. package/studio/server/introspect/revision.test.ts +54 -0
  119. package/studio/server/introspect/revision.ts +49 -0
  120. package/studio/server/introspect/runtime.test.ts +63 -5
  121. package/studio/server/introspect/runtime.ts +49 -3
  122. package/studio/server/introspect/schema-ir-json.ts +181 -0
  123. package/studio/server/introspect/source-overlay/annotations.ts +14 -0
  124. package/studio/server/introspect/source-overlay/handlers.ts +681 -0
  125. package/studio/server/introspect/source-overlay/kernel-calls.ts +49 -0
  126. package/studio/server/introspect/source-overlay/project.ts +248 -0
  127. package/studio/server/introspect/source-overlay/spans.ts +360 -0
  128. package/studio/server/json.ts +46 -0
  129. package/studio/server/lifecycle.ts +5 -1
  130. package/studio/server/sse.test.ts +26 -0
  131. package/studio/server/sse.ts +4 -1
  132. package/studio/server/state/baseline.test.ts +212 -1
  133. package/studio/server/state/baseline.ts +145 -29
  134. package/studio/server/state/comments.test.ts +66 -1
  135. package/studio/server/state/comments.ts +173 -10
  136. package/studio/server/state/context.ts +34 -2
  137. package/studio/server/state/documents.ts +42 -1
  138. package/studio/server/state/integrations.ts +27 -1
  139. package/studio/server/state/layout.test.ts +34 -0
  140. package/studio/server/state/layout.ts +44 -17
  141. package/studio/server/state/settings.ts +29 -22
  142. package/studio/server/state/store.test.ts +122 -0
  143. package/studio/server/state/store.ts +66 -17
  144. package/studio/server/state/visibility.ts +22 -7
  145. package/studio/server/views/model.test.ts +57 -0
  146. package/studio/server/views/model.ts +66 -0
  147. package/studio/server/views/runtime.ts +40 -0
  148. package/studio/server/views/selection-repository.test.ts +31 -0
  149. package/studio/server/views/selection-repository.ts +60 -0
  150. package/studio/server/views/session.test.ts +73 -0
  151. package/studio/server/views/session.ts +166 -0
  152. package/studio/server/{state/views.test.ts → views/target.test.ts} +8 -159
  153. package/studio/server/views/target.ts +248 -0
  154. package/studio/server/watch.ts +1 -1
  155. package/studio/server/workspace/catalog.test.ts +17 -0
  156. package/studio/server/workspace/catalog.ts +43 -0
  157. package/studio/server/{state → workspace}/create.test.ts +1 -1
  158. package/studio/server/{state → workspace}/create.ts +1 -1
  159. package/studio/server/{state → workspace}/git.ts +1 -1
  160. package/studio/server/workspace/updates.ts +83 -0
  161. package/studio/server/workspace-state.ts +1 -1
  162. package/studio/shared/contracts/README.md +16 -0
  163. package/studio/shared/contracts/agent.ts +270 -0
  164. package/studio/shared/contracts/runtime.ts +50 -0
  165. package/studio/shared/contracts/schema.ts +462 -0
  166. package/studio/shared/contracts/surface.test.ts +319 -0
  167. package/studio/shared/contracts/workspace.ts +274 -0
  168. package/studio/shared/layout.test.ts +116 -0
  169. package/studio/shared/schema/identity.test.ts +58 -0
  170. package/studio/shared/schema/identity.ts +82 -0
  171. package/studio/shared/types.ts +11 -1099
  172. package/studio/tsconfig.json +1 -1
  173. package/viewer/dist/main.js +35 -35
  174. package/studio/client/dist/assets/index-BMdnsIJA.css +0 -1
  175. package/studio/client/dist/assets/index-D-vRV8w7.js +0 -8
  176. package/studio/client/dist/assets/index-LGSWRrk8.js +0 -81
  177. package/studio/server/state/catalog.ts +0 -117
  178. package/studio/server/state/instance.ts +0 -280
  179. package/studio/server/state/updates.ts +0 -63
  180. package/studio/server/state/views.ts +0 -535
@@ -0,0 +1,166 @@
1
+ import type {
2
+ StudioSchemaBundle,
3
+ ViewInfo,
4
+ ViewSessionResult,
5
+ ViewTargetCandidate,
6
+ } from '../../shared/types'
7
+ import type { StudioCliDecoder } from '../cli'
8
+
9
+ import { decodeJsonObject, runStudioCliJson } from '../cli'
10
+ import { activeInstanceName } from '../instances/active'
11
+ import { rememberTarget } from './selection-repository'
12
+ import { listViewTargets, viewDefinitionBindings } from './target'
13
+
14
+ export { conciseCliFailure } from '../cli'
15
+
16
+ interface AstraleResult<T> {
17
+ ok: boolean
18
+ data: T | null
19
+ detail: string
20
+ }
21
+
22
+ let viewSessionCommandTail: Promise<void> = Promise.resolve()
23
+
24
+ export function viewSessionArgs(
25
+ origin: string,
26
+ slug: string,
27
+ instance: string,
28
+ targetRef?: string,
29
+ ): string[] {
30
+ const args = ['view', `/:${assertOrigin(origin)}:view.${assertViewSlug(slug)}`]
31
+ if (targetRef) args.push('--target', targetRef)
32
+ args.push('--no-open', '--json', '-i', instance)
33
+ return args
34
+ }
35
+
36
+ interface OpenedViewPayload {
37
+ session?: {
38
+ id?: string
39
+ pageUrl?: string
40
+ view?: { route?: { href?: string } }
41
+ }
42
+ }
43
+
44
+ function decodeOpenedViewPayload(value: unknown): OpenedViewPayload | null {
45
+ const payload = decodeJsonObject(value)
46
+ if (!payload) return null
47
+ const session = decodeJsonObject(payload.session)
48
+ if (!session) return null
49
+ const view = decodeJsonObject(session.view)
50
+ const route = decodeJsonObject(view?.route)
51
+ if (
52
+ typeof session.id !== 'string' ||
53
+ typeof session.pageUrl !== 'string' ||
54
+ typeof route?.href !== 'string'
55
+ ) {
56
+ return null
57
+ }
58
+ return {
59
+ session: {
60
+ id: session.id,
61
+ pageUrl: session.pageUrl,
62
+ view: { route: { href: route.href } },
63
+ },
64
+ }
65
+ }
66
+
67
+ export function readyViewSession(
68
+ opened: OpenedViewPayload | null,
69
+ target: ViewTargetCandidate | null,
70
+ ): Extract<ViewSessionResult, { status: 'ready' }> | null {
71
+ const session = opened?.session
72
+ const viewUrl = session?.view?.route?.href
73
+ if (!session?.id || !session.pageUrl || !viewUrl) return null
74
+ return {
75
+ status: 'ready',
76
+ sessionId: session.id,
77
+ pageUrl: session.pageUrl,
78
+ viewUrl,
79
+ target,
80
+ }
81
+ }
82
+
83
+ export async function launchViewSession(
84
+ root: string,
85
+ origin: string,
86
+ view: ViewInfo,
87
+ bundle: StudioSchemaBundle | null,
88
+ request: { targetId?: unknown },
89
+ timeoutMs: number,
90
+ ): Promise<ViewSessionResult> {
91
+ const instance = await activeInstanceName()
92
+ if (!instance) return { status: 'unavailable', reason: 'No active Astrale instance.' }
93
+
94
+ let target: ViewTargetCandidate | null = null
95
+ if (viewDefinitionBindings(origin, view, bundle).length > 0) {
96
+ const targetId = typeof request.targetId === 'string' ? request.targetId.trim() : ''
97
+ if (!targetId) return { status: 'unavailable', reason: 'Select a target before opening.' }
98
+ const targets = await listViewTargets(root, origin, view, bundle, instance, timeoutMs)
99
+ if (targets.status !== 'available') {
100
+ return { status: 'unavailable', reason: targets.reason ?? 'Targets could not be queried.' }
101
+ }
102
+ target = targets.items.find((item) => item.id === targetId) ?? null
103
+ if (!target) {
104
+ return {
105
+ status: 'unavailable',
106
+ reason: 'That target no longer exists or is no longer visible. Choose another target.',
107
+ }
108
+ }
109
+ }
110
+
111
+ const opened = await runViewSessionCommand<OpenedViewPayload>(
112
+ viewSessionArgs(origin, view.slug, instance, target?.ref),
113
+ Math.max(20_000, timeoutMs + 12_000),
114
+ decodeOpenedViewPayload,
115
+ )
116
+ const session = opened.ok ? readyViewSession(opened.data, target) : null
117
+ if (!session) {
118
+ return {
119
+ status: 'unavailable',
120
+ reason: opened.detail || '`astrale view` could not start the preview session.',
121
+ }
122
+ }
123
+
124
+ if (target) rememberTarget(root, instance, view.slug, target)
125
+ return session
126
+ }
127
+
128
+ export async function closeViewSession(sessionId: string): Promise<{ ok: true }> {
129
+ if (!/^v-[0-9a-f]+$/.test(sessionId)) return { ok: true }
130
+ await runViewSessionCommand(['view', '--close', sessionId, '--json'], 6000, decodeJsonObject)
131
+ return { ok: true }
132
+ }
133
+
134
+ function assertOrigin(value: string): string {
135
+ if (!/^[a-z0-9][a-z0-9.-]*$/i.test(value)) throw new Error(`Invalid domain origin: ${value}`)
136
+ return value
137
+ }
138
+
139
+ function assertViewSlug(value: string): string {
140
+ if (!/^[a-z][a-z0-9-]*$/.test(value)) throw new Error(`Invalid view slug: ${value}`)
141
+ return value
142
+ }
143
+
144
+ function runViewSessionCommand<T>(
145
+ args: string[],
146
+ timeoutMs: number,
147
+ decoder: StudioCliDecoder<T>,
148
+ ): Promise<AstraleResult<T>> {
149
+ const command = viewSessionCommandTail.then(
150
+ () => runAstraleJson(args, timeoutMs, decoder),
151
+ () => runAstraleJson(args, timeoutMs, decoder),
152
+ )
153
+ viewSessionCommandTail = command.then(
154
+ () => undefined,
155
+ () => undefined,
156
+ )
157
+ return command
158
+ }
159
+
160
+ async function runAstraleJson<T>(
161
+ args: string[],
162
+ timeoutMs: number,
163
+ decoder: StudioCliDecoder<T>,
164
+ ): Promise<AstraleResult<T>> {
165
+ return runStudioCliJson(args, decoder, { timeoutMs })
166
+ }
@@ -1,50 +1,10 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
 
3
- import type {
4
- RememberedViewTarget,
5
- StudioSchemaBundle,
6
- ViewInfo,
7
- ViewTargetCandidate,
8
- } from '../../shared/types'
3
+ import type { StudioSchemaBundle, ViewInfo } from '../../shared/types'
9
4
 
10
- import {
11
- conciseCliFailure,
12
- readyViewSession,
13
- reconcileRememberedTarget,
14
- targetDefinition,
15
- targetFromRow,
16
- viewDefinitionBindings,
17
- viewSessionArgs,
18
- } from './views'
19
-
20
- describe('view session command', () => {
21
- test('opens the installed ViewPath without rejected placement overrides', () => {
22
- const args = viewSessionArgs('issues.astrale.ai', 'issue-detail', 'staging', '@issue-1')
23
- expect(args).toEqual([
24
- 'view',
25
- '/:issues.astrale.ai:view.issue-detail',
26
- '--target',
27
- '@issue-1',
28
- '--no-open',
29
- '--json',
30
- '-i',
31
- 'staging',
32
- ])
33
- expect(args).not.toContain('--view-url')
34
- expect(args).not.toContain('--handshake')
35
- })
36
-
37
- test('omits --target for a standalone view', () => {
38
- expect(viewSessionArgs('issues.astrale.ai', 'dashboard', 'local')).toEqual([
39
- 'view',
40
- '/:issues.astrale.ai:view.dashboard',
41
- '--no-open',
42
- '--json',
43
- '-i',
44
- 'local',
45
- ])
46
- })
5
+ import { targetDefinition, viewDefinitionBindings } from './target'
47
6
 
7
+ describe('view target definitions', () => {
48
8
  test('preserves canonical interface View target coordinates', () => {
49
9
  const view = { slug: 'users', kind: 'unknown' } satisfies ViewInfo
50
10
  const bundle = {
@@ -63,11 +23,7 @@ describe('view session command', () => {
63
23
  } as unknown as StudioSchemaBundle
64
24
 
65
25
  expect(viewDefinitionBindings('shell.astrale.ai', view, bundle)).toEqual([
66
- {
67
- className: 'Identity',
68
- classOrigin: 'kernel.astrale.ai',
69
- kind: 'interface',
70
- },
26
+ { className: 'Identity', classOrigin: 'kernel.astrale.ai', kind: 'interface' },
71
27
  ])
72
28
  expect(targetDefinition('Identity', 'kernel.astrale.ai', 'interface')).toBe(
73
29
  '/:kernel.astrale.ai:interface.Identity',
@@ -109,13 +65,8 @@ describe('view session command', () => {
109
65
  test('treats an exact Domain target as authoritative over legacy viewFor', () => {
110
66
  const view = { slug: 'home', kind: 'unknown', viewFor: 'User' } satisfies ViewInfo
111
67
  const bundle = {
112
- ir: {
113
- views: {
114
- home: { name: 'home', auth: 'required', target: { kind: 'domain' } },
115
- },
116
- },
68
+ ir: { views: { home: { name: 'home', auth: 'required', target: { kind: 'domain' } } } },
117
69
  } as unknown as StudioSchemaBundle
118
-
119
70
  expect(viewDefinitionBindings('shell.astrale.ai', view, bundle)).toEqual([])
120
71
  })
121
72
 
@@ -137,9 +88,7 @@ describe('view session command', () => {
137
88
  ref: { origin: 'shell.astrale.ai', kind: 'interface', name: 'Named' },
138
89
  },
139
90
  },
140
- imports: {
141
- Named: { origin: 'legacy.invalid', definition: 'class' },
142
- },
91
+ imports: { Named: { origin: 'legacy.invalid', definition: 'class' } },
143
92
  importsByKey: {
144
93
  'directory.example.dev:interface.Named': {
145
94
  origin: 'directory.example.dev',
@@ -169,17 +118,10 @@ describe('view session command', () => {
169
118
  views: {},
170
119
  classes: {},
171
120
  interfaces: {},
172
- imports: {
173
- User: { origin: 'accounts.example.dev', definition: 'interface' },
174
- },
175
- },
176
- } as unknown as StudioSchemaBundle
177
- const exact = {
178
- ir: {
179
- ...legacy.ir,
180
- importsByKey: {},
121
+ imports: { User: { origin: 'accounts.example.dev', definition: 'interface' } },
181
122
  },
182
123
  } as unknown as StudioSchemaBundle
124
+ const exact = { ir: { ...legacy.ir, importsByKey: {} } } as unknown as StudioSchemaBundle
183
125
 
184
126
  expect(viewDefinitionBindings('shell.astrale.ai', view, legacy)).toEqual([
185
127
  { className: 'User', classOrigin: 'accounts.example.dev', kind: 'interface' },
@@ -187,99 +129,6 @@ describe('view session command', () => {
187
129
  expect(viewDefinitionBindings('shell.astrale.ai', view, exact)).toEqual([])
188
130
  })
189
131
 
190
- test('reads the verified placement URL from the current CLI session route', () => {
191
- expect(
192
- readyViewSession(
193
- {
194
- session: {
195
- id: 'v-a1b2',
196
- pageUrl: 'http://127.0.0.1:4419/s/nonce/',
197
- view: { route: { href: 'https://issues.example/ui/issue' } },
198
- },
199
- },
200
- null,
201
- ),
202
- ).toEqual({
203
- status: 'ready',
204
- sessionId: 'v-a1b2',
205
- pageUrl: 'http://127.0.0.1:4419/s/nonce/',
206
- viewUrl: 'https://issues.example/ui/issue',
207
- target: null,
208
- })
209
- expect(
210
- readyViewSession(
211
- {
212
- session: {
213
- id: 'v-old',
214
- pageUrl: 'http://127.0.0.1:4419/s/old/',
215
- view: { url: 'https://legacy.invalid/view' },
216
- },
217
- } as never,
218
- null,
219
- ),
220
- ).toBeNull()
221
- })
222
- })
223
-
224
- test('reduces CLI stack output to the actionable kernel failure', () => {
225
- expect(
226
- conciseCliFailure(
227
- `275 | if (code === KERNEL_ERROR_CODES.INVALID_REQUEST)\n276 |\nPermissionDeniedError: Permission denied: READ on /:issues.astrale.ai:view.issue\n details: {\n method: \"View:resolve\"\n}\n at mapServerError (errors.ts:280:61)\nBun v1.3.14`,
228
- ),
229
- ).toBe('PermissionDeniedError: Permission denied: READ on /:issues.astrale.ai:view.issue')
230
- })
231
-
232
- describe('target candidates', () => {
233
- const remembered: RememberedViewTarget = {
234
- id: 'gone-id',
235
- className: 'Issue',
236
- classOrigin: 'issues.astrale.ai',
237
- label: 'Deleted issue',
238
- }
239
- const candidate: ViewTargetCandidate = {
240
- id: 'live-id',
241
- ref: '@live-id',
242
- className: 'Issue',
243
- classOrigin: 'issues.astrale.ai',
244
- label: 'Live issue',
245
- }
246
-
247
- test('turns a missing remembered node into an explicit stale selection', () => {
248
- expect(reconcileRememberedTarget(remembered, [candidate])).toEqual({
249
- selected: null,
250
- stale: remembered,
251
- })
252
- expect(reconcileRememberedTarget({ ...remembered, id: 'live-id' }, [candidate])).toEqual({
253
- selected: candidate,
254
- stale: null,
255
- })
256
- })
257
-
258
- test('builds friendly candidates from generic qualified node properties', () => {
259
- expect(
260
- targetFromRow(
261
- {
262
- id: 'iss-1',
263
- props: {
264
- 'kernel.astrale.ai:interface.Named.property.name': 'Broken authorization badge',
265
- 'kernel.astrale.ai:interface.Descriptable.property.description': 'Studio regression',
266
- 'kernel.astrale.ai:interface.Statused.property.status': 'open',
267
- },
268
- },
269
- 'Issue',
270
- 'issues.astrale.ai',
271
- ),
272
- ).toEqual({
273
- id: 'iss-1',
274
- ref: '@iss-1',
275
- className: 'Issue',
276
- classOrigin: 'issues.astrale.ai',
277
- label: 'Broken authorization badge',
278
- description: 'Studio regression',
279
- status: 'open',
280
- })
281
- })
282
-
283
132
  test('addresses exact class instances through a canonical Definition source', () => {
284
133
  expect(targetDefinition('Issue', 'issues.astrale.ai')).toBe('/:issues.astrale.ai:class.Issue')
285
134
  })
@@ -0,0 +1,248 @@
1
+ import type {
2
+ StudioSchemaBundle,
3
+ ViewInfo,
4
+ ViewTargetCandidate,
5
+ ViewTargetResult,
6
+ } from '../../shared/types'
7
+ import type { StudioCliDecoder } from '../cli'
8
+
9
+ import { decodeJsonObject, runStudioCliJson } from '../cli'
10
+ import { reconcileRememberedTarget, targetFromRow, type RawTargetRow } from './model'
11
+ import { readRememberedTarget } from './selection-repository'
12
+
13
+ const TARGET_LIMIT = 200
14
+
15
+ interface AstraleResult<T> {
16
+ ok: boolean
17
+ data: T | null
18
+ detail: string
19
+ }
20
+
21
+ interface RawQueryResult {
22
+ graph?: { nodes?: RawTargetRow[] }
23
+ }
24
+
25
+ function decodeTargetRow(value: unknown): RawTargetRow | null {
26
+ const row = decodeJsonObject(value)
27
+ if (!row) return null
28
+ const props = decodeJsonObject(row.props)
29
+ return {
30
+ ...(typeof row.id === 'string' ? { id: row.id } : {}),
31
+ ...(props ? { props } : {}),
32
+ }
33
+ }
34
+
35
+ function decodeQueryResult(value: unknown): RawQueryResult | null {
36
+ const payload = decodeJsonObject(value)
37
+ const graph = decodeJsonObject(payload?.graph)
38
+ if (!graph || !Array.isArray(graph.nodes)) return null
39
+ const nodes = graph.nodes.map(decodeTargetRow).filter((row) => row !== null)
40
+ return { graph: { nodes } }
41
+ }
42
+
43
+ export async function listViewTargets(
44
+ root: string,
45
+ origin: string,
46
+ view: ViewInfo,
47
+ bundle: StudioSchemaBundle | null,
48
+ instance: string,
49
+ timeoutMs: number,
50
+ ): Promise<ViewTargetResult> {
51
+ const bindings = viewDefinitionBindings(origin, view, bundle)
52
+ if (bindings.length === 0) {
53
+ return {
54
+ status: 'available',
55
+ items: [],
56
+ selected: null,
57
+ stale: null,
58
+ truncated: false,
59
+ }
60
+ }
61
+
62
+ const queried = await Promise.all(
63
+ bindings.map(async (binding) => {
64
+ const definition = targetDefinition(binding.className, binding.classOrigin, binding.kind)
65
+ const result = await runAstraleJson<RawQueryResult>(
66
+ [
67
+ 'query',
68
+ '--definition',
69
+ definition,
70
+ '--limit',
71
+ String(TARGET_LIMIT + 1),
72
+ '--json',
73
+ '-i',
74
+ instance,
75
+ ],
76
+ timeoutMs,
77
+ decodeQueryResult,
78
+ )
79
+ return { binding, result }
80
+ }),
81
+ )
82
+ const successes = queried.filter(
83
+ (item) => item.result.ok && Array.isArray(item.result.data?.graph?.nodes),
84
+ )
85
+ if (successes.length === 0) {
86
+ const reason = queried.map((item) => item.result.detail).find(Boolean)
87
+ return {
88
+ status: 'unavailable',
89
+ items: [],
90
+ selected: null,
91
+ stale: null,
92
+ truncated: false,
93
+ reason: reason || 'The active instance could not be queried for view targets.',
94
+ }
95
+ }
96
+
97
+ const byId = new Map<string, ViewTargetCandidate>()
98
+ let truncated = false
99
+ for (const { binding, result } of successes) {
100
+ const rows = result.data?.graph?.nodes ?? []
101
+ if (rows.length > TARGET_LIMIT) truncated = true
102
+ for (const row of rows.slice(0, TARGET_LIMIT)) {
103
+ const target = targetFromRow(row, binding.className, binding.classOrigin)
104
+ if (target) byId.set(target.id, target)
105
+ }
106
+ }
107
+ const items = [...byId.values()].sort(
108
+ (a, b) => a.label.localeCompare(b.label) || a.className.localeCompare(b.className),
109
+ )
110
+ const remembered = readRememberedTarget(root, instance, view.slug)
111
+ return {
112
+ status: 'available',
113
+ items,
114
+ ...reconcileRememberedTarget(remembered, items),
115
+ truncated,
116
+ }
117
+ }
118
+
119
+ export function targetDefinition(
120
+ className: string,
121
+ classOrigin: string,
122
+ kind: 'class' | 'interface' = 'class',
123
+ ): string {
124
+ return `/:${assertOrigin(classOrigin)}:${kind}.${assertSchemaName(className)}`
125
+ }
126
+
127
+ export interface ViewDefinitionBinding {
128
+ className: string
129
+ classOrigin: string
130
+ kind: 'class' | 'interface'
131
+ }
132
+
133
+ /** Preserve exact canonical View target coordinates; short-name anatomy is legacy-only. */
134
+ export function viewDefinitionBindings(
135
+ origin: string,
136
+ view: ViewInfo,
137
+ bundle: StudioSchemaBundle | null,
138
+ ): ViewDefinitionBinding[] {
139
+ const target = bundle?.ir?.views?.[view.slug]?.target
140
+ if (target) {
141
+ if (target.kind === 'domain') return []
142
+ return uniqueViewBindings(
143
+ target.definitions
144
+ .filter(
145
+ (definition): definition is typeof definition & { kind: 'class' | 'interface' } =>
146
+ definition.kind === 'class' || definition.kind === 'interface',
147
+ )
148
+ .map((definition) => ({
149
+ className: definition.name,
150
+ classOrigin: definition.origin,
151
+ kind: definition.kind,
152
+ })),
153
+ )
154
+ }
155
+
156
+ const names = Array.isArray(view.viewFor) ? view.viewFor : view.viewFor ? [view.viewFor] : []
157
+ const ir = bundle?.ir
158
+ if (!ir) {
159
+ return uniqueViewBindings(
160
+ names.map((className) => ({ className, classOrigin: origin, kind: 'class' })),
161
+ )
162
+ }
163
+
164
+ return uniqueViewBindings(
165
+ names.flatMap((className) => {
166
+ const local: ViewDefinitionBinding[] = []
167
+ const localClass = ir.classes[className]
168
+ const localInterface = ir.interfaces[className]
169
+ if (localClass) {
170
+ local.push({
171
+ className,
172
+ classOrigin: localClass.ref?.origin ?? localClass.origin ?? ir.domain ?? origin,
173
+ kind: 'class',
174
+ })
175
+ }
176
+ if (localInterface) {
177
+ local.push({
178
+ className,
179
+ classOrigin: localInterface.ref?.origin ?? localInterface.origin ?? ir.domain ?? origin,
180
+ kind: 'interface',
181
+ })
182
+ }
183
+
184
+ if (ir.importsByKey !== undefined) {
185
+ const imported = Object.keys(ir.importsByKey).flatMap((key) => {
186
+ const binding = viewBindingFromDefinitionKey(key)
187
+ return binding?.className === className ? [binding] : []
188
+ })
189
+ return [...local, ...imported]
190
+ }
191
+
192
+ const legacy = ir.imports[className]
193
+ if (legacy) {
194
+ return [
195
+ ...local,
196
+ {
197
+ className,
198
+ classOrigin: legacy.origin,
199
+ kind: legacy.definition,
200
+ } satisfies ViewDefinitionBinding,
201
+ ]
202
+ }
203
+
204
+ return local.length > 0 ? local : [{ className, classOrigin: origin, kind: 'class' }]
205
+ }),
206
+ )
207
+ }
208
+
209
+ function viewBindingFromDefinitionKey(key: string): ViewDefinitionBinding | null {
210
+ const separator = key.lastIndexOf(':')
211
+ if (separator <= 0) return null
212
+ const classOrigin = key.slice(0, separator)
213
+ const ref = key.slice(separator + 1)
214
+ const match = /^(class|interface)\.([A-Za-z_$][\w$]*)$/.exec(ref)
215
+ if (!match) return null
216
+ return {
217
+ className: match[2]!,
218
+ classOrigin,
219
+ kind: match[1] as ViewDefinitionBinding['kind'],
220
+ }
221
+ }
222
+
223
+ function uniqueViewBindings(bindings: ViewDefinitionBinding[]): ViewDefinitionBinding[] {
224
+ const unique = new Map<string, ViewDefinitionBinding>()
225
+ for (const binding of bindings) {
226
+ const key = `${binding.classOrigin}:${binding.kind}.${binding.className}`
227
+ if (!unique.has(key)) unique.set(key, binding)
228
+ }
229
+ return [...unique.values()]
230
+ }
231
+
232
+ function assertOrigin(value: string): string {
233
+ if (!/^[a-z0-9][a-z0-9.-]*$/i.test(value)) throw new Error(`Invalid domain origin: ${value}`)
234
+ return value
235
+ }
236
+
237
+ function assertSchemaName(value: string): string {
238
+ if (!/^[A-Za-z_$][\w$]*$/.test(value)) throw new Error(`Invalid class name: ${value}`)
239
+ return value
240
+ }
241
+
242
+ async function runAstraleJson<T>(
243
+ args: string[],
244
+ timeoutMs: number,
245
+ decoder: StudioCliDecoder<T>,
246
+ ): Promise<AstraleResult<T>> {
247
+ return runStudioCliJson(args, decoder, { timeoutMs })
248
+ }
@@ -68,7 +68,7 @@ export function watchDomain(handle: DomainHandle): () => void {
68
68
  broadcast({
69
69
  type: 'schema-diff',
70
70
  domainId: handle.id,
71
- schemaHash: b?.schemaHash ?? 'sha-none',
71
+ renderFingerprint: b?.renderFingerprint ?? 'sha-none',
72
72
  })
73
73
  broadcast({ type: 'anatomy-diff', domainId: handle.id })
74
74
  }, 150)
@@ -0,0 +1,17 @@
1
+ import { expect, test } from 'bun:test'
2
+
3
+ import { buildCatalog } from './catalog'
4
+
5
+ test('workspace catalog contains only discovered domains and never simulated import authorities', () => {
6
+ const catalog = buildCatalog([
7
+ { origin: 'issues.example.dev', id: 'issues' },
8
+ { origin: 'billing.example.dev', id: 'billing-service' },
9
+ ])
10
+
11
+ expect(catalog.map(({ origin, kind }) => ({ origin, kind }))).toEqual([
12
+ { origin: 'kernel.astrale.ai', kind: 'kernel' },
13
+ { origin: 'issues.example.dev', kind: 'local' },
14
+ { origin: 'billing.example.dev', kind: 'local' },
15
+ ])
16
+ expect(catalog.some((entry) => entry.kind === 'external')).toBe(false)
17
+ })
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Display metadata for domains Studio has actually discovered in its workspace.
3
+ * This is not an import registry or an authority for available domains. Until a
4
+ * real registry exists, it contains only the required Kernel and local workspace
5
+ * domains; Studio must not advertise simulated external services as importable.
6
+ */
7
+ import type { DomainCatalogEntry } from '../../shared/types'
8
+
9
+ const icon = (inner: string) =>
10
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`
11
+
12
+ const HEXAGON = icon(
13
+ '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="m7.5 4.27 9 5.15"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
14
+ )
15
+ const BOXES = icon(
16
+ '<path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z"/><path d="m7 16.5-4.74-2.85"/><path d="m7 16.5 5-3"/><path d="M7 16.5v5.17"/><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z"/><path d="m17 16.5-5-3"/><path d="m17 16.5 4.74-2.85"/><path d="M17 16.5v5.17"/><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z"/><path d="M12 8 7.26 5.15"/><path d="m12 8 4.74-2.85"/><path d="M12 13.5V8"/>',
17
+ )
18
+ const KERNEL: DomainCatalogEntry = {
19
+ origin: 'kernel.astrale.ai',
20
+ name: 'Kernel',
21
+ kind: 'kernel',
22
+ description: 'The typed graph every domain is built on.',
23
+ icon: HEXAGON,
24
+ required: true,
25
+ }
26
+
27
+ function humanize(slug: string): string {
28
+ return slug
29
+ .replace(/[-_]+/g, ' ')
30
+ .replace(/\b\w/g, (c) => c.toUpperCase())
31
+ .trim()
32
+ }
33
+
34
+ export function buildCatalog(locals: { origin: string; id: string }[]): DomainCatalogEntry[] {
35
+ const localEntries: DomainCatalogEntry[] = locals.map((d) => ({
36
+ origin: d.origin,
37
+ name: humanize(d.id),
38
+ kind: 'local',
39
+ description: 'A domain in this workspace.',
40
+ icon: BOXES,
41
+ }))
42
+ return [KERNEL, ...localEntries]
43
+ }