@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.
Files changed (81) hide show
  1. package/package.json +1 -1
  2. package/template/.agents/skills/astrale-cli/SKILL.md +1 -1
  3. package/template/.agents/skills/astrale-domain/SKILL.md +5 -5
  4. package/template/.env.example +5 -7
  5. package/template/README.md +2 -2
  6. package/template/client/README.md +81 -62
  7. package/template/client/__tests__/app.test.tsx +143 -98
  8. package/template/client/__tests__/harness.ts +62 -12
  9. package/template/client/__tests__/kernel.test.ts +40 -51
  10. package/template/client/__tests__/seam.test.tsx +115 -0
  11. package/template/client/index.html +1 -1
  12. package/template/client/package.json +1 -0
  13. package/template/client/src/app.tsx +34 -83
  14. package/template/client/src/main.tsx +2 -2
  15. package/template/client/src/shell/client.ts +67 -0
  16. package/template/client/src/shell/index.ts +20 -0
  17. package/template/client/src/shell/invoke.ts +35 -0
  18. package/template/client/src/shell/transformers.ts +72 -0
  19. package/template/client/src/shell/use-async.ts +56 -0
  20. package/template/client/src/shell/use-capability.ts +59 -0
  21. package/template/client/src/shell/use-node.ts +61 -0
  22. package/template/client/src/shell/use-shell.ts +91 -0
  23. package/template/client/src/shell/view-router.tsx +97 -0
  24. package/template/client/src/status/components/StatusCard.tsx +50 -0
  25. package/template/client/src/status/components/index.ts +1 -0
  26. package/template/client/src/status/hooks/index.ts +3 -0
  27. package/template/client/src/status/hooks/useCheck.mutation.ts +16 -0
  28. package/template/client/src/status/hooks/useCheckable.query.ts +64 -0
  29. package/template/client/src/status/index.ts +7 -0
  30. package/template/client/src/status/status.api.ts +12 -0
  31. package/template/client/src/status/status.mappers.ts +19 -0
  32. package/template/client/src/status/status.types.ts +11 -0
  33. package/template/client/src/styles.css +182 -4
  34. package/template/client/src/ui/StatusBadge.tsx +31 -0
  35. package/template/client/src/ui/format.ts +24 -0
  36. package/template/client/src/ui/index.ts +13 -0
  37. package/template/client/src/ui/surface.tsx +56 -0
  38. package/template/client/src/ui/value.tsx +32 -0
  39. package/template/client/src/views/status.tsx +28 -0
  40. package/template/client/tsconfig.json +2 -1
  41. package/template/client/vite.config.ts +11 -13
  42. package/template/client/vitest.config.ts +11 -5
  43. package/template/core/monitor/health.ts +34 -0
  44. package/template/core/monitor/index.ts +9 -0
  45. package/template/core/monitor/keys.ts +41 -0
  46. package/template/core/monitor/node.ts +57 -0
  47. package/template/deps.ts +10 -9
  48. package/template/domain.ts +1 -1
  49. package/template/env.ts +2 -9
  50. package/template/integrations/prober/http.ts +32 -0
  51. package/template/integrations/prober/mock.ts +18 -0
  52. package/template/integrations/prober/port.ts +26 -0
  53. package/template/integrations/prober/registry.ts +65 -0
  54. package/template/package.json +1 -1
  55. package/template/pnpm-lock.yaml +2766 -0
  56. package/template/runtime/index.ts +63 -34
  57. package/template/runtime/monitor/check.ts +29 -0
  58. package/template/runtime/monitor/index.ts +9 -0
  59. package/template/runtime/monitor/seed.ts +95 -0
  60. package/template/runtime/monitor/watch.ts +31 -0
  61. package/template/runtime/shared.ts +21 -0
  62. package/template/runtime/status-page/add.ts +21 -0
  63. package/template/runtime/status-page/check.ts +50 -0
  64. package/template/runtime/status-page/create.ts +24 -0
  65. package/template/runtime/status-page/index.ts +8 -0
  66. package/template/schema/index.ts +11 -4
  67. package/template/schema/monitor.ts +94 -0
  68. package/template/views/index.ts +8 -2
  69. package/template/views/status-page.ts +16 -0
  70. package/template/client/src/lib/kernel.ts +0 -135
  71. package/template/client/src/lib/shell.ts +0 -197
  72. package/template/client/src/lib/use-node.ts +0 -66
  73. package/template/client/src/lib/use-shell.ts +0 -85
  74. package/template/core/keys.ts +0 -28
  75. package/template/core/note.ts +0 -148
  76. package/template/integrations/summary/heuristic.ts +0 -25
  77. package/template/integrations/summary/http.ts +0 -69
  78. package/template/integrations/summary/port.ts +0 -21
  79. package/template/integrations/summary/registry.ts +0 -52
  80. package/template/schema/note.ts +0 -67
  81. package/template/views/note.ts +0 -21
