@astrale-os/adapter-cloudflare 0.3.0 → 0.3.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 (62) hide show
  1. package/LICENSE +202 -0
  2. package/package.json +4 -4
  3. package/template/.agents/skills/astrale-cli/SKILL.md +633 -0
  4. package/template/.domain-studio/comments.json +4 -0
  5. package/template/.env.example +3 -6
  6. package/template/CLAUDE.md +8 -6
  7. package/template/README.md +28 -19
  8. package/template/client/__tests__/app.test.tsx +39 -248
  9. package/template/client/__tests__/harness.ts +0 -47
  10. package/template/client/__tests__/shell.test.ts +46 -0
  11. package/template/client/package.json +1 -1
  12. package/template/client/src/app.tsx +6 -8
  13. package/template/client/src/shell/index.ts +1 -0
  14. package/template/client/src/shell/transformers.ts +36 -33
  15. package/template/client/src/shell/use-node.ts +21 -13
  16. package/template/client/src/ui/index.ts +4 -7
  17. package/template/core/README.md +23 -0
  18. package/template/deps.ts +10 -9
  19. package/template/domain.ts +11 -9
  20. package/template/env.ts +3 -5
  21. package/template/functions/index.ts +14 -25
  22. package/template/icons.ts +25 -0
  23. package/template/integrations/README.md +22 -0
  24. package/template/package.json +3 -3
  25. package/template/runtime/index.ts +25 -72
  26. package/template/schema/index.ts +10 -10
  27. package/template/tsconfig.json +1 -0
  28. package/template/views/index.ts +15 -12
  29. package/template/client/__tests__/kernel.test.ts +0 -77
  30. package/template/client/__tests__/seam.test.tsx +0 -115
  31. package/template/client/src/status/components/StatusCard.tsx +0 -160
  32. package/template/client/src/status/components/index.ts +0 -1
  33. package/template/client/src/status/hooks/index.ts +0 -3
  34. package/template/client/src/status/hooks/useCheck.mutation.ts +0 -16
  35. package/template/client/src/status/hooks/useCheckable.query.ts +0 -72
  36. package/template/client/src/status/index.ts +0 -7
  37. package/template/client/src/status/status.api.ts +0 -29
  38. package/template/client/src/status/status.mappers.ts +0 -102
  39. package/template/client/src/status/status.types.ts +0 -26
  40. package/template/client/src/ui/StatusBadge.tsx +0 -31
  41. package/template/client/src/views/status.tsx +0 -28
  42. package/template/core/monitor/health.ts +0 -34
  43. package/template/core/monitor/index.ts +0 -9
  44. package/template/core/monitor/keys.ts +0 -41
  45. package/template/core/monitor/node.ts +0 -63
  46. package/template/integrations/prober/http.ts +0 -32
  47. package/template/integrations/prober/mock.ts +0 -18
  48. package/template/integrations/prober/port.ts +0 -26
  49. package/template/integrations/prober/registry.ts +0 -65
  50. package/template/runtime/monitoring/index.ts +0 -8
  51. package/template/runtime/monitoring/monitor/check.ts +0 -29
  52. package/template/runtime/monitoring/monitor/index.ts +0 -10
  53. package/template/runtime/monitoring/monitor/seed.ts +0 -104
  54. package/template/runtime/monitoring/monitor/watch.ts +0 -31
  55. package/template/runtime/monitoring/page/add.ts +0 -21
  56. package/template/runtime/monitoring/page/check.ts +0 -50
  57. package/template/runtime/monitoring/page/create.ts +0 -24
  58. package/template/runtime/monitoring/page/index.ts +0 -9
  59. package/template/runtime/shared.ts +0 -21
  60. package/template/schema/monitor.ts +0 -92
  61. package/template/views/status-page.ts +0 -16
  62. package/template/views/welcome.ts +0 -35
@@ -1,9 +1,12 @@
1
1
  # astrale-domain
2
2
 
