@astrale-os/adapter-cloudflare 0.1.9 → 0.2.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/package.json +1 -1
- package/template/.agents/skills/astrale-cli/SKILL.md +1 -1
- package/template/.agents/skills/astrale-domain/SKILL.md +5 -5
- package/template/.env.example +5 -7
- package/template/README.md +2 -2
- package/template/client/README.md +81 -62
- package/template/client/__tests__/app.test.tsx +143 -98
- package/template/client/__tests__/harness.ts +62 -12
- package/template/client/__tests__/kernel.test.ts +40 -51
- package/template/client/__tests__/seam.test.tsx +115 -0
- package/template/client/index.html +1 -1
- package/template/client/package.json +1 -0
- package/template/client/src/app.tsx +34 -83
- package/template/client/src/main.tsx +2 -2
- package/template/client/src/shell/client.ts +67 -0
- package/template/client/src/shell/index.ts +20 -0
- package/template/client/src/shell/invoke.ts +35 -0
- package/template/client/src/shell/transformers.ts +72 -0
- package/template/client/src/shell/use-async.ts +56 -0
- package/template/client/src/shell/use-capability.ts +59 -0
- package/template/client/src/shell/use-node.ts +61 -0
- package/template/client/src/shell/use-shell.ts +91 -0
- package/template/client/src/shell/view-router.tsx +97 -0
- package/template/client/src/status/components/StatusCard.tsx +50 -0
- package/template/client/src/status/components/index.ts +1 -0
- package/template/client/src/status/hooks/index.ts +3 -0
- package/template/client/src/status/hooks/useCheck.mutation.ts +16 -0
- package/template/client/src/status/hooks/useCheckable.query.ts +64 -0
- package/template/client/src/status/index.ts +7 -0
- package/template/client/src/status/status.api.ts +12 -0
- package/template/client/src/status/status.mappers.ts +19 -0
- package/template/client/src/status/status.types.ts +11 -0
- package/template/client/src/styles.css +182 -4
- package/template/client/src/ui/StatusBadge.tsx +31 -0
- package/template/client/src/ui/format.ts +24 -0
- package/template/client/src/ui/index.ts +13 -0
- package/template/client/src/ui/surface.tsx +56 -0
- package/template/client/src/ui/value.tsx +32 -0
- package/template/client/src/views/status.tsx +28 -0
- package/template/client/tsconfig.json +2 -1
- package/template/client/vite.config.ts +11 -13
- package/template/client/vitest.config.ts +11 -5
- package/template/core/monitor/health.ts +34 -0
- package/template/core/monitor/index.ts +9 -0
- package/template/core/monitor/keys.ts +41 -0
- package/template/core/monitor/node.ts +57 -0
- package/template/deps.ts +10 -9
- package/template/domain.ts +1 -1
- package/template/env.ts +2 -9
- package/template/integrations/prober/http.ts +32 -0
- package/template/integrations/prober/mock.ts +18 -0
- package/template/integrations/prober/port.ts +26 -0
- package/template/integrations/prober/registry.ts +65 -0
- package/template/package.json +1 -1
- package/template/pnpm-lock.yaml +2766 -0
- package/template/runtime/index.ts +63 -34
- package/template/runtime/monitor/check.ts +29 -0
- package/template/runtime/monitor/index.ts +9 -0
- package/template/runtime/monitor/seed.ts +95 -0
- package/template/runtime/monitor/watch.ts +31 -0
- package/template/runtime/shared.ts +21 -0
- package/template/runtime/status-page/add.ts +21 -0
- package/template/runtime/status-page/check.ts +50 -0
- package/template/runtime/status-page/create.ts +24 -0
- package/template/runtime/status-page/index.ts +8 -0
- package/template/schema/index.ts +11 -4
- package/template/schema/monitor.ts +94 -0
- package/template/views/index.ts +8 -2
- package/template/views/status-page.ts +16 -0
- package/template/client/src/lib/kernel.ts +0 -135
- package/template/client/src/lib/shell.ts +0 -197
- package/template/client/src/lib/use-node.ts +0 -66
- package/template/client/src/lib/use-shell.ts +0 -85
- package/template/core/keys.ts +0 -28
- package/template/core/note.ts +0 -148
- package/template/integrations/summary/heuristic.ts +0 -25
- package/template/integrations/summary/http.ts +0 -69
- package/template/integrations/summary/port.ts +0 -21
- package/template/integrations/summary/registry.ts +0 -52
- package/template/schema/note.ts +0 -67
- package/template/views/note.ts +0 -21
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Test harness — a FAKE shell parent + a FAKE kernel, so the client's
|
|
3
|
-
* handshake (
|
|
4
|
-
* can be exercised with no real GUI/kernel.
|
|
2
|
+
* Test harness — a FAKE shell parent + a FAKE kernel, so the client's shell
|
|
3
|
+
* handshake (now `@astrale-os/shell`, `createShell({ mode: 'sandboxed' })`) and
|
|
4
|
+
* its kernel calls can be exercised with no real GUI/kernel.
|
|
5
|
+
*
|
|
6
|
+
* `fakeKernelSession()` is the unit-test counterpart: a `KernelClient` whose
|
|
7
|
+
* `call` hits the fake kernel directly (no handshake), for hooks tested in
|
|
8
|
+
* isolation.
|
|
5
9
|
*
|
|
6
10
|
* The fake parent mirrors `runParentHandshake`
|
|
7
11
|
* (`shell/packages/shell/src/application/windowing/handshake.ts`):
|
|
@@ -17,6 +21,8 @@
|
|
|
17
21
|
* the child believes it is framed.
|
|
18
22
|
*/
|
|
19
23
|
|
|
24
|
+
import type { KernelClient } from '@/shell'
|
|
25
|
+
|
|
20
26
|
const INIT_REQUEST_TYPE = 'astrale-shell/init-request'
|
|
21
27
|
const INIT_RESPONSE_TYPE = 'astrale-shell/init-response'
|
|
22
28
|
|
|
@@ -196,26 +202,70 @@ export function ok(result: unknown): { result: unknown } {
|
|
|
196
202
|
return { result }
|
|
197
203
|
}
|
|
198
204
|
|
|
199
|
-
/**
|
|
200
|
-
|
|
205
|
+
/**
|
|
206
|
+
* A `KernelClient` for unit tests that exercise feature hooks WITHOUT a
|
|
207
|
+
* handshake. Its `call` POSTs the kernel JSON envelope (`{ method, params, id }`)
|
|
208
|
+
* so the `installFakeKernel` fetch stub records it and `body.method`/
|
|
209
|
+
* `body.params` assertions hold — same wire the real `shell.kernel` speaks to a
|
|
210
|
+
* JSON kernel. The credential surface (`mintDelegation`/`authToken`/`url`) is
|
|
211
|
+
* stubbed minimally — the isolated hooks under test only ever touch `call`.
|
|
212
|
+
*/
|
|
213
|
+
export function fakeKernelSession(kernelUrl = 'https://k.example.test/api'): KernelClient {
|
|
214
|
+
let idSeq = 0
|
|
215
|
+
return {
|
|
216
|
+
async call(method: string, params: unknown = {}): Promise<unknown> {
|
|
217
|
+
idSeq += 1
|
|
218
|
+
const res = await fetch(kernelUrl, {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: {
|
|
221
|
+
'content-type': 'application/vnd.astrale.kernel+json',
|
|
222
|
+
accept: 'application/vnd.astrale.kernel+json',
|
|
223
|
+
authorization: 'tok-A',
|
|
224
|
+
},
|
|
225
|
+
body: JSON.stringify({ method, params, id: `c${idSeq}` }),
|
|
226
|
+
})
|
|
227
|
+
const obj = (await res.json()) as Record<string, unknown>
|
|
228
|
+
if (obj.error && typeof obj.error === 'object') {
|
|
229
|
+
const err = obj.error as { code?: unknown; message?: unknown }
|
|
230
|
+
const code = typeof err.code === 'number' ? err.code : 5000
|
|
231
|
+
const message = typeof err.message === 'string' ? err.message : 'Unknown error'
|
|
232
|
+
throw new Error(`${code}: ${message}`)
|
|
233
|
+
}
|
|
234
|
+
return obj.result
|
|
235
|
+
},
|
|
236
|
+
mintDelegation: async () => '<fake>',
|
|
237
|
+
authToken: () => '<fake>',
|
|
238
|
+
url: kernelUrl,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Build a minimal kernel `Checkable` node — a StatusPage (or any class via
|
|
244
|
+
* `className`, default `StatusPage`). Carries a domain-qualified `status` prop
|
|
245
|
+
* (`<domain>:class.<className>.property.status`); the name uses the kernel
|
|
246
|
+
* `Named.name` key.
|
|
247
|
+
*/
|
|
248
|
+
export function checkableNode(opts: {
|
|
201
249
|
id: string
|
|
202
250
|
path?: string
|
|
203
251
|
name?: string
|
|
204
|
-
|
|
205
|
-
|
|
252
|
+
status?: string
|
|
253
|
+
className?: string
|
|
206
254
|
domain?: string
|
|
207
255
|
}): { id: string; path: string; class: { raw: string }; props: Record<string, unknown> } {
|
|
208
|
-
const domain = opts.domain ?? '
|
|
256
|
+
const domain = opts.domain ?? 'monitors.astrale.ai'
|
|
257
|
+
const className = opts.className ?? 'StatusPage'
|
|
209
258
|
const props: Record<string, unknown> = {}
|
|
210
259
|
if (opts.name !== undefined) {
|
|
211
260
|
props['kernel.astrale.ai:interface.Named.property.name'] = opts.name
|
|
212
261
|
}
|
|
213
|
-
if (opts.
|
|
214
|
-
|
|
262
|
+
if (opts.status !== undefined) {
|
|
263
|
+
props[`${domain}:class.${className}.property.status`] = opts.status
|
|
264
|
+
}
|
|
215
265
|
return {
|
|
216
266
|
id: opts.id,
|
|
217
|
-
path: opts.path ??
|
|
218
|
-
class: { raw: `/:${domain}:class
|
|
267
|
+
path: opts.path ?? `/${opts.id}`,
|
|
268
|
+
class: { raw: `/:${domain}:class.${className}` },
|
|
219
269
|
props,
|
|
220
270
|
}
|
|
221
271
|
}
|
|
@@ -1,68 +1,57 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { qualifiedString, readProp, readPropBySuffix } from '@/shell'
|
|
4
|
+
import { checkableFromNode } from '@/status'
|
|
5
|
+
import { relativeTime } from '@/ui'
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
import { checkableNode } from './harness'
|
|
7
8
|
|
|
8
|
-
describe('
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
describe('prop readers', () => {
|
|
10
|
+
it('readProp returns the string value at the exact key', () => {
|
|
11
|
+
expect(readProp({ a: 'x', b: 1 }, 'a')).toBe('x')
|
|
12
|
+
expect(readProp({ a: 'x' }, 'missing')).toBeUndefined()
|
|
13
|
+
expect(readProp({ a: 1 }, 'a')).toBeUndefined() // non-string
|
|
13
14
|
})
|
|
14
15
|
|
|
15
|
-
it('
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
expect(req.method).toBe('POST')
|
|
23
|
-
expect(req.url).toBe('https://k.test/api/') // slash added
|
|
24
|
-
expect(req.headers['authorization']).toBe('tok-X')
|
|
25
|
-
expect(req.headers['content-type']).toBe(TYPE)
|
|
26
|
-
expect(req.headers['accept']).toBe(TYPE)
|
|
27
|
-
expect(req.body).toMatchObject({ method: '@n1::get', params: {} })
|
|
28
|
-
expect(req.body.id).toBeTruthy()
|
|
16
|
+
it('readPropBySuffix matches the first key ending with the suffix', () => {
|
|
17
|
+
const props = {
|
|
18
|
+
'monitors.astrale.ai:class.Monitor.property.url': 'https://x.test',
|
|
19
|
+
'kernel.astrale.ai:interface.Named.property.name': 'n',
|
|
20
|
+
}
|
|
21
|
+
expect(readPropBySuffix(props, '.property.url')).toBe('https://x.test')
|
|
22
|
+
expect(readPropBySuffix(props, '.property.status')).toBeUndefined()
|
|
29
23
|
})
|
|
30
24
|
|
|
31
|
-
it('
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
25
|
+
it('qualifiedString prefers a domain key over the kernel one for the same suffix', () => {
|
|
26
|
+
const props = {
|
|
27
|
+
'kernel.astrale.ai:interface.Named.property.name': 'kernel name',
|
|
28
|
+
'monitors.astrale.ai:class.Monitor.property.name': 'domain name',
|
|
29
|
+
}
|
|
30
|
+
expect(qualifiedString(props, 'name')).toBe('domain name')
|
|
36
31
|
})
|
|
32
|
+
})
|
|
37
33
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
'2004: Permission denied',
|
|
34
|
+
describe('checkableFromNode mapper', () => {
|
|
35
|
+
it('projects the name and rolled-up status of a StatusPage node', () => {
|
|
36
|
+
const record = checkableFromNode(
|
|
37
|
+
checkableNode({ id: 'page-1', name: 'Public', status: 'degraded', className: 'StatusPage' }),
|
|
43
38
|
)
|
|
39
|
+
expect(record).toEqual({ name: 'Public', status: 'degraded' })
|
|
44
40
|
})
|
|
45
41
|
|
|
46
|
-
it('
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
it('falls back to the path segment for the name and unknown for a missing status', () => {
|
|
43
|
+
const record = checkableFromNode(checkableNode({ id: 'page-9', path: '/status-pages/edge' }))
|
|
44
|
+
expect(record.name).toBe('edge')
|
|
45
|
+
expect(record.status).toBe('unknown')
|
|
50
46
|
})
|
|
51
47
|
})
|
|
52
48
|
|
|
53
|
-
describe('
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
expect(
|
|
57
|
-
expect(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
it('readPropBySuffix matches the first key ending with the suffix', () => {
|
|
61
|
-
const props = {
|
|
62
|
-
'notes.astrale.ai:class.Note.property.body': 'the body',
|
|
63
|
-
'kernel.astrale.ai:interface.Named.property.name': 'n',
|
|
64
|
-
}
|
|
65
|
-
expect(readPropBySuffix(props, '.property.body')).toBe('the body')
|
|
66
|
-
expect(readPropBySuffix(props, '.property.title')).toBeUndefined()
|
|
49
|
+
describe('relativeTime', () => {
|
|
50
|
+
const now = Date.parse('2026-06-14T12:00:00Z')
|
|
51
|
+
it('renders coarse buckets and a dash for missing input', () => {
|
|
52
|
+
expect(relativeTime(undefined, now)).toBe('—')
|
|
53
|
+
expect(relativeTime('2026-06-14T11:55:00Z', now)).toBe('5m ago')
|
|
54
|
+
expect(relativeTime('2026-06-14T10:00:00Z', now)).toBe('2h ago')
|
|
55
|
+
expect(relativeTime('not-a-date', now)).toBe('not-a-date')
|
|
67
56
|
})
|
|
68
57
|
})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the presentation/logic seam unlocks:
|
|
3
|
+
*
|
|
4
|
+
* 1. PRESENTATION components (`@/ui`) test with ZERO infrastructure — no fake
|
|
5
|
+
* shell, no fake kernel, no session. Just props in, DOM out. (Contrast
|
|
6
|
+
* app.test.tsx, which stands up a handshake + kernel stub to reach the same
|
|
7
|
+
* markup.)
|
|
8
|
+
* 2. FEATURE hooks (`@/status`) test against a bare `KernelClient` (the fake
|
|
9
|
+
* kernel fetch stub) with no handshake — the write lifecycle and the
|
|
10
|
+
* reload signal are verified once, in isolation.
|
|
11
|
+
*/
|
|
12
|
+
import { act, cleanup, render, renderHook, screen, waitFor } from '@testing-library/react'
|
|
13
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
14
|
+
|
|
15
|
+
import { useCheck, useCheckable } from '@/status'
|
|
16
|
+
import { StatusBadge } from '@/ui'
|
|
17
|
+
|
|
18
|
+
import { checkableNode, fakeKernelSession, installFakeKernel, ok } from './harness'
|
|
19
|
+
|
|
20
|
+
afterEach(cleanup)
|
|
21
|
+
|
|
22
|
+
describe('presentation is pure (no kernel, no session, no shell)', () => {
|
|
23
|
+
it('StatusBadge renders the label and tone class for each status', () => {
|
|
24
|
+
const { rerender } = render(<StatusBadge status="up" />)
|
|
25
|
+
expect(screen.getByText('UP').className).toContain('status-up')
|
|
26
|
+
|
|
27
|
+
rerender(<StatusBadge status="degraded" />)
|
|
28
|
+
expect(screen.getByText('DEGRADED').className).toContain('status-degraded')
|
|
29
|
+
|
|
30
|
+
rerender(<StatusBadge status="down" />)
|
|
31
|
+
expect(screen.getByText('DOWN').className).toContain('status-down')
|
|
32
|
+
|
|
33
|
+
rerender(<StatusBadge status="weird" />)
|
|
34
|
+
expect(screen.getByText('UNKNOWN').className).toContain('status-unknown')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('useCheckable — query + reload signal', () => {
|
|
39
|
+
it('loads the record, then exposes reloading across a reload()', async () => {
|
|
40
|
+
const session = fakeKernelSession()
|
|
41
|
+
let status = 'down'
|
|
42
|
+
const kernel = installFakeKernel(() => ok(checkableNode({ id: 'page-1', name: 'API', status })))
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const { result } = renderHook(() => useCheckable(session, 'page-1'))
|
|
46
|
+
await waitFor(() => expect(result.current.state).toBe('ok'))
|
|
47
|
+
expect(result.current.record?.status).toBe('down')
|
|
48
|
+
expect(result.current.reloading).toBe(false)
|
|
49
|
+
|
|
50
|
+
// Server-side status flips; a reload picks it up and flags `reloading`.
|
|
51
|
+
status = 'up'
|
|
52
|
+
act(() => {
|
|
53
|
+
result.current.reload()
|
|
54
|
+
})
|
|
55
|
+
expect(result.current.reloading).toBe(true)
|
|
56
|
+
|
|
57
|
+
await waitFor(() => expect(result.current.reloading).toBe(false))
|
|
58
|
+
expect(result.current.record?.status).toBe('up')
|
|
59
|
+
} finally {
|
|
60
|
+
kernel.restore()
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('surfaces a node-load error', async () => {
|
|
65
|
+
const session = fakeKernelSession()
|
|
66
|
+
const kernel = installFakeKernel(() => ({ error: { code: 3002, message: 'Path not found' } }))
|
|
67
|
+
try {
|
|
68
|
+
const { result } = renderHook(() => useCheckable(session, 'missing'))
|
|
69
|
+
await waitFor(() => expect(result.current.state).toBe('error'))
|
|
70
|
+
expect(result.current.message).toMatch(/Path not found/)
|
|
71
|
+
} finally {
|
|
72
|
+
kernel.restore()
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('useCheck — the shared write-lifecycle', () => {
|
|
78
|
+
it('idle → running → done, then resolves true', async () => {
|
|
79
|
+
const session = fakeKernelSession()
|
|
80
|
+
const kernel = installFakeKernel(() => ok(null))
|
|
81
|
+
try {
|
|
82
|
+
const { result } = renderHook(() => useCheck(session, 'page-1'))
|
|
83
|
+
expect(result.current.phase).toBe('idle')
|
|
84
|
+
|
|
85
|
+
let returned: boolean | undefined
|
|
86
|
+
await act(async () => {
|
|
87
|
+
returned = await result.current.run()
|
|
88
|
+
})
|
|
89
|
+
expect(returned).toBe(true)
|
|
90
|
+
expect(result.current.phase).toBe('done')
|
|
91
|
+
expect(result.current.error).toBeNull()
|
|
92
|
+
} finally {
|
|
93
|
+
kernel.restore()
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('captures a failure: phase failed, error set, resolves false', async () => {
|
|
98
|
+
const session = fakeKernelSession()
|
|
99
|
+
const kernel = installFakeKernel(() => ({
|
|
100
|
+
error: { code: 2004, message: 'Permission denied' },
|
|
101
|
+
}))
|
|
102
|
+
try {
|
|
103
|
+
const { result } = renderHook(() => useCheck(session, 'page-1'))
|
|
104
|
+
let returned: boolean | undefined
|
|
105
|
+
await act(async () => {
|
|
106
|
+
returned = await result.current.run()
|
|
107
|
+
})
|
|
108
|
+
expect(returned).toBe(false)
|
|
109
|
+
expect(result.current.phase).toBe('failed')
|
|
110
|
+
expect(result.current.error).toMatch(/Permission denied/)
|
|
111
|
+
} finally {
|
|
112
|
+
kernel.restore()
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -1,94 +1,45 @@
|
|
|
1
|
-
import type { NodeSession } from './lib/use-node'
|
|
2
|
-
|
|
3
|
-
import { classShortName, type KernelNode, PROP, readProp, readPropBySuffix } from './lib/kernel'
|
|
4
|
-
import { useNode } from './lib/use-node'
|
|
5
|
-
import { useShell } from './lib/use-shell'
|
|
6
|
-
|
|
7
1
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
2
|
+
* Path-based view router for the domain's SPA bundle. The shell mounts each Astrale
|
|
3
|
+
* View in its own iframe at its own mount path, so inside the iframe
|
|
4
|
+
* `window.location.pathname` IS that path. `App` reads it (via `resolveView`) and
|
|
5
|
+
* renders the matching view; an unregistered path shows a self-describing fallback.
|
|
6
|
+
*
|
|
7
|
+
* Today there's one view — the StatusPage panel at `/ui/status-page`. The router
|
|
8
|
+
* (not a direct render) is the extension seam: add a view by
|
|
9
|
+
* 1. writing a `ViewComponent` (usually wrapping `ViewFrame` — see `views/`),
|
|
10
|
+
* 2. adding a `ROUTES` entry keyed by its mount path,
|
|
11
|
+
* 3. registering a matching `defineView({ mount: '/ui/<path>' })` in `views/`.
|
|
13
12
|
*/
|
|
14
|
-
export function App() {
|
|
15
|
-
const { status, session, nodeId, reason } = useShell()
|
|
16
|
-
|
|
17
|
-
return (
|
|
18
|
-
<div className="wrap">
|
|
19
|
-
<div className="card">
|
|
20
|
-
{status === 'loading' && (
|
|
21
|
-
<>
|
|
22
|
-
<h1 className="title">Note</h1>
|
|
23
|
-
<p className="subline">ui-note · Astrale view SPA</p>
|
|
24
|
-
<p className="muted">Waiting for the shell handshake…</p>
|
|
25
|
-
</>
|
|
26
|
-
)}
|
|
27
13
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
<h1 className="title">Note</h1>
|
|
31
|
-
<p className="subline">ui-note · Astrale view SPA</p>
|
|
32
|
-
<div className="banner">
|
|
33
|
-
No parent shell ({reason}). This view is meant to be mounted by the Astrale GUI, which
|
|
34
|
-
hands it a target node and a kernel token. Showing a standalone preview.
|
|
35
|
-
</div>
|
|
36
|
-
<p className="body muted">
|
|
37
|
-
When mounted by the shell, this card renders the Note you opened.
|
|
38
|
-
</p>
|
|
39
|
-
</>
|
|
40
|
-
)}
|
|
14
|
+
import { resolveView, useShell, type ViewRoutes } from '@/shell'
|
|
15
|
+
import { StatusView } from '@/views/status'
|
|
41
16
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
</div>
|
|
45
|
-
)
|
|
17
|
+
const ROUTES: ViewRoutes = {
|
|
18
|
+
'/ui/status-page': StatusView,
|
|
46
19
|
}
|
|
47
20
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
* handshake is `ready`, so `session` is non-null here).
|
|
52
|
-
*/
|
|
53
|
-
function NoteCard({ session, nodeId }: { session: NodeSession; nodeId: string | undefined }) {
|
|
54
|
-
const state = useNode(session, nodeId)
|
|
55
|
-
|
|
56
|
-
const title =
|
|
57
|
-
state.status === 'ok' ? (readProp(state.node.props, PROP.named.name) ?? 'Note') : 'Note'
|
|
58
|
-
|
|
59
|
-
return (
|
|
60
|
-
<>
|
|
61
|
-
<h1 className="title">{title}</h1>
|
|
62
|
-
<p className="subline">
|
|
63
|
-
{state.status === 'ok'
|
|
64
|
-
? `${classShortName(state.node)} · ${state.node.path}`
|
|
65
|
-
: 'ui-note · Astrale view SPA'}
|
|
66
|
-
</p>
|
|
67
|
-
|
|
68
|
-
{state.status === 'idle' && (
|
|
69
|
-
<p className="body muted">Handshake complete, but no target node / token yet.</p>
|
|
70
|
-
)}
|
|
71
|
-
|
|
72
|
-
{state.status === 'loading' && <p className="muted">Loading the Note…</p>}
|
|
21
|
+
export function App() {
|
|
22
|
+
const shell = useShell()
|
|
23
|
+
const View = resolveView(ROUTES)
|
|
73
24
|
|
|
74
|
-
|
|
75
|
-
<div className="banner">Failed to load the Note: {state.message}</div>
|
|
76
|
-
)}
|
|
25
|
+
if (!View) return <UnknownView />
|
|
77
26
|
|
|
78
|
-
|
|
79
|
-
</>
|
|
80
|
-
)
|
|
27
|
+
return <>{View(shell)}</>
|
|
81
28
|
}
|
|
82
29
|
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
30
|
+
/** Fallback when the iframe's mount path has no registered view. */
|
|
31
|
+
function UnknownView() {
|
|
32
|
+
return (
|
|
33
|
+
<div className="wrap">
|
|
34
|
+
<div className="card">
|
|
35
|
+
<h1 className="title">No view here</h1>
|
|
36
|
+
<p className="subline">Astrale view SPA</p>
|
|
37
|
+
<div className="banner">
|
|
38
|
+
No view registered for {window.location.pathname}. Add a ROUTES entry in{' '}
|
|
39
|
+
<code>src/app.tsx</code> (and a matching <code>defineView({'{ mount }'})</code> in the
|
|
40
|
+
domain's <code>views/</code>).
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
94
45
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createRoot } from 'react-dom/client'
|
|
2
2
|
|
|
3
3
|
import './styles.css'
|
|
4
|
-
import { App } from '
|
|
4
|
+
import { App } from '@/app'
|
|
5
5
|
|
|
6
6
|
const root = document.getElementById('root')
|
|
7
|
-
if (!root) throw new Error('
|
|
7
|
+
if (!root) throw new Error('astrale view client: #root missing from index.html')
|
|
8
8
|
|
|
9
9
|
createRoot(root).render(<App />)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph/prop helpers for the nodes the kernel returns — reading fully-qualified
|
|
3
|
+
* props and short class names off a `KernelNode`. Pure shaping: the kernel
|
|
4
|
+
* TRANSPORT now lives in `@astrale-os/shell` (`shell.kernel` → `session.call`),
|
|
5
|
+
* so this file no longer speaks the wire.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Prop key constants — the graph stores props with fully-qualified keys
|
|
10
|
+
* (`<domain>:<member>.property.<name>`). The kernel `Named.name` key is fixed
|
|
11
|
+
* and known; domain props (`url`, `status`) are qualified by the (build-time
|
|
12
|
+
* unknown) domain origin — read those by suffix with `readPropBySuffix`.
|
|
13
|
+
*/
|
|
14
|
+
export const PROP = {
|
|
15
|
+
named: {
|
|
16
|
+
name: 'kernel.astrale.ai:interface.Named.property.name',
|
|
17
|
+
},
|
|
18
|
+
} as const
|
|
19
|
+
|
|
20
|
+
export type KernelNode = {
|
|
21
|
+
id: string
|
|
22
|
+
path: string
|
|
23
|
+
class: string | { raw?: string }
|
|
24
|
+
props: Record<string, unknown>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readProp(props: Record<string, unknown>, key: string): string | undefined {
|
|
28
|
+
const v = props[key]
|
|
29
|
+
return typeof v === 'string' ? v : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a string prop by key suffix — for domain-qualified props whose full key
|
|
34
|
+
* embeds the (build-time-unknown) domain origin. e.g.
|
|
35
|
+
* `readPropBySuffix(props, '.property.url')`.
|
|
36
|
+
*/
|
|
37
|
+
export function readPropBySuffix(
|
|
38
|
+
props: Record<string, unknown>,
|
|
39
|
+
suffix: string,
|
|
40
|
+
): string | undefined {
|
|
41
|
+
for (const [k, v] of Object.entries(props)) {
|
|
42
|
+
if (k.endsWith(suffix) && typeof v === 'string') return v
|
|
43
|
+
}
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Short class name from a `class.raw` path `/:<domain>:class.<Name>` → `<Name>`. */
|
|
48
|
+
export function classShortName(node: KernelNode): string {
|
|
49
|
+
const raw = (typeof node.class === 'string' ? node.class : node.class?.raw) ?? ''
|
|
50
|
+
const last = raw.split(':').pop() ?? ''
|
|
51
|
+
const dot = last.indexOf('.')
|
|
52
|
+
return dot >= 0 ? last.slice(dot + 1) : last
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Operator-facing message for a failed kernel call. Errors from the shell's
|
|
57
|
+
* kernel client carry a numeric `code` (e.g. `NotFoundError` → `code 3002`) with
|
|
58
|
+
* the raw server text in `message`; surface them as `<code>: <message>` so the
|
|
59
|
+
* code stays visible. Plain errors fall back to their message.
|
|
60
|
+
*/
|
|
61
|
+
export function errorMessage(err: unknown): string {
|
|
62
|
+
if (err instanceof Error) {
|
|
63
|
+
const code = (err as { code?: unknown }).code
|
|
64
|
+
return typeof code === 'number' ? `${code}: ${err.message}` : err.message
|
|
65
|
+
}
|
|
66
|
+
return String(err)
|
|
67
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kernel boundary — everything for talking to the kernel and the GUI
|
|
3
|
+
* shell, plus the common read/write primitives features build their
|
|
4
|
+
* `.query`/`.mutation` hooks on. Import from the barrel: `@/shell`.
|
|
5
|
+
*/
|
|
6
|
+
export { classShortName, errorMessage, PROP, readProp, readPropBySuffix } from './client'
|
|
7
|
+
export type { KernelNode } from './client'
|
|
8
|
+
export { callMethod, invokeNode, nodeAddr } from './invoke'
|
|
9
|
+
export type { KernelClient } from '@astrale-os/shell'
|
|
10
|
+
export { useNode } from './use-node'
|
|
11
|
+
export type { NodeState } from './use-node'
|
|
12
|
+
export { useShell } from './use-shell'
|
|
13
|
+
export type { ShellState, ShellStatus } from './use-shell'
|
|
14
|
+
export { useAsync } from './use-async'
|
|
15
|
+
export type { AsyncResource, AsyncState } from './use-async'
|
|
16
|
+
export { useCapability } from './use-capability'
|
|
17
|
+
export type { Capability, Phase } from './use-capability'
|
|
18
|
+
export { asNodeArray, linkTargets, qualifiedProp, qualifiedString } from './transformers'
|
|
19
|
+
export { resolveView, ViewFrame } from './view-router'
|
|
20
|
+
export type { ViewComponent, ViewRoutes } from './view-router'
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kernel invocation primitives — the single place a view reaches the kernel,
|
|
3
|
+
* over the shell's proper `KernelClient` (`session.call`, wired by the sandboxed
|
|
4
|
+
* handshake: fresh delegation token per call, redirects followed). `callMethod`
|
|
5
|
+
* is the generic method-ref call; `invokeNode` is instance dispatch by node id
|
|
6
|
+
* (`@<id>::<method>` — the shell hands views node IDs, not paths); `nodeAddr`
|
|
7
|
+
* picks the stable address for a node.
|
|
8
|
+
*/
|
|
9
|
+
import type { KernelClient } from '@astrale-os/shell'
|
|
10
|
+
|
|
11
|
+
import type { KernelNode } from './client'
|
|
12
|
+
|
|
13
|
+
/** One kernel call through the live session (proper client: fresh token, redirects). */
|
|
14
|
+
export function callMethod(
|
|
15
|
+
session: KernelClient,
|
|
16
|
+
method: string,
|
|
17
|
+
params: Record<string, unknown> = {},
|
|
18
|
+
): Promise<unknown> {
|
|
19
|
+
return session.call(method, params)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Instance-method dispatch by node id. */
|
|
23
|
+
export function invokeNode(
|
|
24
|
+
session: KernelClient,
|
|
25
|
+
nodeId: string,
|
|
26
|
+
method: string,
|
|
27
|
+
params: Record<string, unknown> = {},
|
|
28
|
+
): Promise<unknown> {
|
|
29
|
+
return callMethod(session, `@${nodeId}::${method}`, params)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Stable instance address: tree path when known, else `@id`. */
|
|
33
|
+
export function nodeAddr(node: KernelNode): string {
|
|
34
|
+
return node.path && node.path.startsWith('/') ? node.path : `@${node.id}`
|
|
35
|
+
}
|