@@ -3,11 +3,20 @@
3
3
  --fg: #171717;
4
4
  --muted: #737373;
5
5
  --border: #e5e5e5;
6
+ --border-strong: #d4d4d8;
6
7
  --card: #ffffff;
7
8
  --accent: #2563eb;
9
+ --accent-soft: rgba(37, 99, 235, 0.1);
8
10
  --warn-bg: #fffbeb;
9
11
  --warn-border: #fde68a;
10
12
  --warn-fg: #92400e;
13
+ --err: #dc2626;
14
+ --err-soft: rgba(220, 38, 38, 0.1);
15
+ --up: #16a34a;
16
+ --degraded: #d97706;
17
+ --down: #dc2626;
18
+ --unknown: #737373;
19
+ --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
11
20
  }
12
21
 
13
22
  * {
@@ -49,7 +58,7 @@ body {
49
58
  }
50
59
 
51
60
  .subline {
52
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
61
+ font-family: var(--mono);
53
62
  font-size: 0.72rem;
54
63
  color: var(--muted);
55
64
  margin: 0.25rem 0 1rem;
@@ -61,6 +70,8 @@ body {
61
70
  margin: 0 0 1rem;
62
71
  }
63
72
 
73
+ /* ─── Banners ───────────────────────────────────────────────────────────── */
74
+
64
75
  .banner {
65
76
  border: 1px solid var(--warn-border);
66
77
  background: var(--warn-bg);
@@ -71,11 +82,57 @@ body {
71
82
  margin-bottom: 1rem;
72
83
  }
73
84
 
85
+ .banner-error {
86
+ border-color: var(--err);
87
+ background: var(--err-soft);
88
+ color: var(--err);
89
+ word-break: break-word;
90
+ }
91
+
92
+ /* ─── Panel ─────────────────────────────────────────────────────────────── */
93
+
94
+ .panel {
95
+ background: var(--card);
96
+ border: 1px solid var(--border);
97
+ border-radius: 0.7rem;
98
+ padding: 1.05rem 1.2rem;
99
+ }
100
+
101
+ .panel-danger {
102
+ border-color: color-mix(in srgb, var(--err) 45%, var(--border));
103
+ }
104
+
105
+ .panel-head {
106
+ display: flex;
107
+ align-items: center;
108
+ justify-content: space-between;
109
+ gap: 0.75rem;
110
+ margin-bottom: 0.85rem;
111
+ }
112
+
113
+ .panel-title {
114
+ font-size: 1.1rem;
115
+ font-weight: 650;
116
+ margin: 0;
117
+ }
118
+
119
+ .panel-danger .panel-title {
120
+ color: var(--err);
121
+ }
122
+
123
+ .panel-actions {
124
+ display: inline-flex;
125
+ align-items: center;
126
+ gap: 0.5rem;
127
+ }
128
+
129
+ /* ─── Key/value ─────────────────────────────────────────────────────────── */
130
+
74
131
  .kv {
75
132
  border: 1px solid var(--border);
76
133
  border-radius: 0.5rem;
77
134
  overflow: hidden;
78
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
135
+ font-family: var(--mono);
79
136
  font-size: 0.72rem;
80
137
  }
81
138
 
@@ -97,14 +154,135 @@ body {
97
154
 
98
155
  .kv-val {
99
156
  word-break: break-all;
157
+ min-width: 0;
100
158
  }
101
159
 
102
160
  .muted {
103
161
  color: var(--muted);
104
162
  }
105
163
 
106
- .section-label {
164
+ .mono {
165
+ font-family: var(--mono);
107
166
  font-size: 0.78rem;
167
+ }
168
+
169
+ .link {
170
+ color: var(--accent);
171
+ text-decoration: none;
172
+ }
173
+
174
+ .link:hover {
175
+ text-decoration: underline;
176
+ }
177
+
178
+ /* ─── Status row + badge ────────────────────────────────────────────────── */
179
+
180
+ .status-row {
181
+ display: flex;
182
+ align-items: center;
183
+ justify-content: space-between;
184
+ gap: 0.75rem;
185
+ margin: 0 0 1rem;
186
+ }
187
+
188
+ .status-badge {
189
+ display: inline-block;
190
+ font-weight: 700;
191
+ font-size: 1rem;
192
+ letter-spacing: 0.04em;
193
+ padding: 0.3rem 0.85rem;
194
+ border-radius: 0.5rem;
195
+ color: #ffffff;
196
+ }
197
+
198
+ .status-up {
199
+ background: var(--up);
200
+ }
201
+
202
+ .status-degraded {
203
+ background: var(--degraded);
204
+ }
205
+
206
+ .status-down {
207
+ background: var(--down);
208
+ }
209
+
210
+ .status-unknown {
211
+ background: var(--unknown);
212
+ }
213
+
214
+ /* ─── Buttons ───────────────────────────────────────────────────────────── */
215
+
216
+ .check-btn {
217
+ font: inherit;
108
218
  font-weight: 600;
109
- margin: 0 0 0.5rem;
219
+ color: #ffffff;
220
+ background: var(--accent);
221
+ border: 0;
222
+ border-radius: 0.5rem;
223
+ padding: 0.4rem 0.85rem;
224
+ cursor: pointer;
225
+ }
226
+
227
+ .check-btn:hover:not(:disabled) {
228
+ opacity: 0.9;
229
+ }
230
+
231
+ .check-btn:disabled {
232
+ opacity: 0.55;
233
+ cursor: default;
234
+ }
235
+
236
+ /* ─── Empty state ───────────────────────────────────────────────────────── */
237
+
238
+ .empty {
239
+ border: 1px dashed var(--border-strong);
240
+ border-radius: 0.6rem;
241
+ padding: 1.4rem 1.2rem;
242
+ text-align: center;
243
+ display: flex;
244
+ flex-direction: column;
245
+ gap: 0.6rem;
246
+ align-items: center;
247
+ }
248
+
249
+ .empty-text {
250
+ margin: 0;
251
+ color: var(--muted);
252
+ font-size: 0.85rem;
253
+ }
254
+
255
+ .empty-hint {
256
+ font-family: var(--mono);
257
+ font-size: 0.72rem;
258
+ color: var(--muted);
259
+ background: var(--bg);
260
+ border: 1px solid var(--border);
261
+ border-radius: 0.4rem;
262
+ padding: 0.3rem 0.6rem;
263
+ word-break: break-all;
264
+ }
265
+
266
+ /* ─── Loading ───────────────────────────────────────────────────────────── */
267
+
268
+ .loading-line {
269
+ display: flex;
270
+ align-items: center;
271
+ gap: 0.5rem;
272
+ margin: 0;
273
+ }
274
+
275
+ .spinner {
276
+ width: 0.85rem;
277
+ height: 0.85rem;
278
+ border-radius: 50%;
279
+ border: 2px solid var(--border-strong);
280
+ border-top-color: var(--accent);
281
+ animation: spin 0.8s linear infinite;
282
+ }
283
+
284
+ @keyframes spin {
285
+ to {
286
+ transform: rotate(360deg);
287
+ }
110
288
  }
@@ -0,0 +1,31 @@
1
+ /** Health status chip — pure presentational; styling lives in `styles.css`. */
2
+
3
+ /** The health vocabulary the badge styles, shared across checkable nodes. */
4
+ export type HealthStatus = 'up' | 'down' | 'degraded' | 'unknown'
5
+
6
+ /** Map any raw status string to the four styled states (unrecognized → unknown). */
7
+ function normalize(status: string): HealthStatus {
8
+ return status === 'up' || status === 'down' || status === 'degraded' ? status : 'unknown'
9
+ }
10
+
11
+ /**
12
+ * Color-coded status pill: up = green, degraded = amber, down = red, unknown =
13
+ * muted. The label is the uppercased status (`UP` / `DEGRADED` / `DOWN` /
14
+ * `UNKNOWN`). Takes any raw status string — the vocabulary depends on the node
15
+ * type (up/down for a Monitor, up/degraded/down for a StatusPage) — and styles
16
+ * the four known states, falling back to `unknown` for anything else. Both
17
+ * checkable node types render through it, so it lives in the feature-agnostic
18
+ * `@/ui`, not a feature's own folder.
19
+ */
20
+ export function StatusBadge({ status }: { status: string }) {
21
+ const known = normalize(status)
22
+ const label =
23
+ known === 'up'
24
+ ? 'UP'
25
+ : known === 'degraded'
26
+ ? 'DEGRADED'
27
+ : known === 'down'
28
+ ? 'DOWN'
29
+ : 'UNKNOWN'
30
+ return <span className={`status-badge status-${known}`}>{label}</span>
31
+ }
@@ -0,0 +1,24 @@
1
+ /** Shared display formatters — pure, presentation-side (used across views). */
2
+
3
+ /**
4
+ * Coarse relative time for `lastCheckedAt`-style ISO stamps:
5
+ * `just now` / `5m ago` / `2h ago` / a `YYYY-MM-DD` date for anything older than
6
+ * 30 days. Returns `—` for a missing stamp and the raw string for an unparseable
7
+ * one.
8
+ */
9
+ export function relativeTime(iso: string | undefined, now = Date.now()): string {
10
+ if (!iso) return '—'
11
+ const t = Date.parse(iso)
12
+ if (Number.isNaN(t)) return iso
13
+ const diff = now - t
14
+ if (diff < 0) return 'just now'
15
+ const s = Math.floor(diff / 1000)
16
+ if (s < 45) return 'just now'
17
+ const m = Math.floor(s / 60)
18
+ if (m < 60) return `${m}m ago`
19
+ const h = Math.floor(m / 60)
20
+ if (h < 48) return `${h}h ago`
21
+ const d = Math.floor(h / 24)
22
+ if (d < 30) return `${d}d ago`
23
+ return new Date(t).toISOString().slice(0, 10)
24
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The design system — generic, feature-AGNOSTIC presentational primitives +
3
+ * display formatters, no domain knowledge and no kernel session. The one
4
+ * domain-shaped exception is `StatusBadge`: it renders the up/degraded/down/
5
+ * unknown health vocabulary that any `Checkable` node reports, so it's a shared
6
+ * primitive here rather than living inside the status feature.
7
+ * Import from the barrel: `import { Panel, KV, relativeTime } from '@/ui'`.
8
+ */
9
+ export { EmptyState, ErrorBanner, Panel, Spinner } from './surface'
10
+ export { StatusBadge } from './StatusBadge'
11
+ export type { HealthStatus } from './StatusBadge'
12
+ export { ExternalLink, KV, Mono } from './value'
13
+ export { relativeTime } from './format'
@@ -0,0 +1,56 @@
1
+ /** Containers + feedback surfaces — pure presentational. */
2
+ import type { ReactNode } from 'react'
3
+
4
+ /**
5
+ * A titled card section. `actions` render in the header (e.g. a button); `tone:
6
+ * 'danger'` tints the border/title for destructive panels.
7
+ */
8
+ export function Panel({
9
+ title,
10
+ actions,
11
+ tone,
12
+ children,
13
+ }: {
14
+ title: string
15
+ actions?: ReactNode
16
+ tone?: 'danger'
17
+ children: ReactNode
18
+ }) {
19
+ return (
20
+ <section className={tone === 'danger' ? 'panel panel-danger' : 'panel'}>
21
+ <header className="panel-head">
22
+ <h2 className="panel-title">{title}</h2>
23
+ {actions && <div className="panel-actions">{actions}</div>}
24
+ </header>
25
+ {children}
26
+ </section>
27
+ )
28
+ }
29
+
30
+ /** A red alert banner for a failed call. */
31
+ export function ErrorBanner({ children }: { children: ReactNode }) {
32
+ return (
33
+ <div className="banner banner-error" role="alert">
34
+ {children}
35
+ </div>
36
+ )
37
+ }
38
+
39
+ /** Teaching empty state — what's missing and an optional command that fixes it. */
40
+ export function EmptyState({ children, hint }: { children: ReactNode; hint?: string }) {
41
+ return (
42
+ <div className="empty">
43
+ <p className="empty-text">{children}</p>
44
+ {hint && <code className="empty-hint">{hint}</code>}
45
+ </div>
46
+ )
47
+ }
48
+
49
+ /** A spinner with a label, for in-flight loads. */
50
+ export function Spinner({ label }: { label: string }) {
51
+ return (
52
+ <p className="muted loading-line">
53
+ <span className="spinner" aria-hidden="true" /> {label}
54
+ </p>
55
+ )
56
+ }
@@ -0,0 +1,32 @@
1
+ /** Inline value renderers — pure presentational. */
2
+ import type { ReactNode } from 'react'
3
+
4
+ /** One label/value row in a `.kv` grid. */
5
+ export function KV({ label, children }: { label: string; children: ReactNode }) {
6
+ return (
7
+ <div className="kv-row">
8
+ <div className="kv-key">{label}</div>
9
+ <div className="kv-val">{children}</div>
10
+ </div>
11
+ )
12
+ }
13
+
14
+ /** Monospace inline value with a soft em-dash fallback. */
15
+ export function Mono({ value, title }: { value: string | undefined; title?: string }) {
16
+ if (!value) return <span className="muted">—</span>
17
+ return (
18
+ <span className="mono" title={title ?? value}>
19
+ {value}
20
+ </span>
21
+ )
22
+ }
23
+
24
+ /** An external link rendered without its scheme, opening in a new tab. */
25
+ export function ExternalLink({ url }: { url: string | undefined }) {
26
+ if (!url) return <span className="muted">—</span>
27
+ return (
28
+ <a className="mono link" href={url} target="_blank" rel="noreferrer noopener" title={url}>
29
+ {url.replace(/^https?:\/\//, '')}
30
+ </a>
31
+ )
32
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The status view — the SPA's one view: the StatusPage panel at `/ui/status-page`.
3
+ * Mounted by the Astrale shell as a sandboxed iframe; `@/shell` (built on the real
4
+ * `@astrale-os/shell`) completes the handshake and hands over the kernel session +
5
+ * the target node id. `ViewFrame` gates the handshake (loading / standalone /
6
+ * ready); the `ready` body delegates to `StatusCard`, the feature container that
7
+ * loads the node, renders its rolled-up status, and exposes a "Check now" action.
8
+ *
9
+ * Pure composition — no data/transport logic lives here (that's `@/status` +
10
+ * `@/shell`).
11
+ */
12
+ import { type ShellState, ViewFrame } from '@/shell'
13
+ import { StatusCard } from '@/status'
14
+
15
+ export function StatusView(shell: ShellState) {
16
+ return (
17
+ <ViewFrame shell={shell} title="Status" subline="astrale view">
18
+ {(session, nodeId) =>
19
+ nodeId ? (
20
+ // Keyed by node id so a target hot-swap remounts with fresh state.
21
+ <StatusCard key={nodeId} session={session} nodeId={nodeId} />
22
+ ) : (
23
+ <div className="banner">No target node — open this view from a StatusPage.</div>
24
+ )
25
+ }
26
+ </ViewFrame>
27
+ )
28
+ }
@@ -11,7 +11,8 @@
11
11
  "skipLibCheck": true,
12
12
  "isolatedModules": true,
13
13
  "resolveJsonModule": true,
14
- "verbatimModuleSyntax": true
14
+ "verbatimModuleSyntax": true,
15
+ "paths": { "@/*": ["./src/*"] }
15
16
  },
16
17
  "include": [
17
18
  "src/**/*.ts",
@@ -1,30 +1,28 @@
1
1
  import viteReact from '@vitejs/plugin-react'
2
+ import { fileURLToPath } from 'node:url'
2
3
  import { defineConfig } from 'vite'
3
4
 
4
5
  /**
5
- * Client SPA for the domain's `ui-note` View. Built into `../.dist/`,
6
- * served by the generated worker via its `ASSETS` binding (`.astrale/`).
6
+ * Client SPA for the domain's Views. Built into `../.dist/`, served by the
7
+ * generated worker via its `ASSETS` binding (`.astrale/`).
7
8
  *
8
9
  * `base: '/ui/'` + `outDir: '../.dist'`: Vite emits asset refs as
9
10
  * `/ui/assets/<hash>.js`; the worker strips the `/ui` prefix before delegating
10
11
  * to `ASSETS`, so files resolve from the directory root. `index.html` is the
11
- * SPA fallback for `/ui/<anything>`.
12
+ * SPA fallback for `/ui/<anything>` — `src/app.tsx` routes on the mount path.
12
13
  *
13
- * Deliberately self-contained only react/react-dom/vite/@vitejs/plugin-react,
14
- * all on public npm so the template builds without the @astrale-os registry
15
- * or workspace `link:`s. Two small subsets are reimplemented inline: the shell
16
- * child-handshake (`src/lib/shell.ts`, including `ctrl:tokenRefresh` so a
17
- * pushed credential is picked up) and a minimal JSON kernel client
18
- * (`src/lib/kernel.ts`, `@<id>::get`). Tests live in `__tests__/` and run on
19
- * `vitest`/`happy-dom` (see `vitest.config.ts`) — vite never bundles them.
14
+ * Consumes `@astrale-os/shell` for the child handshake + the real kernel client:
15
+ * `src/shell/use-shell.ts` boots `createShell({ mode: 'sandboxed' })` and the
16
+ * feature hooks call kernel methods through `shell.kernel` (token refresh, codec
17
+ * negotiation, redirect following, delegation all handled by the SDK).
20
18
  *
21
- * Deferred on purpose (grow via `@astrale-os/kernel-client` if needed): no
22
- * msgpack, no streaming/binary, no redirect following, no client-side minting,
23
- * no writes. See `README.md`.
19
+ * `@` is aliased to `src/` (mirrors tsconfig `paths`) so feature code imports
20
+ * `@/shell`, `@/ui`, `@/status`.
24
21
  */
25
22
  export default defineConfig({
26
23
  base: '/ui/',
27
24
  plugins: [viteReact()],
25
+ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
28
26
  build: {
29
27
  outDir: '../.dist',
30
28
  emptyOutDir: true,
@@ -1,18 +1,24 @@
1
1
  import viteReact from '@vitejs/plugin-react'
2
+ import { fileURLToPath } from 'node:url'
2
3
  import { defineConfig } from 'vitest/config'
3
4
 
4
5
  /**
5
- * Vitest config for the self-contained client. A DOM env (`happy-dom`) backs
6
- * the React render + `window.postMessage`/`MessageChannel` used by the fake
7
- * shell-parent harness. Kept separate from `vite.config.ts` (which sets the
8
- * `/ui/` base + `../.dist` build) so tests don't inherit the SPA build
9
- * options.
6
+ * Vitest config for the client SPA. A DOM env (`happy-dom`) backs the React
7
+ * render + the shell handshake the tests exercise. Kept separate from
8
+ * `vite.config.ts` (which sets the `/ui/` base + `../.dist` build) so tests
9
+ * don't inherit the SPA build options.
10
10
  */
11
11
  export default defineConfig({
12
12
  plugins: [viteReact()],
13
+ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
13
14
  test: {
14
15
  environment: 'happy-dom',
15
16
  globals: true,
16
17
  include: ['__tests__/**/*.test.{ts,tsx}', 'src/**/*.test.{ts,tsx}'],
18
+ // `@astrale-os/*` ship bundler-targeted ESM (extensionless relative imports).
19
+ // vitest externalizes node_modules and uses Node's stricter ESM resolver,
20
+ // which wants explicit `.js`; inlining the scope makes vitest transform them
21
+ // through vite — the same context the build uses. Standard for these deps.
22
+ server: { deps: { inline: [/@astrale-os\//] } },
17
23
  },
18
24
  })
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Health logic — PURE status rules, no I/O. `classify` maps one probe's HTTP code
3
+ * to a monitor verdict; `rollup` aggregates a status page's watched monitors into
4
+ * a page verdict. (Node identity/layout/seed data lives in `./node`; the kernel
5
+ * reads/writes live in `runtime/`; the network probe behind the `Prober` port.)
6
+ */
7
+
8
+ /** A monitor's health verdict. `unknown` is the pre-first-check state. */
9
+ export type HealthStatus = 'up' | 'down' | 'unknown'
10
+
11
+ /**
12
+ * Map an observed HTTP status code to a verdict. `0` means the host was
13
+ * unreachable (DNS/timeout/refused) — that's `down`. 2xx/3xx is `up`; anything
14
+ * else (4xx/5xx) is `down`. Pure.
15
+ */
16
+ export function classify(statusCode: number): HealthStatus {
17
+ if (statusCode >= 200 && statusCode < 400) return 'up'
18
+ return 'down'
19
+ }
20
+
21
+ /** A status page's rolled-up verdict — `degraded` = only non-critical checks down. */
22
+ export type PageStatus = 'up' | 'degraded' | 'down' | 'unknown'
23
+
24
+ /**
25
+ * Roll up a page's watched monitors into a page verdict: a CRITICAL monitor down
26
+ * ⇒ `down`; any other monitor down ⇒ `degraded`; all up ⇒ `up`; none ⇒ `unknown`.
27
+ * Pure.
28
+ */
29
+ export function rollup(members: readonly { status: string; critical: boolean }[]): PageStatus {
30
+ if (members.length === 0) return 'unknown'
31
+ const down = members.filter((m) => m.status === 'down')
32
+ if (down.some((m) => m.critical)) return 'down'
33
+ return down.length > 0 ? 'degraded' : 'up'
34
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The monitor bounded context's PURE core, in one place: schema accessors
3
+ * (`keys`), the health status rule (`health`), and node identity/layout/seed
4
+ * data (`node`). No I/O, no clock/RNG — all deterministic + testable.
5
+ * `runtime/monitor/` imports the operations' logic from here.
6
+ */
7
+ export * from './keys'
8
+ export * from './health'
9
+ export * from './node'
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Compiled-schema accessors for the monitor context — the ONE place class paths,
3
+ * method paths, and qualified prop keys come from. Pure (schema-derived); never
4
+ * hand-write key strings. `D` (the compiled domain) comes from `schema/index`,
5
+ * re-exported here so the rest of the context reads it from one place.
6
+ */
7
+ import { K } from '@astrale-os/kernel-core'
8
+
9
+ import { D } from '../../schema'
10
+
11
+ export { D, K }
12
+
13
+ /** Kernel ops/classes the logic addresses. */
14
+ export const NODE_CREATE = K.Node.createNode.path.method.raw
15
+ export const FOLDER_CLASS = K.Folder.path.class.raw
16
+ export const NAME_KEY = K.Named.name.key
17
+
18
+ /** Domain class/edge paths. */
19
+ export const MONITOR_CLASS = D.Monitor.path.class.raw
20
+ export const STATUS_PAGE_CLASS = D.StatusPage.path.class.raw
21
+ export const WATCHES_EDGE = D.watches.path.class.raw
22
+
23
+ /** Qualified storage keys for Monitor node props. */
24
+ export const MONITOR_KEYS = {
25
+ url: D.Monitor.url.key,
26
+ status: D.Monitor.status.key,
27
+ statusCode: D.Monitor.statusCode.key,
28
+ latencyMs: D.Monitor.latencyMs.key,
29
+ lastCheckedAt: D.Monitor.lastCheckedAt.key,
30
+ } as const
31
+
32
+ /** Qualified storage key for the StatusPage's rolled-up status. */
33
+ export const PAGE_KEYS = {
34
+ status: D.StatusPage.status.key,
35
+ } as const
36
+
37
+ /** Qualified key for the `watches` edge's `critical` prop. Edge-prop accessors
38
+ * aren't typed yet (an SDK gap), so this casts to read the key. */
39
+ export const WATCHES_KEYS = {
40
+ critical: (D.watches as unknown as { critical: { key: string } }).critical.key,
41
+ } as const
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Node identity — PURE domain constants + slug logic (no I/O): where monitors and
3
+ * status pages live in the graph, how a new node's slug is formed, and the seed
4
+ * set. `runtime/` consumes these; the IMPURE bits (the slug's entropy suffix, the
5
+ * node writes) stay in `runtime/`, so this file is deterministic and testable.
6
+ */
7
+
8
+ /** Where each kind of node lives in the graph (domain layout; `seed` creates the folders). */
9
+ export const MONITORS_PARENT = '/monitors'
10
+ export const STATUS_PAGES_PARENT = '/status-pages'
11
+
12
+ /** Deterministic URL-/name-safe stem (lowercased, non-alnum → `-`, clamped). Pure. */
13
+ export function slugify(text: string): string {
14
+ return (
15
+ text
16
+ .replace(/^https?:\/\//i, '')
17
+ .toLowerCase()
18
+ .replace(/[^a-z0-9]+/g, '-')
19
+ .replace(/^-|-$/g, '')
20
+ .slice(0, 40) || 'node'
21
+ )
22
+ }
23
+
24
+ /**
25
+ * Slug for a NEW node: the stem + a caller-supplied `suffix`. Pure — `runtime/`
26
+ * injects the `suffix` (clock/RNG entropy for collision safety) so core stays
27
+ * deterministic. `seed` uses fixed slugs (not this) for idempotency.
28
+ */
29
+ export function uniqueSlug(text: string, suffix: string): string {
30
+ return `${slugify(text)}-${suffix}`
31
+ }
32
+
33
+ export interface StarterMonitor {
34
+ slug: string
35
+ name: string
36
+ url: string
37
+ /** Whether the seeded status page treats this monitor as critical. */
38
+ critical: boolean
39
+ }
40
+
41
+ /**
42
+ * Monitors laid down by `seed` (postInstall). httpbin returns a deterministic
43
+ * status code, so the second starter is a stable demo target; the first points at
44
+ * a real site. Fixed slugs keep `seed` idempotent across reinstalls.
45
+ */
46
+ export const STARTERS: readonly StarterMonitor[] = [
47
+ { slug: 'astrale', name: 'Astrale', url: 'https://astrale.ai', critical: true },
48
+ {
49
+ slug: 'httpbin-200',
50
+ name: 'httpbin (200)',
51
+ url: 'https://httpbin.org/status/200',
52
+ critical: false,
53
+ },
54
+ ]
55
+
56
+ /** The status page `seed` creates, watching every starter monitor. */
57
+ export const STARTER_PAGE = { slug: 'status', name: 'Status' } as const