3
-
4
3
  A standalone [Astrale](https://astrale.ai) domain, deployed as an Astrale-managed
5
4
  service (default) or as a worker on your own Cloudflare account.
6
5
 
6
+ This is a **blank skeleton** — every folder is wired up and ready, but the domain
7
+ ships with no classes, interfaces, views, or integrations. Fill in the terrain;
8
+ nothing here is a demo you have to delete.
9
+
7
10
  ## For AI agents
8
11
 
9
12
  The complete domain-authoring guide is the `astrale-domain` skill (schema
@@ -30,30 +33,36 @@ astrale domain install <url> --direct # mount it on an instance (CLI)
30
33
  pnpm prod # deploy prod → prints a URL
31
34
  ```
32
35
 
36
+ A freshly-scaffolded skeleton deploys and installs as-is (an empty domain with no
37
+ members) — then you add classes and handlers.
38
+
33
39
  ## What you write
34
40
 
35
41
  ```
36
- schema/ classes + interfaces (the data model) + compiled accessors
37
- core/<context>/ pure, transport-agnostic logic per bounded context (e.g. core/monitor)
42
+ schema/ classes + interfaces + edges (the data model) + compiled accessors
43
+ icons.ts class icons (raw SVG), attached via nodeClass({ icon })
44
+ core/<context>/ pure, transport-agnostic logic per bounded context
38
45
  integrations/ external-API ports + adapters + a lazy registry (built into deps)
39
46
  runtime/ the execute() handlers — the composition root (the methods map)
40
- views/ iframe-mountable UIs
41
- functions/ standalone Functions (e.g. webhooks)
47
+ views/ iframe-mountable UIs (inline HTML or a client/ SPA)
48
+ functions/ standalone Functions (e.g. webhooks, a postInstall seed)
42
49
  client/ the SPA served under /ui
43
50
  deps.ts env → typed Deps container (what handlers read as ctx.deps)
44
51
  domain.ts wires it all together — the worker-safe definition
45
52
  astrale.config.ts binds the domain to its deploy adapter (node-only)
46
53
  ```
47
54
 
48
- `domain.ts` is the one place the domain contract is declared: it imports the
49
- modules above and passes them to `defineDomain({ schema, methods, deps, views,
50
- functions, })` alongside the identity (`origin`, `requires`, `postInstall`).
51
- Nothing is discovered by folder name a renamed or mistyped module is a compile
52
- error at that call, never a silently-missing route. Drop a field (`deps`,
53
- `views`, `functions`) when the domain has none. `astrale.config.ts` then binds
54
- that domain to a deploy adapter with `deploy(domain, cloudflare({ }))` and
55
- chooses, per env, which `client` assets serve mounted `/ui` views — keeping the
56
- node-only adapter (wrangler, filesystem, frontend builds) out of the worker
55
+ `schema/`, `runtime/`, `views/`, and `functions/` start with empty registries;
56
+ `core/` and `integrations/` are empty folders with a short README each explaining
57
+ the pattern. `domain.ts` is the one place the domain contract is declared: it
58
+ imports the modules above and passes them to `defineDomain({ schema, methods,
59
+ deps, views, functions, })` alongside the identity (`origin`, `requires`,
60
+ `postInstall`). Nothing is discovered by folder name a renamed or mistyped
61
+ module is a compile error at that call, never a silently-missing route. Drop a
62
+ field (`deps`, `views`, `functions`) when the domain has none. `astrale.config.ts`
63
+ then binds that domain to a deploy adapter with `deploy(domain, cloudflare({ }))`
64
+ and chooses, per env, which `client` assets serve mounted `/ui` views — keeping
65
+ the node-only adapter (wrangler, filesystem, frontend builds) out of the worker
57
66
  bundle.
58
67
 
59
68
  Everything else — the Worker entry, the wrangler config, the signing identity —
@@ -97,11 +106,11 @@ adapter-owned keys `name`, `main`, `assets`, `routes` are rejected (use
97
106
 
98
107
  - **Edit a handler** (`core/` logic or `runtime/` wiring) → hot-reloads at the same URL. Nothing to reinstall.
99
108
  - **Edit the schema** (`schema/`) → rebuilds the graph; reinstall with `astrale domain install <url> --direct`.
100
- - **`postInstall`** (the static `Monitor.seed` method) runs once after install, as
101
- the system identity, so the domain can seed `/monitoring/monitors` and
102
- `/monitoring/pages/status` and set its own grants. It
103
- must be a class-hosted static addressed by a typed colon-path
104
- (`/:origin:class.X:seed`) — the kernel refuses tree paths here.
109
+ - **`postInstall`** author a standalone `seed` function in `functions/`, reference
110
+ it from `domain.ts` (`postInstall: functions.seed`), and the kernel runs it once
111
+ after install, as the system identity, so the domain can lay down its initial
112
+ graph and set its own grants. Functions are first-class domain members,
113
+ addressable at `/:origin:function.seed`.
105
114
 
106
115
  ## Identity
107
116
 
@@ -1,275 +1,66 @@
1
- import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
1
+ import { cleanup, render, renderHook, screen, waitFor } from '@testing-library/react'
2
2
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
3
3
 
4
4
  import { App } from '@/app'
5
+ import { useShell } from '@/shell'
5
6
 
6
- import { checkableNode, installFakeKernel, installFakeParent, ok } from './harness'
7
-
8
- const KERNEL_URL = 'https://k.example.test/api'
7
+ import { installFakeParent, type FakeParent } from './harness'
9
8
 
10
9
  /**
11
- * The router picks a view from `window.location.pathname`, so every test mounts
12
- * at the StatusPage view's path. `replaceState` rewrites the happy-dom location
13
- * without a reload; the fallback test overrides it.
10
+ * The router picks a view from `window.location.pathname`. This skeleton has no
11
+ * views registered, so every path falls through to the fallback — that's the
12
+ * test below. The second block exercises the shell handshake plumbing (via the
13
+ * fake parent) so you know the seam works the moment you add a real view.
14
+ *
15
+ * `replaceState` rewrites the happy-dom location without a reload.
14
16
  */
15
17
  function mountAt(path: string) {
16
18
  window.history.replaceState({}, '', path)
17
19
  }
18
20
 
19
21
  beforeEach(() => {
20
- mountAt('/ui/status-page')
21
- })
22
-
23
- afterEach(() => {
24
- cleanup()
22
+ mountAt('/ui/anything')
25
23
  })
26
24
 
27
- /**
28
- * Let the handshake + any kernel fetches settle, flushing the resulting React
29
- * state updates inside `act`. The handshake completes over a real MessagePort
30
- * (async hops), so a microtask isn't enough — a short timer pump is.
31
- */
32
- async function flush(ms = 20) {
33
- await act(async () => {
34
- await new Promise((r) => setTimeout(r, ms))
35
- })
36
- }
25
+ afterEach(cleanup)
37
26
 
38
- function handshake(targetNodeId?: string) {
39
- return installFakeParent({
40
- windowId: 'win-1',
41
- kernelUrl: KERNEL_URL,
42
- functionId: 'ui-status-page',
43
- delegationToken: 'tok-A',
44
- tokenExpiresAt: Date.now() + 3_600_000,
45
- targetNodeId,
27
+ describe('router fallback (no views registered)', () => {
28
+ it('renders the self-describing fallback for any path', () => {
29
+ render(<App />)
30
+ expect(screen.getByText('No view here')).toBeTruthy()
31
+ expect(screen.getByText(/No view registered for/)).toBeTruthy()
46
32
  })
47
- }
48
-
49
- /** A StatusPage node fixture — its rolled-up `status`, no `url`. */
50
- const page = (id: string, status: string, name = 'Public status') =>
51
- checkableNode({ id, name, status, className: 'StatusPage' })
52
-
53
- const MONITOR_PATH = '/monitoring/monitors/astrale'
54
-
55
- const watchLinks = (critical = true) => [
56
- {
57
- class: { raw: '/:monitors.astrale.ai:class.watches' },
58
- target: { raw: MONITOR_PATH },
59
- props: { 'monitors.astrale.ai:class.watches.property.critical': critical },
60
- },
61
- ]
62
-
63
- const monitor = (status: string, statusCode = 200) =>
64
- checkableNode({
65
- id: 'monitor-1',
66
- path: MONITOR_PATH,
67
- name: 'Astrale homepage',
68
- status,
69
- statusCode,
70
- latencyMs: 84,
71
- lastCheckedAt: '2026-06-14T11:55:00Z',
72
- url: 'https://astrale.ai',
73
- className: 'Monitor',
74
- })
75
-
76
- describe('status view (/ui/status-page)', () => {
77
- it('loads the StatusPage node and renders its watched monitors', async () => {
78
- const parent = handshake('page-1')
79
- const kernel = installFakeKernel((body) => {
80
- if (body.method === '@page-1::get') return ok(page('page-1', 'degraded'))
81
- if (body.method === '@page-1::getLinks') return ok(watchLinks())
82
- if (body.method === `${MONITOR_PATH}::get`) return ok(monitor('up'))
83
- return { error: { code: 3002, message: `unexpected: ${body.method}` } }
84
- })
85
-
86
- try {
87
- render(<App />)
88
- await flush()
89
-
90
- // Parent observed the child's handshakeAck.
91
- await expect(parent.ack).resolves.toBeUndefined()
92
-
93
- expect(screen.getByText('Public status')).toBeTruthy()
94
- const badge = screen.getByText('DEGRADED')
95
- expect(badge.className).toContain('status-degraded')
96
- expect(screen.getByText('Astrale homepage')).toBeTruthy()
97
- expect(screen.getByText('astrale.ai')).toBeTruthy()
98
- expect(screen.getByText('Critical')).toBeTruthy()
99
- expect(screen.getByText('84ms')).toBeTruthy()
100
-
101
- expect(kernel.calls.map((c) => c.body.method)).toEqual([
102
- '@page-1::get',
103
- '@page-1::getLinks',
104
- `${MONITOR_PATH}::get`,
105
- ])
106
- } finally {
107
- kernel.restore()
108
- parent.restore()
109
- }
110
- })
111
-
112
- it('clicks "Check now", issues @<id>::check, and reflects the refreshed status', async () => {
113
- const parent = handshake('page-1')
114
- // First ::get is down; ::check rolls up server-side; the post-check ::get is up.
115
- let checked = false
116
- const kernel = installFakeKernel((body) => {
117
- if (body.method === '@page-1::check') {
118
- checked = true
119
- return ok(null)
120
- }
121
- if (body.method === '@page-1::getLinks') return ok(watchLinks())
122
- if (body.method === `${MONITOR_PATH}::get`) return ok(monitor(checked ? 'up' : 'down'))
123
- return ok(page('page-1', checked ? 'up' : 'down'))
124
- })
125
-
126
- try {
127
- render(<App />)
128
- await flush()
129
-
130
- expect(screen.getAllByText('DOWN').length).toBeGreaterThan(0)
131
- expect(kernel.calls).toHaveLength(3)
132
- expect(kernel.calls[0]!.body.method).toBe('@page-1::get')
133
-
134
- await act(async () => {
135
- fireEvent.click(screen.getByText('Check now'))
136
- })
137
- await flush()
138
-
139
- // A ::check call was issued, then the node was re-fetched.
140
- expect(kernel.calls.map((c) => c.body.method)).toEqual([
141
- '@page-1::get',
142
- '@page-1::getLinks',
143
- `${MONITOR_PATH}::get`,
144
- '@page-1::check',
145
- '@page-1::get',
146
- '@page-1::getLinks',
147
- `${MONITOR_PATH}::get`,
148
- ])
149
- expect(screen.getAllByText('UP').length).toBeGreaterThan(0)
150
- } finally {
151
- kernel.restore()
152
- parent.restore()
153
- }
154
- })
155
-
156
- it('surfaces a check failure without losing the loaded record', async () => {
157
- const parent = handshake('page-1')
158
- const kernel = installFakeKernel((body) => {
159
- if (body.method === '@page-1::check') {
160
- return { error: { code: 2004, message: 'Permission denied' } }
161
- }
162
- if (body.method === '@page-1::getLinks') return ok(watchLinks())
163
- if (body.method === `${MONITOR_PATH}::get`) return ok(monitor('up'))
164
- return ok(page('page-1', 'up'))
165
- })
166
-
167
- try {
168
- render(<App />)
169
- await flush()
170
-
171
- await act(async () => {
172
- fireEvent.click(screen.getByText('Check now'))
173
- })
174
- await flush()
175
-
176
- // The proper client maps the error code to a typed error, so the operator
177
- // sees the clean server message (no `<code>:` prefix).
178
- expect(screen.getByText(/Check failed:/)).toBeTruthy()
179
- expect(screen.getByText(/Permission denied/)).toBeTruthy()
180
- // The record is still shown.
181
- expect(screen.getByText('Public status')).toBeTruthy()
182
- } finally {
183
- kernel.restore()
184
- parent.restore()
185
- }
186
- })
187
-
188
- it('renders the kernel error on the node-load error path', async () => {
189
- const parent = handshake('missing-1')
190
- const kernel = installFakeKernel(() => ({ error: { code: 3002, message: 'Path not found' } }))
33
+ })
191
34
 
192
- try {
193
- render(<App />)
194
- await flush()
35
+ describe('shell handshake plumbing', () => {
36
+ let parent: FakeParent | undefined
195
37
 
196
- expect(screen.getByText(/Failed to load/)).toBeTruthy()
197
- expect(screen.getByText(/Path not found/)).toBeTruthy()
198
- } finally {
199
- kernel.restore()
200
- parent.restore()
201
- }
38
+ afterEach(() => {
39
+ parent?.restore()
40
+ parent = undefined
202
41
  })
203
42
 
204
- it('re-fetches on a setTarget hot-swap', async () => {
205
- const parent = handshake('page-1')
206
- const kernel = installFakeKernel((body) => {
207
- if (body.method.endsWith('::getLinks')) return ok([])
208
- return body.method === '@page-2::get'
209
- ? ok(page('page-2', 'down', 'Internal status'))
210
- : ok(page('page-1', 'up', 'Public status'))
43
+ it('reaches `ready` with a kernel session after the parent handshake', async () => {
44
+ parent = installFakeParent({
45
+ windowId: 'w-1',
46
+ kernelUrl: 'https://k.example.test/api',
47
+ functionId: 'fn-1',
48
+ delegationToken: 'tok-A',
49
+ tokenExpiresAt: Date.now() + 60_000,
50
+ targetNodeId: 'node-1',
211
51
  })
212
52
 
213
- try {
214
- render(<App />)
215
- await flush()
216
- expect(screen.getByText('Public status')).toBeTruthy()
217
- expect(screen.getAllByText('UP').length).toBeGreaterThan(0)
218
-
219
- // Parent pushes a new target node.
220
- parent.setTarget('page-2')
221
- await flush()
222
-
223
- expect(screen.getByText('Internal status')).toBeTruthy()
224
- expect(screen.getByText('DOWN')).toBeTruthy()
225
- } finally {
226
- kernel.restore()
227
- parent.restore()
228
- }
229
- })
230
-
231
- it('explains a missing target instead of a blank screen', async () => {
232
- const parent = handshake(undefined)
233
- const kernel = installFakeKernel(() => ok(null))
234
- try {
235
- render(<App />)
236
- await flush()
237
-
238
- expect(screen.getByText(/No target node/)).toBeTruthy()
239
- expect(kernel.calls).toHaveLength(0)
240
- } finally {
241
- kernel.restore()
242
- parent.restore()
243
- }
53
+ const { result } = renderHook(() => useShell())
54
+ // waitFor polls inside `act`, so the handshake's async state updates land
55
+ // wrapped — the parent's `ack` promise need not be awaited separately.
56
+ await waitFor(() => expect(result.current.status).toBe('ready'))
57
+ expect(result.current.session).not.toBeNull()
58
+ expect(result.current.nodeId).toBe('node-1')
244
59
  })
245
60
 
246
61
  it('falls back to a standalone preview with no parent', async () => {
247
- // No fake parent the shell sees window.parent === window and stands alone.
248
- const kernel = installFakeKernel(() => ok(null))
249
- try {
250
- render(<App />)
251
- await flush()
252
-
253
- expect(screen.getByText(/No parent shell/)).toBeTruthy()
254
- expect(kernel.calls).toHaveLength(0)
255
- } finally {
256
- kernel.restore()
257
- }
258
- })
259
- })
260
-
261
- describe('router fallback', () => {
262
- it('renders "No view registered" for an unregistered path', async () => {
263
- mountAt('/ui/nope')
264
- const kernel = installFakeKernel(() => ok(null))
265
- try {
266
- render(<App />)
267
- await flush()
268
-
269
- expect(screen.getByText(/No view registered for \/ui\/nope/)).toBeTruthy()
270
- expect(kernel.calls).toHaveLength(0)
271
- } finally {
272
- kernel.restore()
273
- }
62
+ const { result } = renderHook(() => useShell())
63
+ await waitFor(() => expect(result.current.status).toBe('standalone'))
64
+ expect(result.current.session).toBeNull()
274
65
  })
275
66
  })
@@ -238,50 +238,3 @@ export function fakeKernelSession(kernelUrl = 'https://k.example.test/api'): Ker
238
238
  url: kernelUrl,
239
239
  }
240
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: {
249
- id: string
250
- path?: string
251
- name?: string
252
- status?: string
253
- url?: string
254
- statusCode?: number
255
- latencyMs?: number
256
- lastCheckedAt?: string
257
- className?: string
258
- domain?: string
259
- }): { id: string; path: string; class: { raw: string }; props: Record<string, unknown> } {
260
- const domain = opts.domain ?? 'monitors.astrale.ai'
261
- const className = opts.className ?? 'StatusPage'
262
- const props: Record<string, unknown> = {}
263
- if (opts.name !== undefined) {
264
- props['kernel.astrale.ai:interface.Named.property.name'] = opts.name
265
- }
266
- if (opts.status !== undefined) {
267
- props[`${domain}:class.${className}.property.status`] = opts.status
268
- }
269
- if (opts.url !== undefined) {
270
- props[`${domain}:class.${className}.property.url`] = opts.url
271
- }
272
- if (opts.statusCode !== undefined) {
273
- props[`${domain}:class.${className}.property.statusCode`] = opts.statusCode
274
- }
275
- if (opts.latencyMs !== undefined) {
276
- props[`${domain}:class.${className}.property.latencyMs`] = opts.latencyMs
277
- }
278
- if (opts.lastCheckedAt !== undefined) {
279
- props[`${domain}:class.${className}.property.lastCheckedAt`] = opts.lastCheckedAt
280
- }
281
- return {
282
- id: opts.id,
283
- path: opts.path ?? `/${opts.id}`,
284
- class: { raw: `/:${domain}:class.${className}` },
285
- props,
286
- }
287
- }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The kernel-boundary plumbing every view builds on, tested with ZERO
3
+ * infrastructure — no shell, no kernel, no session. Props in, values out. These
4
+ * are the helpers `@/shell` exposes for reading qualified node props (which the
5
+ * kernel stores under domain-prefixed keys) plus the shared display formatters.
6
+ * Your feature mappers (`<feature>.mappers.ts`) build on these.
7
+ */
8
+ import { describe, expect, it } from 'vitest'
9
+
10
+ import { qualifiedString, readProp, readPropBySuffix } from '@/shell'
11
+ import { relativeTime } from '@/ui'
12
+
13
+ describe('prop readers', () => {
14
+ it('readProp returns the string value at the exact key', () => {
15
+ expect(readProp({ a: 'x', b: 1 }, 'a')).toBe('x')
16
+ expect(readProp({ a: 'x' }, 'missing')).toBeUndefined()
17
+ expect(readProp({ a: 1 }, 'a')).toBeUndefined() // non-string
18
+ })
19
+
20
+ it('readPropBySuffix matches the first key ending with the suffix', () => {
21
+ const props = {
22
+ 'acme.example.dev:class.Widget.property.label': 'Hello',
23
+ 'kernel.astrale.ai:interface.Named.property.name': 'n',
24
+ }
25
+ expect(readPropBySuffix(props, '.property.label')).toBe('Hello')
26
+ expect(readPropBySuffix(props, '.property.status')).toBeUndefined()
27
+ })
28
+
29
+ it('qualifiedString prefers a domain key over the kernel one for the same suffix', () => {
30
+ const props = {
31
+ 'kernel.astrale.ai:interface.Named.property.name': 'kernel name',
32
+ 'acme.example.dev:class.Widget.property.name': 'domain name',
33
+ }
34
+ expect(qualifiedString(props, 'name')).toBe('domain name')
35
+ })
36
+ })
37
+
38
+ describe('relativeTime', () => {
39
+ const now = Date.parse('2026-06-14T12:00:00Z')
40
+ it('renders coarse buckets and a dash for missing input', () => {
41
+ expect(relativeTime(undefined, now)).toBe('—')
42
+ expect(relativeTime('2026-06-14T11:55:00Z', now)).toBe('5m ago')
43
+ expect(relativeTime('2026-06-14T10:00:00Z', now)).toBe('2h ago')
44
+ expect(relativeTime('not-a-date', now)).toBe('not-a-date')
45
+ })
46
+ })
@@ -11,7 +11,7 @@
11
11
  "test": "vitest run"
12
12
  },
13
13
  "dependencies": {
14
- "@astrale-os/shell": ">=0.1.0 <1.0.0",
14
+ "@astrale-os/shell": ">=0.2.0 <1.0.0",
15
15
  "react": "^19.2.0",
16
16
  "react-dom": "^19.2.0"
17
17
  },
@@ -4,19 +4,17 @@
4
4
  * `window.location.pathname` IS that path. `App` reads it (via `resolveView`) and
5
5
  * renders the matching view; an unregistered path shows a self-describing fallback.
6
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/`),
7
+ * `ROUTES` starts EMPTYthis is a blank skeleton, so every path falls through to
8
+ * `UnknownView`. Add a view by
9
+ * 1. writing a `ViewComponent` (usually wrapping `ViewFrame` — see `@/shell`),
10
10
  * 2. adding a `ROUTES` entry keyed by its mount path,
11
- * 3. registering a matching `defineView({ mount: '/ui/<path>' })` in `views/`.
11
+ * 3. registering a matching `defineView({ mount: '/ui/<path>' })` in the domain's
12
+ * `views/`.
12
13
  */
13
14
 
14
15
  import { resolveView, useShell, type ViewRoutes } from '@/shell'
15
- import { StatusView } from '@/views/status'
16
16
 
17
- const ROUTES: ViewRoutes = {
18
- '/ui/status-page': StatusView,
19
- }
17
+ const ROUTES: ViewRoutes = {}
20
18
 
21
19
  export function App() {
22
20
  const shell = useShell()
@@ -16,5 +16,6 @@ export type { AsyncResource, AsyncState } from './use-async'
16
16
  export { useCapability } from './use-capability'
17
17
  export type { Capability, Phase } from './use-capability'
18
18
  export { asNodeArray, linkTargets, qualifiedProp, qualifiedString } from './transformers'
19
+ export type { GraphEdge } from './transformers'
19
20
  export { resolveView, ViewFrame } from './view-router'
20
21
  export type { ViewComponent, ViewRoutes } from './view-router'
@@ -1,8 +1,11 @@
1
1
  /**
2
- * Graph-response transformers at the kernel boundary — turn raw `::get` /
3
- * `::listChildren` / `::getLinks` payloads into the shapes features consume.
4
- * Domain-neutral: per-class node→record mappers live in each feature's
5
- * `*.mappers.ts` and build on `qualifiedString` here.
2
+ * Graph-shape helpers at the kernel boundary — turn `shell.kernel.graph` results
3
+ * (`graph.node` / `graph.children` / `graph.links`, backed by `function.get`)
4
+ * into the shapes features consume. Domain-neutral: per-class node→record mappers
5
+ * live in each feature's `*.mappers.ts` and build on `qualifiedString` here.
6
+ *
7
+ * The graph sugar already returns typed records (`{ nodes }`, `{ edges }`) — no
8
+ * envelope-sniffing needed — so these are thin projections over those shapes.
6
9
  *
7
10
  * Props are stored under QUALIFIED keys
8
11
  * (`monitors.astrale.ai:class.Monitor.property.url`). The client must not import
@@ -13,6 +16,15 @@
13
16
  */
14
17
  import type { KernelNode } from './client'
15
18
 
19
+ /** One incident edge as `shell.kernel.graph.links` returns it (a `GraphEdgeWire`). */
20
+ export type GraphEdge = {
21
+ class: string
22
+ source: string
23
+ target: string
24
+ slug?: string | null
25
+ props?: Record<string, unknown>
26
+ }
27
+
16
28
  /** Resolve a prop by `.property.<name>` suffix; domain keys win over kernel ones. */
17
29
  export function qualifiedProp(props: Record<string, unknown>, name: string): unknown {
18
30
  const suffix = `.property.${name}`
@@ -33,40 +45,31 @@ export function qualifiedString(props: Record<string, unknown>, name: string): s
33
45
  return typeof v === 'string' && v !== '' ? v : undefined
34
46
  }
35
47
 
36
- type RawLink = { class?: unknown; edgeClass?: unknown; target?: unknown; to?: unknown }
37
-
38
48
  /**
39
- * Targets of a `::getLinks` response whose edge class ends with `classTail`
40
- * (e.g. `'monitor_on_host'`). Tolerates bare-array and `{ links }` envelopes,
41
- * and both `target`/`to` + `class`/`edgeClass`.
49
+ * Targets of `graph.links` edges whose edge class ends with `classTail`
50
+ * (e.g. `'monitor_on_host'`). Accepts the `graph.links` result (`{ edges }`) or a
51
+ * bare `GraphEdge[]`.
42
52
  */
43
- export function linkTargets(raw: unknown, classTail: string): string[] {
44
- const links: RawLink[] = Array.isArray(raw)
45
- ? (raw as RawLink[])
46
- : ((raw as { links?: RawLink[] } | null)?.links ?? [])
53
+ export function linkTargets(
54
+ edges: GraphEdge[] | { edges: GraphEdge[] } | null | undefined,
55
+ classTail: string,
56
+ ): string[] {
57
+ const list: GraphEdge[] = Array.isArray(edges) ? edges : (edges?.edges ?? [])
47
58
  const out: string[] = []
48
- for (const link of links) {
49
- const cls =
50
- typeof link.class === 'string'
51
- ? link.class
52
- : typeof link.edgeClass === 'string'
53
- ? link.edgeClass
54
- : ''
55
- if (!cls.endsWith(classTail)) continue
56
- const target =
57
- typeof link.target === 'string'
58
- ? link.target
59
- : typeof link.to === 'string'
60
- ? link.to
61
- : undefined
62
- if (target) out.push(target)
59
+ for (const edge of list) {
60
+ if (typeof edge.class !== 'string' || !edge.class.endsWith(classTail)) continue
61
+ if (typeof edge.target === 'string') out.push(edge.target)
63
62
  }
64
63
  return out
65
64
  }
66
65
 
67
- /** A `::listChildren` response as a node array (bare array or enveloped). */
68
- export function asNodeArray(raw: unknown): KernelNode[] {
69
- if (Array.isArray(raw)) return raw as KernelNode[]
70
- const obj = raw as { children?: KernelNode[]; nodes?: KernelNode[] } | null
71
- return obj?.children ?? obj?.nodes ?? []
66
+ /**
67
+ * The nodes of a `graph.children` / `graph.tree` result. Accepts the sugar result
68
+ * (`{ nodes }`) or a bare `KernelNode[]`.
69
+ */
70
+ export function asNodeArray(
71
+ result: KernelNode[] | { nodes: KernelNode[] } | null | undefined,
72
+ ): KernelNode[] {
73
+ if (Array.isArray(result)) return result
74
+ return result?.nodes ?? []
72
75
  }