feat: initial Matrix SDK
Full SDK workspace: core, contracts, sdk, cli, browser-host, browser-kit, federation, omega-core, oracle, self-healing, strategies, tools, emacs. Clean extraction from the development monorepo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
1a0419dc0c
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
dist/
|
||||
**/dist/
|
||||
.turbo/
|
||||
**/.turbo/
|
||||
.tsbuildinfo
|
||||
**/.tsbuildinfo
|
||||
*.tgz
|
||||
22
README.md
Normal file
22
README.md
Normal file
@ -0,0 +1,22 @@
|
||||
# Matrix SDK Workspace
|
||||
|
||||
This workspace is the isolated SDK incubation area for
|
||||
`WORKSTREAMS/matrix-sdk-isolation`.
|
||||
|
||||
Current proven checkpoints:
|
||||
|
||||
- copied SDK-bearing packages build/test/type-check/pack independently
|
||||
- copied package identities were renamed to final `@open-matrix/*` names
|
||||
- `@open-matrix/sdk` facade package builds/tests/packs
|
||||
- `@open-matrix/browser-kit` builds package-manager-ready `dist` exports
|
||||
- a fresh external temp consumer installs packed tarballs, compiles, starts a
|
||||
`MatrixActor`, and calls it through the Matrix runtime/transport path
|
||||
|
||||
Proof commands:
|
||||
|
||||
```bash
|
||||
pnpm --dir projects/matrix-sdk prove
|
||||
pnpm --dir projects/matrix-sdk prove:external-consumer
|
||||
```
|
||||
|
||||
Matrix-3 consumers have not been repointed yet.
|
||||
30
package.json
Normal file
30
package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "matrix-sdk-workspace",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"scripts": {
|
||||
"clean": "node scripts/clean.mjs",
|
||||
"build": "pnpm -r --sort --if-present run build",
|
||||
"test": "pnpm -r --sort --if-present run test",
|
||||
"type-check": "pnpm -r --sort --if-present run type-check",
|
||||
"pack:all": "pnpm -r exec pnpm pack",
|
||||
"prove:external-consumer": "node scripts/prove-external-consumer.mjs",
|
||||
"prove:cli-uat": "node scripts/prove-sdk-cli-uat.mjs",
|
||||
"prove": "pnpm run clean && pnpm run build && pnpm run test && pnpm run type-check && pnpm run pack:all"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-js": "5.2.0",
|
||||
"@nats-io/nats-core": "^3.3.1",
|
||||
"morphdom": "^2.7.8",
|
||||
"nats.ws": "^1.30.3",
|
||||
"vite": "^5.4.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"esbuild": "^0.27.3",
|
||||
"tsx": "^4.15.6",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
60
packages/browser-host/CONTRACT.md
Normal file
60
packages/browser-host/CONTRACT.md
Normal file
@ -0,0 +1,60 @@
|
||||
# @open-matrix/browser-host Contract
|
||||
|
||||
Layer: browser runtime host.
|
||||
|
||||
## Owns
|
||||
|
||||
```text
|
||||
browser runtime bootstrap consumption
|
||||
MatrixAuthorityContext installation into runtime context
|
||||
browser runtimeInstanceRoot propagation
|
||||
hosted runtime target resolution for browser apps
|
||||
browser app readiness probes
|
||||
```
|
||||
|
||||
## Does Not Own
|
||||
|
||||
```text
|
||||
auth decisions
|
||||
namespace ownership
|
||||
Registry storage
|
||||
Device inventory storage
|
||||
NATS/JWT policy
|
||||
```
|
||||
|
||||
## Consumes
|
||||
|
||||
```text
|
||||
/api/bootstrap authorityContext
|
||||
MatrixAuthorityContext
|
||||
browser bus token material from gateway bootstrap
|
||||
```
|
||||
|
||||
## Provided Surfaces
|
||||
|
||||
```text
|
||||
matrix-dsl-host browser element
|
||||
hosted runtime target resolution helpers
|
||||
app-ready probe helpers
|
||||
bundle freshness monitor
|
||||
```
|
||||
|
||||
## Forbidden
|
||||
|
||||
```text
|
||||
selectedAuthorityRoot from DOM authority-root
|
||||
selectedAuthorityRoot from runtime.root
|
||||
selectedAuthorityRoot from assetHostRoot
|
||||
selectedAuthorityRoot from URL host
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Browser authority comes from `/api/bootstrap`. Package configuration may control app mounting and readiness behavior, but it must not decide data authority.
|
||||
|
||||
## Proof Gates
|
||||
|
||||
```text
|
||||
pnpm test:focused @open-matrix/browser-host
|
||||
pnpm --filter @open-matrix/browser-host type-check
|
||||
```
|
||||
32
packages/browser-host/build.mjs
Normal file
32
packages/browser-host/build.mjs
Normal file
@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @open-matrix/browser-host build
|
||||
*
|
||||
* Shell-phase build. Compiles src/ via tsc to dist/. Phase 1 of
|
||||
* core-decontamination uses this to verify the package boundary works
|
||||
* end-to-end before files are moved out of core.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { rmSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const distDir = resolve(__dirname, 'dist');
|
||||
const require = createRequire(import.meta.url);
|
||||
const typescriptPackageJson = require.resolve('typescript/package.json', { paths: [__dirname] });
|
||||
const tscBin = resolve(dirname(typescriptPackageJson), 'bin/tsc');
|
||||
|
||||
rmSync(distDir, { recursive: true, force: true });
|
||||
execFileSync(process.execPath, [
|
||||
tscBin,
|
||||
'-p',
|
||||
resolve(__dirname, 'tsconfig.build.json'),
|
||||
], {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
console.log('[browser-host-build] dist/index.js');
|
||||
65
packages/browser-host/matrix.control-surface.contract.json
Normal file
65
packages/browser-host/matrix.control-surface.contract.json
Normal file
@ -0,0 +1,65 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"package": "@open-matrix/browser-host",
|
||||
"layer": "browser-host",
|
||||
"owns": [
|
||||
"browser runtime bootstrap consumption",
|
||||
"MatrixAuthorityContext installation into browser runtime context",
|
||||
"runtimeInstanceRoot propagation"
|
||||
],
|
||||
"doesNotOwn": [
|
||||
"auth decisions",
|
||||
"namespace ownership",
|
||||
"Registry storage",
|
||||
"provider binding resolution"
|
||||
],
|
||||
"providedActorSurfaces": [
|
||||
{
|
||||
"subjectPattern": "<runtimeInstanceRoot>.<browser-app-mount>.$inbox",
|
||||
"placement": "browser-runtime",
|
||||
"ops": [
|
||||
"$introspect",
|
||||
"$prompt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"requiredActorSurfaces": [
|
||||
{
|
||||
"subjectPattern": "SPACE-HIVECAST.system.gateway.http",
|
||||
"rootSource": "asset host HTTP bootstrap",
|
||||
"ops": [
|
||||
"/api/bootstrap"
|
||||
]
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"sources": [
|
||||
"/api/bootstrap authorityContext"
|
||||
],
|
||||
"forbiddenSources": [
|
||||
"DOM authority-root",
|
||||
"runtime.root",
|
||||
"assetHostRoot"
|
||||
]
|
||||
},
|
||||
"persistence": {
|
||||
"owns": [
|
||||
"none"
|
||||
],
|
||||
"reads": [
|
||||
"bootstrap response only"
|
||||
]
|
||||
},
|
||||
"security": {
|
||||
"mustAllow": [
|
||||
"browser session bootstrap credentials for authorized selectable roots"
|
||||
],
|
||||
"mustDeny": [
|
||||
"browser-side recomputation of authority roots"
|
||||
]
|
||||
},
|
||||
"proofGates": [
|
||||
"guard:control-surface-contracts",
|
||||
"pnpm test:focused @open-matrix/browser-host"
|
||||
]
|
||||
}
|
||||
67
packages/browser-host/package.json
Normal file
67
packages/browser-host/package.json
Normal file
@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@open-matrix/browser-host",
|
||||
"version": "0.1.0",
|
||||
"description": "Browser host shell for Matrix custom elements (extracted from @open-matrix/core during core-decontamination).",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./host": {
|
||||
"types": "./dist/host.d.ts",
|
||||
"import": "./dist/host.js",
|
||||
"default": "./dist/host.js"
|
||||
},
|
||||
"./recovery": {
|
||||
"types": "./dist/recovery.d.ts",
|
||||
"import": "./dist/recovery.js",
|
||||
"default": "./dist/recovery.js"
|
||||
},
|
||||
"./app-ready": {
|
||||
"types": "./dist/app-ready.d.ts",
|
||||
"import": "./dist/app-ready.js",
|
||||
"default": "./dist/app-ready.js"
|
||||
},
|
||||
"./bundle-freshness": {
|
||||
"types": "./dist/BundleFreshness.d.ts",
|
||||
"import": "./dist/BundleFreshness.js",
|
||||
"default": "./dist/BundleFreshness.js"
|
||||
},
|
||||
"./profile-trace": {
|
||||
"types": "./dist/profile-trace.d.ts",
|
||||
"import": "./dist/profile-trace.js",
|
||||
"default": "./dist/profile-trace.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"test": "node --import tsx --test src/__tests__/*.spec.ts",
|
||||
"test:ci": "pnpm run test",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json",
|
||||
"CONTRACT.md",
|
||||
"matrix.control-surface.contract.json"
|
||||
],
|
||||
"dependencies": {
|
||||
"@open-matrix/contracts": "workspace:*",
|
||||
"@open-matrix/federation": "workspace:*",
|
||||
"@open-matrix/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
277
packages/browser-host/src/AppReadySignal.ts
Normal file
277
packages/browser-host/src/AppReadySignal.ts
Normal file
@ -0,0 +1,277 @@
|
||||
import type { IMatrixContext } from '@open-matrix/core';
|
||||
import { RequestReply } from '@open-matrix/core';
|
||||
|
||||
export type MatrixAppReadinessLevel = 'shell' | 'operational' | 'cognitive';
|
||||
|
||||
export interface MatrixAppReadyActorProbe {
|
||||
readonly mount: string;
|
||||
readonly operation: string;
|
||||
readonly ok: boolean;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
export interface MatrixAppReadyActorProbeRequest {
|
||||
readonly mount: string;
|
||||
readonly operation: string;
|
||||
readonly payload?: Record<string, unknown>;
|
||||
readonly targetRoot?: string;
|
||||
readonly timeoutMs?: number;
|
||||
readonly accept?: (result: unknown) => boolean;
|
||||
readonly invalidResultMessage?: string;
|
||||
}
|
||||
|
||||
export interface MatrixAppReadyDetail {
|
||||
readonly appName: string;
|
||||
readonly root: string | null;
|
||||
readonly authorityRoot: string | null;
|
||||
readonly runtimeTargetRoot: string | null;
|
||||
readonly requestedTarget: string | null;
|
||||
readonly actualTarget: string | null;
|
||||
readonly transportConnected: boolean;
|
||||
readonly readinessLevel: MatrixAppReadinessLevel;
|
||||
readonly requiredActorsReachable: boolean;
|
||||
readonly actorProbes: readonly MatrixAppReadyActorProbe[];
|
||||
readonly errors: readonly string[];
|
||||
}
|
||||
|
||||
export interface MatrixAppReadySignalOptions {
|
||||
readonly appName: string;
|
||||
readonly appElement: HTMLElement;
|
||||
readonly root: string | null;
|
||||
readonly authorityRoot: string | null;
|
||||
readonly runtimeTargetRoot: string | null;
|
||||
readonly requestedTarget: string | null;
|
||||
readonly actualTarget: string | null;
|
||||
readonly transportConnected: boolean;
|
||||
readonly readinessLevel: MatrixAppReadinessLevel;
|
||||
readonly requiredActorsReachable: boolean;
|
||||
readonly actorProbes?: readonly MatrixAppReadyActorProbe[];
|
||||
readonly errors: readonly string[];
|
||||
}
|
||||
|
||||
export interface MatrixAppReadyMonitorOptions {
|
||||
readonly appName: string;
|
||||
readonly appSelector: string;
|
||||
readonly hostSelector?: string;
|
||||
readonly timeoutMs?: number;
|
||||
readonly pollIntervalMs?: number;
|
||||
readonly readiness: (
|
||||
appElement: HTMLElement,
|
||||
hostElement: HTMLElement | null
|
||||
) => MatrixAppReadyDetail | Promise<MatrixAppReadyDetail | null> | null;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 100;
|
||||
|
||||
export function markMatrixAppReady(options: MatrixAppReadySignalOptions): MatrixAppReadyDetail {
|
||||
const detail: MatrixAppReadyDetail = {
|
||||
appName: options.appName,
|
||||
root: options.root,
|
||||
authorityRoot: options.authorityRoot,
|
||||
runtimeTargetRoot: options.runtimeTargetRoot,
|
||||
requestedTarget: options.requestedTarget,
|
||||
actualTarget: options.actualTarget,
|
||||
transportConnected: options.transportConnected,
|
||||
readinessLevel: options.readinessLevel,
|
||||
requiredActorsReachable: options.requiredActorsReachable,
|
||||
actorProbes: options.actorProbes ?? [],
|
||||
errors: options.errors,
|
||||
};
|
||||
|
||||
options.appElement.setAttribute('data-app-ready', options.appName);
|
||||
options.appElement.setAttribute('data-app-ready-level', options.readinessLevel);
|
||||
options.appElement.setAttribute('data-app-required-actors-reachable', String(options.requiredActorsReachable));
|
||||
const readyRoot = options.runtimeTargetRoot === null ? '' : options.runtimeTargetRoot;
|
||||
const requestedTarget = options.requestedTarget === null ? '' : options.requestedTarget;
|
||||
const actualTarget = options.actualTarget === null ? '' : options.actualTarget;
|
||||
options.appElement.setAttribute('data-app-ready-root', readyRoot);
|
||||
options.appElement.setAttribute('data-app-ready-requested-target', requestedTarget);
|
||||
options.appElement.setAttribute('data-app-ready-actual-target', actualTarget);
|
||||
document.documentElement.setAttribute('data-app-ready', options.appName);
|
||||
document.documentElement.setAttribute('data-app-ready-level', options.readinessLevel);
|
||||
document.documentElement.setAttribute('data-app-required-actors-reachable', String(options.requiredActorsReachable));
|
||||
document.documentElement.setAttribute('data-app-ready-root', readyRoot);
|
||||
document.documentElement.setAttribute('data-app-ready-requested-target', requestedTarget);
|
||||
document.documentElement.setAttribute('data-app-ready-actual-target', actualTarget);
|
||||
window.dispatchEvent(new CustomEvent<MatrixAppReadyDetail>('matrix-app-ready', { detail }));
|
||||
return detail;
|
||||
}
|
||||
|
||||
export function monitorMatrixAppReady(options: MatrixAppReadyMonitorOptions): void {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
||||
const startedAt = performance.now();
|
||||
let completed = false;
|
||||
let pending = false;
|
||||
let timer: number | null = null;
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const attempt = () => {
|
||||
if (completed || pending) {
|
||||
return;
|
||||
}
|
||||
const appElement = document.querySelector(options.appSelector);
|
||||
let hostElement: HTMLElement | null = null;
|
||||
if (options.hostSelector) {
|
||||
const selectedHost = document.querySelector(options.hostSelector);
|
||||
if (!(selectedHost instanceof HTMLElement)) {
|
||||
scheduleNextAttempt(startedAt, timeoutMs, pollIntervalMs, attempt, (nextTimer) => {
|
||||
timer = nextTimer;
|
||||
}, clearTimer);
|
||||
return;
|
||||
}
|
||||
hostElement = selectedHost;
|
||||
}
|
||||
|
||||
if (appElement instanceof HTMLElement) {
|
||||
const readiness = options.readiness(appElement, hostElement);
|
||||
if (isPromiseLike(readiness)) {
|
||||
pending = true;
|
||||
readiness
|
||||
.then((resolved) => {
|
||||
pending = false;
|
||||
if (resolved) {
|
||||
completeReadiness(options, appElement, resolved, clearTimer);
|
||||
completed = true;
|
||||
return;
|
||||
}
|
||||
scheduleNextAttempt(startedAt, timeoutMs, pollIntervalMs, attempt, (nextTimer) => {
|
||||
timer = nextTimer;
|
||||
}, clearTimer);
|
||||
})
|
||||
.catch(() => {
|
||||
pending = false;
|
||||
scheduleNextAttempt(startedAt, timeoutMs, pollIntervalMs, attempt, (nextTimer) => {
|
||||
timer = nextTimer;
|
||||
}, clearTimer);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (readiness) {
|
||||
completed = true;
|
||||
completeReadiness(options, appElement, readiness, clearTimer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
scheduleNextAttempt(startedAt, timeoutMs, pollIntervalMs, attempt, (nextTimer) => {
|
||||
timer = nextTimer;
|
||||
}, clearTimer);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', clearTimer, { once: true });
|
||||
attempt();
|
||||
}
|
||||
|
||||
export async function probeMatrixAppReadyActors(
|
||||
context: IMatrixContext,
|
||||
probes: readonly MatrixAppReadyActorProbeRequest[],
|
||||
): Promise<readonly MatrixAppReadyActorProbe[]> {
|
||||
const results: MatrixAppReadyActorProbe[] = [];
|
||||
for (const probe of probes) {
|
||||
try {
|
||||
const result = await RequestReply.execute<Record<string, unknown>, unknown>(
|
||||
context,
|
||||
probe.mount,
|
||||
probe.operation,
|
||||
probe.payload ?? {},
|
||||
{
|
||||
timeoutMs: probe.timeoutMs ?? 5000,
|
||||
...(probe.targetRoot ? { targetRoot: probe.targetRoot } : {}),
|
||||
},
|
||||
);
|
||||
if (probe.accept && !probe.accept(result)) {
|
||||
results.push({
|
||||
mount: probe.mount,
|
||||
operation: probe.operation,
|
||||
ok: false,
|
||||
error: probe.invalidResultMessage ?? 'Actor returned an invalid readiness response',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
mount: probe.mount,
|
||||
operation: probe.operation,
|
||||
ok: true,
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
mount: probe.mount,
|
||||
operation: probe.operation,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function matrixAppReadyProbeErrors(
|
||||
probes: readonly MatrixAppReadyActorProbe[],
|
||||
): readonly string[] {
|
||||
return probes
|
||||
.filter((probe) => !probe.ok)
|
||||
.map((probe) => `${probe.mount} ${probe.operation}: ${probe.error ?? 'not reachable'}`);
|
||||
}
|
||||
|
||||
export function matrixAppReadyProbesOk(
|
||||
probes: readonly MatrixAppReadyActorProbe[],
|
||||
): boolean {
|
||||
return probes.length > 0 && probes.every((probe) => probe.ok);
|
||||
}
|
||||
|
||||
function completeReadiness(
|
||||
options: MatrixAppReadyMonitorOptions,
|
||||
appElement: HTMLElement,
|
||||
readiness: MatrixAppReadyDetail,
|
||||
clearTimer: () => void,
|
||||
): void {
|
||||
clearTimer();
|
||||
markMatrixAppReady({
|
||||
appName: options.appName,
|
||||
appElement,
|
||||
root: readiness.root,
|
||||
authorityRoot: readiness.authorityRoot,
|
||||
runtimeTargetRoot: readiness.runtimeTargetRoot,
|
||||
requestedTarget: readiness.requestedTarget,
|
||||
actualTarget: readiness.actualTarget,
|
||||
transportConnected: readiness.transportConnected,
|
||||
readinessLevel: readiness.readinessLevel,
|
||||
requiredActorsReachable: readiness.requiredActorsReachable,
|
||||
actorProbes: readiness.actorProbes,
|
||||
errors: readiness.errors,
|
||||
});
|
||||
}
|
||||
|
||||
function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
|
||||
return Boolean(
|
||||
value
|
||||
&& typeof value === 'object'
|
||||
&& 'then' in value
|
||||
&& typeof (value as { then?: unknown }).then === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleNextAttempt(
|
||||
startedAt: number,
|
||||
timeoutMs: number,
|
||||
pollIntervalMs: number,
|
||||
attempt: () => void,
|
||||
setTimer: (timer: number) => void,
|
||||
clearTimer: () => void,
|
||||
): void {
|
||||
if (performance.now() - startedAt >= timeoutMs) {
|
||||
clearTimer();
|
||||
return;
|
||||
}
|
||||
setTimer(window.setTimeout(attempt, pollIntervalMs));
|
||||
}
|
||||
174
packages/browser-host/src/BrowserProfileTrace.ts
Normal file
174
packages/browser-host/src/BrowserProfileTrace.ts
Normal file
@ -0,0 +1,174 @@
|
||||
type BrowserProfileValue = string | number | boolean | null | undefined;
|
||||
|
||||
export type IBrowserProfileDetails = Record<string, BrowserProfileValue>;
|
||||
|
||||
export interface IBrowserProfileEvent {
|
||||
readonly scope: string;
|
||||
readonly label: string;
|
||||
readonly durationMs?: number;
|
||||
readonly timestamp: string;
|
||||
readonly details?: IBrowserProfileDetails;
|
||||
}
|
||||
|
||||
type BrowserProfileWindow = Window & {
|
||||
__mxProfileEvents?: IBrowserProfileEvent[];
|
||||
__mxProfileSeq?: number;
|
||||
};
|
||||
|
||||
function getWindow(): BrowserProfileWindow | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return window as BrowserProfileWindow;
|
||||
}
|
||||
|
||||
function readProfileSetting(): string | null {
|
||||
const currentWindow = getWindow();
|
||||
if (!currentWindow) {
|
||||
return null;
|
||||
}
|
||||
const search = typeof currentWindow.location?.search === 'string'
|
||||
? currentWindow.location.search
|
||||
: '';
|
||||
const value = new URLSearchParams(search).get('mxProfile');
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim().toLowerCase() : null;
|
||||
}
|
||||
|
||||
function formatDetails(details?: IBrowserProfileDetails): string {
|
||||
if (!details) {
|
||||
return '';
|
||||
}
|
||||
const entries = Object.entries(details)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key, value]) => `${key}=${String(value)}`);
|
||||
return entries.length > 0 ? ` ${entries.join(' ')}` : '';
|
||||
}
|
||||
|
||||
function appendBrowserProfileEvent(event: IBrowserProfileEvent): void {
|
||||
const currentWindow = getWindow();
|
||||
if (!currentWindow) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(currentWindow.__mxProfileEvents)) {
|
||||
currentWindow.__mxProfileEvents = [];
|
||||
}
|
||||
currentWindow.__mxProfileEvents.push(event);
|
||||
}
|
||||
|
||||
function nextProfileSequence(): number {
|
||||
const currentWindow = getWindow();
|
||||
if (!currentWindow) {
|
||||
return 0;
|
||||
}
|
||||
currentWindow.__mxProfileSeq = (currentWindow.__mxProfileSeq ?? 0) + 1;
|
||||
return currentWindow.__mxProfileSeq;
|
||||
}
|
||||
|
||||
function nowMs(): number {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||
return performance.now();
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export function isBrowserProfileEnabled(scope: string): boolean {
|
||||
const setting = readProfileSetting();
|
||||
if (!setting) {
|
||||
return false;
|
||||
}
|
||||
if (setting === '1' || setting === 'true' || setting === 'all' || setting === '*') {
|
||||
return true;
|
||||
}
|
||||
return setting
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.includes(scope.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function logBrowserProfile(
|
||||
scope: string,
|
||||
label: string,
|
||||
details?: IBrowserProfileDetails,
|
||||
): void {
|
||||
if (!isBrowserProfileEnabled(scope)) {
|
||||
return;
|
||||
}
|
||||
console.info(`[MxProfile:${scope}] ${label}${formatDetails(details)}`);
|
||||
appendBrowserProfileEvent({
|
||||
scope,
|
||||
label,
|
||||
details,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBrowserProfileSpan(
|
||||
scope: string,
|
||||
label: string,
|
||||
details?: IBrowserProfileDetails,
|
||||
): (finalDetails?: IBrowserProfileDetails) => void {
|
||||
if (!isBrowserProfileEnabled(scope)) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const startTime = nowMs();
|
||||
const sequence = nextProfileSequence();
|
||||
const startMark = `mx-profile:${scope}:${label}:start:${sequence}`;
|
||||
const endMark = `mx-profile:${scope}:${label}:end:${sequence}`;
|
||||
if (typeof performance !== 'undefined' && typeof performance.mark === 'function') {
|
||||
performance.mark(startMark);
|
||||
}
|
||||
if (details) {
|
||||
logBrowserProfile(scope, `${label}:start`, details);
|
||||
} else {
|
||||
logBrowserProfile(scope, `${label}:start`);
|
||||
}
|
||||
|
||||
return (finalDetails?: IBrowserProfileDetails) => {
|
||||
const durationMs = nowMs() - startTime;
|
||||
if (typeof performance !== 'undefined' && typeof performance.mark === 'function') {
|
||||
performance.mark(endMark);
|
||||
}
|
||||
if (
|
||||
typeof performance !== 'undefined'
|
||||
&& typeof performance.measure === 'function'
|
||||
) {
|
||||
performance.measure(`mx-profile:${scope}:${label}`, startMark, endMark);
|
||||
}
|
||||
|
||||
const mergedDetails = finalDetails
|
||||
? { ...(details ?? {}), ...finalDetails }
|
||||
: details;
|
||||
|
||||
console.info(
|
||||
`[MxProfile:${scope}] ${label} ${durationMs.toFixed(1)}ms${formatDetails(mergedDetails)}`,
|
||||
);
|
||||
appendBrowserProfileEvent({
|
||||
scope,
|
||||
label,
|
||||
durationMs,
|
||||
details: mergedDetails,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export async function profileBrowserSpan<T>(
|
||||
scope: string,
|
||||
label: string,
|
||||
fn: () => Promise<T>,
|
||||
details?: IBrowserProfileDetails,
|
||||
): Promise<T> {
|
||||
const finish = createBrowserProfileSpan(scope, label, details);
|
||||
try {
|
||||
const result = await fn();
|
||||
finish();
|
||||
return result;
|
||||
} catch (error) {
|
||||
finish({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
197
packages/browser-host/src/BrowserRuntimeControlActor.ts
Normal file
197
packages/browser-host/src/BrowserRuntimeControlActor.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import { MatrixActor } from '@open-matrix/core';
|
||||
import { MatrixRuntime } from '@open-matrix/core';
|
||||
|
||||
type MatrixIntrospect = ReturnType<MatrixActor['on$introspect']>;
|
||||
type MatrixIntrospectChild = NonNullable<MatrixIntrospect['children']>[number];
|
||||
|
||||
export type BrowserRuntimeLifecycleState = 'live' | 'closing';
|
||||
|
||||
export interface IBrowserRuntimeControlService {
|
||||
readonly runtimeId: string;
|
||||
readonly runtimeMount: string;
|
||||
readonly startedAt: string;
|
||||
getState(): BrowserRuntimeLifecycleState;
|
||||
getRuntime(): MatrixRuntime;
|
||||
getInventory(): readonly IBrowserRuntimeControlInventoryEntry[];
|
||||
}
|
||||
|
||||
export interface IMountBrowserRuntimeControlOptions {
|
||||
readonly runtime: MatrixRuntime;
|
||||
readonly runtimeId: string;
|
||||
readonly runtimeMount: string;
|
||||
readonly controlMount: string;
|
||||
readonly startedAt: string;
|
||||
getState(): BrowserRuntimeLifecycleState;
|
||||
getInventory?: () => readonly IBrowserRuntimeControlInventoryEntry[];
|
||||
}
|
||||
|
||||
export interface IBrowserRuntimeControlInventoryEntry {
|
||||
readonly mount: string;
|
||||
readonly type?: string;
|
||||
}
|
||||
|
||||
const BROWSER_RUNTIME_CONTROL_SERVICE = 'browser-runtime-control';
|
||||
|
||||
export class BrowserRuntimeControlActor extends MatrixActor {
|
||||
static readonly hasDynamicChildren = true;
|
||||
static description = 'Runtime control actor for a browser tab runtime.';
|
||||
|
||||
static override accepts: Record<string, unknown> = {
|
||||
'runtime.status': { description: 'Report browser runtime lifecycle and direct actor inventory', options: 'object?' },
|
||||
'runtime.ready': { description: 'Report whether the browser runtime is ready for traffic', options: 'object?' },
|
||||
'runtime.health': { description: 'Report browser runtime health', options: 'object?' },
|
||||
};
|
||||
|
||||
protected override onIntrospect(payload?: { depth?: 'basic' | 'full' }): ReturnType<MatrixActor['onIntrospect']> {
|
||||
const base = super.onIntrospect(payload);
|
||||
const service = this._service();
|
||||
// The browser runtime actor extends the base introspect shape with
|
||||
// browser-specific runtime metadata. The supertype declares a strict
|
||||
// shape; cast through unknown so the extension stays type-safe at
|
||||
// the call site of `base` while satisfying the override signature.
|
||||
const extended: ReturnType<MatrixActor['onIntrospect']> = {
|
||||
...base,
|
||||
children: [
|
||||
...(base.children ?? []),
|
||||
...this._directRuntimeChildren(service),
|
||||
],
|
||||
};
|
||||
return Object.assign(extended, {
|
||||
type: 'runtime',
|
||||
runtimeId: service.runtimeId,
|
||||
runtimeType: 'browser',
|
||||
state: service.getState(),
|
||||
startedAt: service.startedAt,
|
||||
uptimeMs: Date.now() - Date.parse(service.startedAt),
|
||||
});
|
||||
}
|
||||
|
||||
onRuntimeStatus(): Record<string, unknown> {
|
||||
const service = this._service();
|
||||
const runtime = service.getRuntime();
|
||||
const mounts = runtime.getAllComponents().map((component) => component.mount);
|
||||
return {
|
||||
ok: true,
|
||||
runtimeId: service.runtimeId,
|
||||
runtimeMount: service.runtimeMount,
|
||||
type: 'browser',
|
||||
state: service.getState(),
|
||||
startedAt: service.startedAt,
|
||||
uptimeMs: Date.now() - Date.parse(service.startedAt),
|
||||
actorCount: mounts.length,
|
||||
mounts,
|
||||
rootActors: this._directRuntimeChildren(service).map((child) => ({
|
||||
mount: child.mount,
|
||||
componentClass: child.componentClass,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
onRuntimeReady(): Record<string, unknown> {
|
||||
const state = this._service().getState();
|
||||
return {
|
||||
ok: true,
|
||||
ready: state === 'live',
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
onRuntimeHealth(): Record<string, unknown> {
|
||||
const service = this._service();
|
||||
const state = service.getState();
|
||||
return {
|
||||
ok: true,
|
||||
healthy: state === 'live',
|
||||
runtimeId: service.runtimeId,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
private _directRuntimeChildren(service: IBrowserRuntimeControlService): MatrixIntrospectChild[] {
|
||||
const runtimeMount = service.runtimeMount.trim();
|
||||
const currentMount = this.mount.trim();
|
||||
const seen = new Set<string>();
|
||||
const children: MatrixIntrospectChild[] = [];
|
||||
|
||||
for (const entry of service.getInventory()) {
|
||||
const actualMount = typeof entry.mount === 'string' ? entry.mount.trim() : '';
|
||||
const childMount = this._directChildMount(runtimeMount, currentMount, actualMount);
|
||||
if (!childMount || seen.has(childMount)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(childMount);
|
||||
children.push({
|
||||
id: childMount,
|
||||
slot: 'runtime',
|
||||
mount: childMount,
|
||||
componentClass: entry.type || 'RuntimeInventoryActor',
|
||||
accepts: [],
|
||||
emits: [],
|
||||
tracks: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const component of service.getRuntime().getAllComponents()) {
|
||||
const actualMount = component.mount.trim();
|
||||
const childMount = this._directChildMount(runtimeMount, currentMount, actualMount);
|
||||
if (!childMount || seen.has(childMount)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(childMount);
|
||||
children.push({
|
||||
id: childMount,
|
||||
slot: 'runtime',
|
||||
mount: childMount,
|
||||
componentClass: component.constructor.name || 'MatrixActor',
|
||||
accepts: [],
|
||||
emits: [],
|
||||
tracks: [],
|
||||
});
|
||||
}
|
||||
|
||||
return children.sort((left, right) => left.mount.localeCompare(right.mount));
|
||||
}
|
||||
|
||||
private _directChildMount(runtimeMount: string, currentMount: string, actualMount: string): string | null {
|
||||
if (!actualMount || actualMount === currentMount) {
|
||||
return null;
|
||||
}
|
||||
if (runtimeMount && actualMount.startsWith(`${runtimeMount}.`)) {
|
||||
const localMount = actualMount.slice(runtimeMount.length + 1);
|
||||
return localMount && !localMount.includes('.') ? localMount : null;
|
||||
}
|
||||
if (!actualMount.includes('.')) {
|
||||
return actualMount;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _service(): IBrowserRuntimeControlService {
|
||||
const service = this.context?.getService<IBrowserRuntimeControlService>(BROWSER_RUNTIME_CONTROL_SERVICE);
|
||||
if (!service) {
|
||||
throw new Error('Browser runtime control service is not registered');
|
||||
}
|
||||
return service;
|
||||
}
|
||||
}
|
||||
|
||||
export async function mountBrowserRuntimeControl(
|
||||
options: IMountBrowserRuntimeControlOptions,
|
||||
): Promise<string> {
|
||||
const service: IBrowserRuntimeControlService = {
|
||||
runtimeId: options.runtimeId,
|
||||
runtimeMount: options.runtimeMount,
|
||||
startedAt: options.startedAt,
|
||||
getRuntime: () => options.runtime,
|
||||
getState: options.getState,
|
||||
getInventory: () => options.getInventory?.() ?? [],
|
||||
};
|
||||
options.runtime.registerService(BROWSER_RUNTIME_CONTROL_SERVICE, service);
|
||||
if (!options.runtime.hasComponent(options.runtimeMount)) {
|
||||
await options.runtime.createSupervised(BrowserRuntimeControlActor, options.runtimeMount);
|
||||
}
|
||||
if (options.controlMount !== options.runtimeMount && !options.runtime.hasComponent(options.controlMount)) {
|
||||
await options.runtime.createSupervised(BrowserRuntimeControlActor, options.controlMount);
|
||||
}
|
||||
return options.controlMount;
|
||||
}
|
||||
112
packages/browser-host/src/BundleFreshness.ts
Normal file
112
packages/browser-host/src/BundleFreshness.ts
Normal file
@ -0,0 +1,112 @@
|
||||
export interface BundleFreshnessMonitorOptions {
|
||||
readonly appName: string;
|
||||
readonly currentModuleUrl: string;
|
||||
readonly enabled?: boolean;
|
||||
readonly intervalMs?: number;
|
||||
readonly documentUrl?: string;
|
||||
readonly fetchHtml?: (url: string) => Promise<string>;
|
||||
readonly reload?: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_BUNDLE_FRESHNESS_INTERVAL_MS = 30_000;
|
||||
const MIN_BUNDLE_FRESHNESS_INTERVAL_MS = 5_000;
|
||||
|
||||
export function htmlReferencesBundle(
|
||||
html: string,
|
||||
currentModuleUrl: string,
|
||||
documentUrl: string,
|
||||
): boolean {
|
||||
const currentPath = normalizeUrlPath(currentModuleUrl, documentUrl);
|
||||
const scriptPattern = /<script\b[^>]*\bsrc=(["'])(.*?)\1/gi;
|
||||
for (const match of html.matchAll(scriptPattern)) {
|
||||
const rawSrc = match[2]?.trim();
|
||||
if (!rawSrc) {
|
||||
continue;
|
||||
}
|
||||
if (normalizeUrlPath(rawSrc, documentUrl) === currentPath) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const inlineImportPattern = /\bimport\s+(?:[^'"]+\s+from\s+)?(["'])(.*?)\1/gi;
|
||||
for (const match of html.matchAll(inlineImportPattern)) {
|
||||
const rawImport = match[2]?.trim();
|
||||
if (!rawImport) {
|
||||
continue;
|
||||
}
|
||||
if (normalizeUrlPath(rawImport, documentUrl) === currentPath) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function installBundleFreshnessMonitor(
|
||||
options: BundleFreshnessMonitorOptions,
|
||||
): () => void {
|
||||
if (options.enabled === false || typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const documentUrl = options.documentUrl ?? window.location.href;
|
||||
const fetchHtml = options.fetchHtml ?? fetchDocumentHtml;
|
||||
const reload = options.reload ?? (() => window.location.reload());
|
||||
const intervalMs = Math.max(
|
||||
options.intervalMs ?? DEFAULT_BUNDLE_FRESHNESS_INTERVAL_MS,
|
||||
MIN_BUNDLE_FRESHNESS_INTERVAL_MS,
|
||||
);
|
||||
let checking = false;
|
||||
let disposed = false;
|
||||
|
||||
const check = async () => {
|
||||
if (checking || disposed) {
|
||||
return;
|
||||
}
|
||||
checking = true;
|
||||
try {
|
||||
const html = await fetchHtml(documentUrl);
|
||||
if (!htmlReferencesBundle(html, options.currentModuleUrl, documentUrl)) {
|
||||
console.warn(
|
||||
`[${options.appName}] Loaded bundle is no longer the served entrypoint; reloading.`,
|
||||
);
|
||||
reload();
|
||||
}
|
||||
} catch {
|
||||
// Keep the running app active if the entrypoint probe itself fails.
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void check();
|
||||
}
|
||||
};
|
||||
const timer = window.setInterval(() => {
|
||||
void check();
|
||||
}, intervalMs);
|
||||
window.addEventListener('focus', check);
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener('focus', check);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUrlPath(value: string, baseUrl: string): string {
|
||||
return new URL(value, baseUrl).pathname;
|
||||
}
|
||||
|
||||
async function fetchDocumentHtml(url: string): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
accept: 'text/html',
|
||||
},
|
||||
});
|
||||
return response.text();
|
||||
}
|
||||
145
packages/browser-host/src/HostedRuntimeRecovery.ts
Normal file
145
packages/browser-host/src/HostedRuntimeRecovery.ts
Normal file
@ -0,0 +1,145 @@
|
||||
type HostedRuntimeUnavailableReason = 'missing' | 'not-live' | 'unauthorized';
|
||||
|
||||
export interface IHostedRuntimeRecoveryInput {
|
||||
readonly runtimeRoot?: string | null;
|
||||
readonly authorityRoot?: string | null;
|
||||
readonly requestedRoot?: string | null;
|
||||
readonly unavailableReason?: string | null;
|
||||
readonly currentHref?: string | null;
|
||||
}
|
||||
|
||||
export interface IHostedRuntimeRecoveryAction {
|
||||
readonly key: 'retry' | 'auto' | 'shell';
|
||||
readonly label: string;
|
||||
readonly href: string;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
export interface IHostedRuntimeRecoveryPlan {
|
||||
readonly requestedRoot: string;
|
||||
readonly runtimeRoot: string;
|
||||
readonly authorityRoot: string;
|
||||
readonly unavailableReason: HostedRuntimeUnavailableReason;
|
||||
readonly summary: string;
|
||||
readonly actions: IHostedRuntimeRecoveryAction[];
|
||||
}
|
||||
|
||||
function normalize(value: string | null | undefined): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function serializeRelativeUrl(url: URL): string {
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
function resolveCurrentHref(currentHref?: string | null): string | null {
|
||||
const direct = normalize(currentHref);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.location?.href === 'string') {
|
||||
return window.location.href;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildSummary(
|
||||
requestedRoot: string,
|
||||
runtimeRoot: string,
|
||||
authorityRoot: string,
|
||||
unavailableReason: HostedRuntimeUnavailableReason,
|
||||
): string {
|
||||
if (unavailableReason === 'unauthorized') {
|
||||
const authorityLabel = authorityRoot || 'the signed-in authority';
|
||||
return `Requested target ${requestedRoot} is not authorized for ${authorityLabel}.`;
|
||||
}
|
||||
const reasonText = unavailableReason === 'not-live'
|
||||
? 'is registered but currently offline'
|
||||
: 'is no longer registered';
|
||||
if (requestedRoot === runtimeRoot) {
|
||||
return `Requested runtime ${requestedRoot} ${reasonText}.`;
|
||||
}
|
||||
if (!runtimeRoot) {
|
||||
const authorityLabel = authorityRoot || 'the selected authority';
|
||||
return `Requested runtime ${requestedRoot} ${reasonText}; no live runtime is currently attached for ${authorityLabel}.`;
|
||||
}
|
||||
return `Requested runtime ${requestedRoot} ${reasonText}; clear the selected target before using ${runtimeRoot}.`;
|
||||
}
|
||||
|
||||
export function resolveHostedRuntimeRecoveryPlan(
|
||||
input: IHostedRuntimeRecoveryInput,
|
||||
): IHostedRuntimeRecoveryPlan | null {
|
||||
const requestedRoot = normalize(input.requestedRoot);
|
||||
const runtimeRoot = normalize(input.runtimeRoot);
|
||||
const authorityRoot = normalize(input.authorityRoot);
|
||||
const unavailableReasonRaw = normalize(input.unavailableReason);
|
||||
const unavailableReason = unavailableReasonRaw === 'missing'
|
||||
|| unavailableReasonRaw === 'not-live'
|
||||
|| unavailableReasonRaw === 'unauthorized'
|
||||
? unavailableReasonRaw
|
||||
: null;
|
||||
if (!requestedRoot || !unavailableReason) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summary = buildSummary(requestedRoot, runtimeRoot, authorityRoot, unavailableReason);
|
||||
const actions: IHostedRuntimeRecoveryAction[] = [];
|
||||
const href = resolveCurrentHref(input.currentHref);
|
||||
|
||||
if (href) {
|
||||
try {
|
||||
const currentUrl = new URL(href, 'https://hivecast.ai');
|
||||
const hasExplicitTarget = currentUrl.searchParams.has('daemonRoot') || currentUrl.searchParams.has('daemonRealm');
|
||||
const relativeCurrentHref = serializeRelativeUrl(currentUrl);
|
||||
actions.push({
|
||||
key: 'retry',
|
||||
label: 'Retry',
|
||||
href: relativeCurrentHref,
|
||||
title: 'Retry the current hosted app target.',
|
||||
});
|
||||
if (hasExplicitTarget) {
|
||||
const autoUrl = new URL(currentUrl.toString());
|
||||
autoUrl.searchParams.delete('daemonRoot');
|
||||
autoUrl.searchParams.delete('daemonRealm');
|
||||
actions.push({
|
||||
key: 'auto',
|
||||
label: 'Use Auto',
|
||||
href: serializeRelativeUrl(autoUrl),
|
||||
title: 'Clear the explicit runtime target and let HiveCast pick the current live target.',
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
key: 'shell',
|
||||
label: 'Open Shell',
|
||||
href: `/apps/web/#dashboard?redirect=${encodeURIComponent(relativeCurrentHref)}`,
|
||||
title: 'Open the HiveCast shell and return to this app after choosing a target.',
|
||||
});
|
||||
return {
|
||||
requestedRoot,
|
||||
runtimeRoot,
|
||||
authorityRoot,
|
||||
unavailableReason,
|
||||
summary,
|
||||
actions,
|
||||
};
|
||||
} catch {
|
||||
// Ignore malformed URLs and show shell-only recovery.
|
||||
}
|
||||
}
|
||||
|
||||
actions.push({
|
||||
key: 'shell',
|
||||
label: 'Open Shell',
|
||||
href: '/apps/web/#dashboard',
|
||||
title: 'Open the HiveCast shell to inspect or switch attached leaves.',
|
||||
});
|
||||
|
||||
return {
|
||||
requestedRoot,
|
||||
runtimeRoot,
|
||||
authorityRoot,
|
||||
unavailableReason,
|
||||
summary,
|
||||
actions,
|
||||
};
|
||||
}
|
||||
139
packages/browser-host/src/HostedRuntimeTarget.ts
Normal file
139
packages/browser-host/src/HostedRuntimeTarget.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import type { MatrixAuthorityContext } from '@open-matrix/core';
|
||||
|
||||
type HostLike = {
|
||||
getAttribute?: (name: string) => string | null;
|
||||
dataset?: Record<string, string | undefined>;
|
||||
} | null | undefined;
|
||||
|
||||
type HostLocatorLike = {
|
||||
closest?: (selector: string) => unknown;
|
||||
getRootNode?: () => unknown;
|
||||
} | null | undefined;
|
||||
|
||||
export const HOSTED_RUNTIME_TARGET_CHANGED_EVENT = 'matrix:hosted-runtime-target-changed';
|
||||
|
||||
export interface IHostedRuntimeTargetInfo {
|
||||
runtimeRoot: string;
|
||||
runtimeInstanceRoot: string;
|
||||
authorityRoot: string;
|
||||
assetHostRoot: string;
|
||||
platformServiceRoot: string;
|
||||
sessionAuthorityRoot: string;
|
||||
selectedAuthorityRoot: string;
|
||||
platformRoot: string;
|
||||
targetSource: string;
|
||||
targetKind: string;
|
||||
requestedRoot: string;
|
||||
unavailableReason: string;
|
||||
isAuthenticated: boolean;
|
||||
isPlatformAdmin: boolean;
|
||||
selectableAuthorityRoots: readonly string[];
|
||||
}
|
||||
|
||||
function trimValue(value: string | null | undefined): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function readBoolean(value: string | null | undefined): boolean {
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
function readCsv(value: string | null | undefined): readonly string[] {
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
return value.split(',').map((item) => item.trim()).filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
export function resolveHostedRuntimeTargetInfo(host: HostLike): IHostedRuntimeTargetInfo {
|
||||
const runtimeInstanceRoot = trimValue(
|
||||
host?.dataset?.runtimeInstanceRoot
|
||||
?? host?.getAttribute?.('runtime-root')
|
||||
?? host?.dataset?.runtimeRoot
|
||||
?? host?.dataset?.runtimeTargetRoot,
|
||||
);
|
||||
const selectedAuthorityRoot = trimValue(host?.dataset?.selectedAuthorityRoot);
|
||||
const sessionAuthorityRoot = trimValue(host?.dataset?.sessionAuthorityRoot);
|
||||
return {
|
||||
runtimeRoot: runtimeInstanceRoot,
|
||||
runtimeInstanceRoot,
|
||||
authorityRoot: selectedAuthorityRoot || sessionAuthorityRoot,
|
||||
assetHostRoot: trimValue(host?.dataset?.assetHostRoot),
|
||||
platformServiceRoot: trimValue(host?.dataset?.platformServiceRoot),
|
||||
sessionAuthorityRoot,
|
||||
selectedAuthorityRoot,
|
||||
platformRoot: trimValue(
|
||||
host?.dataset?.platformServiceRoot
|
||||
?? host?.getAttribute?.('bus-root')
|
||||
?? host?.dataset?.platformRoot,
|
||||
),
|
||||
targetSource: trimValue(host?.dataset?.runtimeTargetSource),
|
||||
targetKind: trimValue(host?.dataset?.runtimeTargetKind),
|
||||
requestedRoot: trimValue(host?.dataset?.runtimeTargetRequestedRoot),
|
||||
unavailableReason: trimValue(host?.dataset?.runtimeTargetUnavailableReason),
|
||||
isAuthenticated: readBoolean(host?.dataset?.authenticated),
|
||||
isPlatformAdmin: readBoolean(host?.dataset?.isPlatformAdmin),
|
||||
selectableAuthorityRoots: readCsv(host?.dataset?.selectableAuthorityRoots),
|
||||
};
|
||||
}
|
||||
|
||||
export function hostedTargetAuthorityContext(info: IHostedRuntimeTargetInfo): MatrixAuthorityContext {
|
||||
return {
|
||||
assetHostRoot: info.assetHostRoot,
|
||||
platformServiceRoot: info.platformServiceRoot || info.platformRoot,
|
||||
sessionAuthorityRoot: info.sessionAuthorityRoot || null,
|
||||
selectedAuthorityRoot: info.selectedAuthorityRoot || null,
|
||||
runtimeInstanceRoot: info.runtimeInstanceRoot || info.runtimeRoot || null,
|
||||
isAuthenticated: info.isAuthenticated,
|
||||
isPlatformAdmin: info.isPlatformAdmin,
|
||||
selectableAuthorityRoots: info.selectableAuthorityRoots,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveHostedRuntimeTargetHost(node: HostLocatorLike): HostLike {
|
||||
let current = node;
|
||||
const visited = new Set<unknown>();
|
||||
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
const direct = typeof current.closest === 'function'
|
||||
? current.closest('matrix-dsl-host')
|
||||
: null;
|
||||
if (direct && typeof direct === 'object') {
|
||||
return direct as HostLike;
|
||||
}
|
||||
const root = typeof current.getRootNode === 'function'
|
||||
? current.getRootNode()
|
||||
: null;
|
||||
if (!root || typeof root !== 'object' || !('host' in root)) {
|
||||
break;
|
||||
}
|
||||
current = (root as { host?: HostLocatorLike }).host ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hostedRuntimeTargetInfoEquals(
|
||||
left: IHostedRuntimeTargetInfo,
|
||||
right: IHostedRuntimeTargetInfo,
|
||||
): boolean {
|
||||
return (
|
||||
left.runtimeRoot === right.runtimeRoot
|
||||
&& left.runtimeInstanceRoot === right.runtimeInstanceRoot
|
||||
&& left.authorityRoot === right.authorityRoot
|
||||
&& left.assetHostRoot === right.assetHostRoot
|
||||
&& left.platformServiceRoot === right.platformServiceRoot
|
||||
&& left.sessionAuthorityRoot === right.sessionAuthorityRoot
|
||||
&& left.selectedAuthorityRoot === right.selectedAuthorityRoot
|
||||
&& left.platformRoot === right.platformRoot
|
||||
&& left.targetSource === right.targetSource
|
||||
&& left.targetKind === right.targetKind
|
||||
&& left.requestedRoot === right.requestedRoot
|
||||
&& left.unavailableReason === right.unavailableReason
|
||||
&& left.isAuthenticated === right.isAuthenticated
|
||||
&& left.isPlatformAdmin === right.isPlatformAdmin
|
||||
&& left.selectableAuthorityRoots.length === right.selectableAuthorityRoots.length
|
||||
&& left.selectableAuthorityRoots.every((value, index) => value === right.selectableAuthorityRoots[index])
|
||||
);
|
||||
}
|
||||
4440
packages/browser-host/src/MatrixDSLHost.ts
Normal file
4440
packages/browser-host/src/MatrixDSLHost.ts
Normal file
File diff suppressed because it is too large
Load Diff
42
packages/browser-host/src/__tests__/BundleFreshness.spec.ts
Normal file
42
packages/browser-host/src/__tests__/BundleFreshness.spec.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
|
||||
import { htmlReferencesBundle } from '../BundleFreshness';
|
||||
|
||||
describe('htmlReferencesBundle', () => {
|
||||
it('accepts the currently served Vite entry module', () => {
|
||||
const html = '<script type="module" crossorigin src="/apps/director/assets/index-new.js"></script>';
|
||||
assert.equal(
|
||||
htmlReferencesBundle(
|
||||
html,
|
||||
'https://dev.hivecast.ai/apps/director/assets/index-new.js',
|
||||
'https://dev.hivecast.ai/apps/director/',
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts Vite dev HTML that imports the entrypoint from an inline module script', () => {
|
||||
const html = '<script type="module">import "./index.ts";</script>';
|
||||
assert.equal(
|
||||
htmlReferencesBundle(
|
||||
html,
|
||||
'https://local.hivecast.ai/apps/web/index.ts',
|
||||
'https://local.hivecast.ai/apps/web/',
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an older content-hashed bundle', () => {
|
||||
const html = '<script type="module" crossorigin src="/apps/director/assets/index-new.js"></script>';
|
||||
assert.equal(
|
||||
htmlReferencesBundle(
|
||||
html,
|
||||
'https://dev.hivecast.ai/apps/director/assets/index-old.js',
|
||||
'https://dev.hivecast.ai/apps/director/',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,92 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { resolveHostedRuntimeTargetInfo } from '../HostedRuntimeTarget';
|
||||
|
||||
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('resolveHostedRuntimeTargetInfo', () => {
|
||||
it('keeps asset/platform/session/selected roots separate', () => {
|
||||
const host = {
|
||||
getAttribute(name: string): string | null {
|
||||
if (name === 'runtime-root') return 'test-user-1.DIRECTOR.SESSION-ABC';
|
||||
if (name === 'authority-root') return 'SPACE-HIVECAST';
|
||||
return null;
|
||||
},
|
||||
dataset: {
|
||||
assetHostRoot: 'SPACE-HIVECAST',
|
||||
platformServiceRoot: 'SPACE-HIVECAST',
|
||||
sessionAuthorityRoot: 'test-user-1',
|
||||
selectedAuthorityRoot: 'test-user-1',
|
||||
selectableAuthorityRoots: 'test-user-1',
|
||||
platformRoot: 'SPACE-HIVECAST',
|
||||
authenticated: 'true',
|
||||
isPlatformAdmin: 'false',
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(resolveHostedRuntimeTargetInfo(host), {
|
||||
runtimeRoot: 'test-user-1.DIRECTOR.SESSION-ABC',
|
||||
runtimeInstanceRoot: 'test-user-1.DIRECTOR.SESSION-ABC',
|
||||
authorityRoot: 'test-user-1',
|
||||
assetHostRoot: 'SPACE-HIVECAST',
|
||||
platformServiceRoot: 'SPACE-HIVECAST',
|
||||
sessionAuthorityRoot: 'test-user-1',
|
||||
selectedAuthorityRoot: 'test-user-1',
|
||||
platformRoot: 'SPACE-HIVECAST',
|
||||
targetSource: '',
|
||||
targetKind: '',
|
||||
requestedRoot: '',
|
||||
unavailableReason: '',
|
||||
isAuthenticated: true,
|
||||
isPlatformAdmin: false,
|
||||
selectableAuthorityRoots: ['test-user-1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('requests browser NATS credentials for selectedAuthorityRoot, not sessionAuthorityRoot', () => {
|
||||
const source = fs.readFileSync(path.join(thisDir, '..', 'MatrixDSLHost.ts'), 'utf8');
|
||||
assert.match(source, /const selectedAuthorityRoot = \(this\.dataset\.selectedAuthorityRoot \?\? ''\)\.trim\(\);/);
|
||||
assert.match(source, /: \(selectedAuthorityRoot \|\| requestedDaemonRoot\);/);
|
||||
assert.doesNotMatch(source, /principalAuthorityRoot[\s\S]{0,220}requestedDaemonRoot === platformRoot/);
|
||||
});
|
||||
|
||||
it('does not let addressRoot decide session or authority context roots', () => {
|
||||
const source = fs.readFileSync(path.join(thisDir, '..', 'MatrixDSLHost.ts'), 'utf8');
|
||||
assert.doesNotMatch(source, /bootstrap\.authenticated \? bootstrap\.addressRoot/);
|
||||
assert.doesNotMatch(source, /const authorityRootService =[\s\S]{0,120}\|\| this\.dataset\.addressRoot/);
|
||||
});
|
||||
|
||||
it('does not promote anonymous platform bootstrap into selectedAuthorityRoot', () => {
|
||||
const source = fs.readFileSync(path.join(thisDir, '..', 'MatrixDSLHost.ts'), 'utf8');
|
||||
assert.match(source, /parseBootstrapAuthorityContext\(bootstrap\.authorityContext\)/);
|
||||
assert.match(source, /const selectedAuthorityRoot = authorityContext\.selectedAuthorityRoot \?\? '';/);
|
||||
assert.doesNotMatch(source, /bootstrapSelectedAuthorityRoot/);
|
||||
assert.doesNotMatch(source, /bootstrapAuthorityContextRoot/);
|
||||
});
|
||||
|
||||
it('does not let runtime summary addressRoot re-home selectedAuthorityRoot', () => {
|
||||
const source = fs.readFileSync(path.join(thisDir, '..', 'MatrixDSLHost.ts'), 'utf8');
|
||||
assert.match(source, /_hostedAuthorityRootFromBootstrap/);
|
||||
assert.doesNotMatch(source, /_selectHostedAuthorityRoot/);
|
||||
assert.doesNotMatch(source, /summaryAuthorityRoot/);
|
||||
assert.doesNotMatch(source, /summary\.authority\?\.addressRoot/);
|
||||
});
|
||||
|
||||
it('skips Phase B platform probes for normal user authority sessions', () => {
|
||||
const source = fs.readFileSync(path.join(thisDir, '..', 'MatrixDSLHost.ts'), 'utf8');
|
||||
assert.match(source, /const isNormalUserAuthoritySession = bootstrapAuthenticated/);
|
||||
assert.match(source, /userAuthorityRoot !== serviceRoot/);
|
||||
assert.match(source, /Phase B platform probe skipped for user authority session/);
|
||||
});
|
||||
|
||||
it('does not refresh bus tokens through a platform auth target for normal users', () => {
|
||||
const source = fs.readFileSync(path.join(thisDir, '..', 'MatrixDSLHost.ts'), 'utf8');
|
||||
assert.match(source, /_shouldSkipBusTokenRefreshForTarget\(authTarget\)/);
|
||||
assert.match(source, /targetRoot !== userAuthorityRoot/);
|
||||
assert.match(source, /Bus token refresh skipped for platform auth target in user authority session/);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,56 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('MatrixDSLHost login-required bootstrap', () => {
|
||||
it('does not auto-redirect matrix-web away from its own login/signup shell', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../MatrixDSLHost.ts'), 'utf8');
|
||||
|
||||
assert.match(source, /hostedAppName !== 'web'/);
|
||||
assert.match(source, /this\._redirectToPlatformLogin\(\)/);
|
||||
});
|
||||
|
||||
it('reserves device handoff for the edge Device shell', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../MatrixDSLHost.ts'), 'utf8');
|
||||
|
||||
assert.match(source, /_shouldRedirectToDeviceLogin\(hostedAppName/);
|
||||
assert.match(source, /normalizedAppName === 'edge'/);
|
||||
assert.match(source, /this\._redirectToDeviceLogin\(\)/);
|
||||
});
|
||||
|
||||
it('keeps the NATS backbone available for authenticated Matrix Web namespace setup', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../MatrixDSLHost.ts'), 'utf8');
|
||||
|
||||
assert.match(source, /const natsWsUrl = natsWsAttr \?\? \(!discoveredNats \? backboneUrl : null\);/);
|
||||
assert.doesNotMatch(source, /renderNamespaceSetupLocally\s*\?\s*null\s*:/);
|
||||
});
|
||||
|
||||
it('does not query platform registry metadata for the Matrix Web shell itself', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../MatrixDSLHost.ts'), 'utf8');
|
||||
|
||||
assert.match(source, /const appName = this\._deriveHostedAppName\(\);[\s\S]*if \(!appName\) return;[\s\S]*if \(appName === 'web'\) return;/);
|
||||
});
|
||||
|
||||
it('does not query platform registry metadata for standalone apps before a user Space is selected', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../MatrixDSLHost.ts'), 'utf8');
|
||||
|
||||
assert.match(source, /const platformServiceRoot = \(this\.dataset\.platformServiceRoot \?\? ''\)\.trim\(\);/);
|
||||
assert.match(source, /if \(platformServiceRoot && selectedAuthorityRoot === platformServiceRoot && !isPlatformAdmin\) \{\s*return;\s*\}/);
|
||||
});
|
||||
|
||||
it('does not claim platform service registry entries for browser apps before a user Space is selected', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../MatrixDSLHost.ts'), 'utf8');
|
||||
|
||||
assert.match(source, /this\._shouldSkipBrowserRuntimeRegistryClaim\(controlPlaneRoot\)/);
|
||||
assert.match(source, /result: 'skipped-no-registry-authority'/);
|
||||
assert.match(source, /return !candidateAuthorityRoots\.some\(\(root\) => root\.length > 0 && root !== platformRoot\);/);
|
||||
});
|
||||
|
||||
it('does not claim browser runtime registry entries when this Host is not the authority coordinator', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../MatrixDSLHost.ts'), 'utf8');
|
||||
|
||||
assert.match(source, /bootstrapNodeRoleActive\(bootstrap\.nodeRoles, 'authorityCoordinator'\)/);
|
||||
assert.match(source, /this\.dataset\.authorityCoordinatorActive === 'false'/);
|
||||
});
|
||||
});
|
||||
6
packages/browser-host/src/app-ready.ts
Normal file
6
packages/browser-host/src/app-ready.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* App-ready signal — emit and observe Matrix app readiness across
|
||||
* shell, operational, and cognitive levels.
|
||||
*/
|
||||
|
||||
export * from './AppReadySignal.js';
|
||||
112
packages/browser-host/src/boundary-identifiers.md
Normal file
112
packages/browser-host/src/boundary-identifiers.md
Normal file
@ -0,0 +1,112 @@
|
||||
# Boundary Identifiers — In-Package Reference
|
||||
|
||||
If you're editing files in this package, you must know which identifier
|
||||
you're touching. Full spec:
|
||||
`WORKSTREAMS/platform-control-plane-freeze/FOUR-ROOTS-DISENTANGLE.md`
|
||||
(working title preserved for grep continuity).
|
||||
|
||||
## The Four Boundary Identifiers
|
||||
|
||||
| Boundary name | What it identifies | Example | Where it crosses boundaries |
|
||||
|---|---|---|---|
|
||||
| **subjectScope** | NATS subject prefix for this Host's actors | `SPACE-HIVECAST` | `data-subject-scope` attr, `bootstrap.subjectScope` JSON field, `IBrowserBootstrapResponse.subjectScope` |
|
||||
| **authority** | Who owns this surface (user/principal) | `test-user-1` | `data-authority` attr, `bootstrap.authority` JSON field, `IBrowserBootstrapResponse.authority` |
|
||||
| **appName** | Which webapp this is | `director` | `data-app-name` attr, `bootstrap.appName` JSON field, `matrix.json:appName` |
|
||||
| **tabRuntimeId** | This browser tab's runtime identity | `test-user-1.DIRECTOR.SESSION-A1B2C3D4` | `data-tab-runtime-id` attr, computed client-side from `authority` + `appName` + session id |
|
||||
|
||||
## The discipline: clear at the boundary, flexible inside
|
||||
|
||||
The names above are required at **interface boundaries** — anywhere code in
|
||||
this package talks to code outside it. That means:
|
||||
|
||||
- HTTP bootstrap response fields
|
||||
- HTML attributes on `<matrix-dsl-host>`
|
||||
- Exported TypeScript interfaces, types, and function signatures
|
||||
- matrix.json fields
|
||||
|
||||
Inside private functions and private fields of this package, local variable
|
||||
names can use whatever feels natural for the surrounding code. The
|
||||
discipline is at the seams.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Boundary-crossing values use the canonical boundary name.** When you
|
||||
read a value from a config file, HTML attribute, JSON field, or exported
|
||||
interface, you read the canonical name (`subjectScope`, `authority`,
|
||||
`appName`, `tabRuntimeId`). You can rename it as a local variable inside
|
||||
your function if you want; just make sure the boundary read happened
|
||||
under the right name.
|
||||
|
||||
2. **One concept per boundary name.** Never write `setAttribute('root', ...)`
|
||||
in new code. The `root` attribute is ambiguous — it has meant all four
|
||||
identifiers at different times in git history. Pick the specific one.
|
||||
|
||||
3. **No fallbacks.** Per CLAUDE.md "no fallbacks or legacy crutches,"
|
||||
if `appName` is missing from a bootstrap that needs it, **throw**.
|
||||
Do not silently degrade to using `subjectScope` in the `appName` slot —
|
||||
that's the wire-crossing bug this workstream exists to fix.
|
||||
|
||||
4. **Forbidden boundary names** (do not introduce in new code at boundaries):
|
||||
- `root` — meant all four at different times
|
||||
- `realm` — alias of `root`, same problem
|
||||
- `baseRoot` — got passed in as appName but is named after subjectScope
|
||||
- `rootAttr` — generic, ambiguous
|
||||
- `daemonRoot` / `daemonRealm` — pre-Host-Service legacy
|
||||
- `busRoot` / `authorityRoot` / `runtimeRoot` — these were in-flight rename
|
||||
drafts; superseded by `subjectScope` / `authority` / `tabRuntimeId`
|
||||
- `transportRoot` — classify each site; if the value has `.SESSION-XXXX`,
|
||||
the boundary name is `tabRuntimeId`; otherwise it's `subjectScope`
|
||||
- `spaceRoot` — use `authority`
|
||||
- `appRoot` — use `appName` or `tabRuntimeId` depending on which
|
||||
|
||||
5. **Allowed legacy aliases (compat only at boundaries, one release):**
|
||||
- `bootstrap.root` — still emitted for one release; readers must migrate
|
||||
- `bootstrap.platformRoot` — synonym of `subjectScope` during transition
|
||||
- `bootstrap.addressRoot` — synonym of `authority` during transition
|
||||
- `bootstrap.busRoot` / `bootstrap.authorityRoot` / `bootstrap.runtimeRoot` —
|
||||
in-flight rename names from earlier drafts; emit both old and new for
|
||||
one release so any consumer already coded against them keeps working
|
||||
|
||||
## HTML attribute writes are intent-explicit
|
||||
|
||||
When you write attributes during browser bootstrap, write the specific
|
||||
canonical attribute name:
|
||||
|
||||
```ts
|
||||
this.setAttribute('data-subject-scope', subjectScope); // not 'root', not 'realm'
|
||||
this.setAttribute('data-authority', authority);
|
||||
this.setAttribute('data-app-name', appName);
|
||||
this.setAttribute('data-tab-runtime-id', tabRuntimeId);
|
||||
```
|
||||
|
||||
The legacy `root` attribute is still written during the transition release
|
||||
(transport readers in older code path still consume it as `subjectScope` in
|
||||
hosted mode). New code reads `data-subject-scope` instead.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Through git history, "root" has meant all four of these identifiers at
|
||||
different times. `commit d73fc73ee` (May 14, 2026) introduced `busRoot`
|
||||
as a separate bootstrap field when Space-claimed users couldn't load the
|
||||
platform shell. That fix worked for transport, but the HTML attribute
|
||||
`root` is read by THREE consumers expecting THREE different meanings, so
|
||||
writing `busRoot` into it broke tab identity downstream
|
||||
(`tab_realm.ts:generateIdentityTabRoot`, whose `appName` parameter was being
|
||||
fed `busRoot` until this workstream landed).
|
||||
|
||||
The lesson: **at boundaries, names that say what they mean prevent the
|
||||
wire-crossing class of bug.** Internal variable names can stay flexible;
|
||||
the boundary names cannot.
|
||||
|
||||
## Where each identifier comes from
|
||||
|
||||
- **subjectScope** — server-side, derived from `status.transport.root` of
|
||||
whichever Host's gateway answered the HTTP request
|
||||
- **authority** — server-side, the signed-in principal's
|
||||
`primary_authority_root` (or, on Device hosts, the Device's linked
|
||||
authority from `host-auth.json`)
|
||||
- **appName** — server-side, derived from the route's app context
|
||||
(`/apps/director/` → `director`); declared in the package's `matrix.json`
|
||||
- **tabRuntimeId** — client-side, generated as
|
||||
`${authority}.${appName.toUpperCase()}.SESSION-${sessionId}`,
|
||||
persisted in sessionStorage
|
||||
10
packages/browser-host/src/host.ts
Normal file
10
packages/browser-host/src/host.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Browser host element + control actor.
|
||||
*
|
||||
* The MatrixDSLHost custom element bootstraps a browser tab's Matrix
|
||||
* runtime, transport, and root context. Importing this module registers
|
||||
* the `<matrix-dsl-host>` custom element as a side effect.
|
||||
*/
|
||||
|
||||
export * from './MatrixDSLHost.js';
|
||||
export * from './BrowserRuntimeControlActor.js';
|
||||
23
packages/browser-host/src/index.ts
Normal file
23
packages/browser-host/src/index.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @open-matrix/browser-host
|
||||
*
|
||||
* Browser-host shell for Matrix custom elements. Extracted from
|
||||
* @open-matrix/core during the core-decontamination workstream to stop
|
||||
* MatrixDSLHost changes from rebuilding the actor kernel.
|
||||
*
|
||||
* Phase 1 (this commit): package shell only. The actual files still live
|
||||
* in `@open-matrix/core/browser/*` and are re-exported here so consumer
|
||||
* migrations can start in parallel with the move. Phase 1 commit 3 moves
|
||||
* the files and removes the core copies.
|
||||
*
|
||||
* Use subpath imports when possible:
|
||||
* from '@open-matrix/browser-host/host' — host element + control
|
||||
* from '@open-matrix/browser-host/recovery' — recovery + target helpers
|
||||
* from '@open-matrix/browser-host/app-ready' — readiness signal
|
||||
*/
|
||||
|
||||
export * from './BundleFreshness.js';
|
||||
export * from './host.js';
|
||||
export * from './recovery.js';
|
||||
export * from './app-ready.js';
|
||||
export * from './profile-trace.js';
|
||||
5
packages/browser-host/src/profile-trace.ts
Normal file
5
packages/browser-host/src/profile-trace.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Browser profile tracing helpers.
|
||||
*/
|
||||
|
||||
export * from './BrowserProfileTrace.js';
|
||||
6
packages/browser-host/src/recovery.ts
Normal file
6
packages/browser-host/src/recovery.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Hosted-runtime recovery and target-info helpers.
|
||||
*/
|
||||
|
||||
export * from './HostedRuntimeRecovery.js';
|
||||
export * from './HostedRuntimeTarget.js';
|
||||
11
packages/browser-host/tsconfig.build.json
Normal file
11
packages/browser-host/tsconfig.build.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"exclude": ["src/__tests__/**/*.ts"]
|
||||
}
|
||||
15
packages/browser-host/tsconfig.json
Normal file
15
packages/browser-host/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
23
packages/browser-kit/build.mjs
Normal file
23
packages/browser-kit/build.mjs
Normal file
@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { cpSync, rmSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const packageDir = dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const typescriptPackageJson = require.resolve('typescript/package.json', { paths: [packageDir] });
|
||||
const tscBin = resolve(dirname(typescriptPackageJson), 'bin/tsc');
|
||||
|
||||
rmSync(resolve(packageDir, 'dist'), { recursive: true, force: true });
|
||||
execFileSync(process.execPath, [tscBin, '-p', resolve(packageDir, 'tsconfig.json')], {
|
||||
cwd: packageDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
execFileSync(process.execPath, [resolve(packageDir, 'fix-esm-imports.mjs')], {
|
||||
cwd: packageDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
cpSync(resolve(packageDir, 'src/theme/base.css'), resolve(packageDir, 'dist/theme/base.css'));
|
||||
cpSync(resolve(packageDir, 'src/theme/tokens.css'), resolve(packageDir, 'dist/theme/tokens.css'));
|
||||
59
packages/browser-kit/fix-esm-imports.mjs
Normal file
59
packages/browser-kit/fix-esm-imports.mjs
Normal file
@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, extname, join, resolve } from 'node:path';
|
||||
|
||||
const distDir = new URL('./dist/', import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1');
|
||||
let fixed = 0;
|
||||
let files = 0;
|
||||
|
||||
function processFile(filePath) {
|
||||
if (!filePath.endsWith('.js') && !filePath.endsWith('.d.ts')) return;
|
||||
files += 1;
|
||||
|
||||
const fileDir = dirname(filePath);
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
let changed = false;
|
||||
|
||||
const addExt = (match, prefix, specifier, suffix) => {
|
||||
const ext = extname(specifier);
|
||||
if (['.js', '.mjs', '.cjs', '.json', '.css'].includes(ext)) {
|
||||
return match;
|
||||
}
|
||||
const resolved = resolve(fileDir, specifier);
|
||||
const fileCandidates = [`${resolved}.js`, `${resolved}.mjs`, `${resolved}.cjs`, `${resolved}.d.ts`];
|
||||
if (fileCandidates.some((candidate) => existsSync(candidate))) {
|
||||
changed = true;
|
||||
return `${prefix}${specifier}.js${suffix}`;
|
||||
}
|
||||
if (existsSync(resolved) && statSync(resolved).isDirectory()) {
|
||||
changed = true;
|
||||
return `${prefix}${specifier}/index.js${suffix}`;
|
||||
}
|
||||
changed = true;
|
||||
return `${prefix}${specifier}.js${suffix}`;
|
||||
};
|
||||
|
||||
const updated = content
|
||||
.replace(/(from\s+['"])(\.\.?\/[^'"]+)(['"])/g, addExt)
|
||||
.replace(/(import\(['"])(\.\.?\/[^'"]+)(['"]\))/g, addExt)
|
||||
.replace(/(import\s+['"])(\.\.?\/[^'"]+)(['"])/g, addExt);
|
||||
|
||||
if (changed) {
|
||||
writeFileSync(filePath, updated);
|
||||
fixed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
walk(full);
|
||||
} else {
|
||||
processFile(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(distDir);
|
||||
console.log(`Fixed ${fixed}/${files} files with .js extensions`);
|
||||
64
packages/browser-kit/package.json
Normal file
64
packages/browser-kit/package.json
Normal file
@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@open-matrix/browser-kit",
|
||||
"version": "0.1.0",
|
||||
"description": "Shared browser infrastructure for Matrix webapps — shims, theme tokens, vite config factory, app shell",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./shims/*": {
|
||||
"types": "./dist/shims/*.d.ts",
|
||||
"import": "./dist/shims/*.js",
|
||||
"default": "./dist/shims/*.js"
|
||||
},
|
||||
"./theme": {
|
||||
"types": "./dist/theme/index.d.ts",
|
||||
"import": "./dist/theme/index.js",
|
||||
"default": "./dist/theme/index.js"
|
||||
},
|
||||
"./theme/base.css": "./dist/theme/base.css",
|
||||
"./theme/tokens.css": "./dist/theme/tokens.css",
|
||||
"./vite": {
|
||||
"types": "./dist/vite/index.d.ts",
|
||||
"import": "./dist/vite/index.js",
|
||||
"default": "./dist/vite/index.js"
|
||||
},
|
||||
"./components/MxAppShell": {
|
||||
"types": "./dist/components/MxAppShell.d.ts",
|
||||
"import": "./dist/components/MxAppShell.js",
|
||||
"default": "./dist/components/MxAppShell.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"test": "node --input-type=module -e \"const kit = await import('./dist/vite/index.js'); if (typeof kit.createMatrixViteConfig !== 'function') throw new Error('createMatrixViteConfig export missing');\"",
|
||||
"type-check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@open-matrix/core": "*",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@open-matrix/core": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@open-matrix/core": "workspace:*",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
}
|
||||
153
packages/browser-kit/src/components/MxAppShell.ts
Normal file
153
packages/browser-kit/src/components/MxAppShell.ts
Normal file
@ -0,0 +1,153 @@
|
||||
/**
|
||||
* MxAppShell — shared page layout with header and status bar.
|
||||
*
|
||||
* Provides consistent chrome for all Matrix webapps:
|
||||
* - Top header with app title, icon, and NATS connection indicator
|
||||
* - Status bar at bottom with daemon root, user identity, connection state
|
||||
* - Content slot for the app's root component
|
||||
*
|
||||
* Usage in index.html:
|
||||
* <mx-app-shell title="Harness" icon="science">
|
||||
* <mx-harness-app slot="content" name="harness"></mx-harness-app>
|
||||
* </mx-app-shell>
|
||||
*
|
||||
* Or wrap programmatically — the shell injects theme tokens automatically.
|
||||
*/
|
||||
|
||||
export class MxAppShell extends HTMLElement {
|
||||
private _connected = false;
|
||||
private _daemonRoot = '';
|
||||
private _principalId = '';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this._connected = true;
|
||||
this._render();
|
||||
this._watchConnection();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this._connected = false;
|
||||
}
|
||||
|
||||
private _render(): void {
|
||||
if (!this.shadowRoot) return;
|
||||
const title = this.getAttribute('title') || 'Matrix';
|
||||
const icon = this.getAttribute('icon') || '';
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
font-family: var(--mx-font-family, system-ui, -apple-system, sans-serif);
|
||||
color: var(--mx-color-text, #f1f5f9);
|
||||
background: var(--mx-color-surface, #0f172a);
|
||||
}
|
||||
|
||||
.shell-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shell-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
background: var(--mx-color-surface-inset, #020617);
|
||||
border-top: 1px solid var(--mx-color-border-light, #1e293b);
|
||||
font-size: var(--mx-font-size-xs, 10px);
|
||||
color: var(--mx-color-text-muted, #475569);
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--mx-color-success, #22c55e);
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: var(--mx-color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.status-spacer { flex: 1; }
|
||||
|
||||
.status-label { color: var(--mx-color-text-secondary, #94a3b8); }
|
||||
</style>
|
||||
|
||||
<div class="shell-content">
|
||||
<slot name="content"></slot>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div class="shell-status-bar">
|
||||
<div class="status-item">
|
||||
<span class="status-dot" id="conn-dot"></span>
|
||||
<span id="conn-label">Connecting...</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Root:</span>
|
||||
<span id="daemon-root">—</span>
|
||||
</div>
|
||||
<span class="status-spacer"></span>
|
||||
<div class="status-item">
|
||||
<span class="status-label">User:</span>
|
||||
<span id="principal-id">—</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">${title}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _watchConnection(): void {
|
||||
// Poll for DSL host connection state
|
||||
const interval = setInterval(() => {
|
||||
if (!this._connected) { clearInterval(interval); return; }
|
||||
|
||||
const dslHost = this.closest('matrix-dsl-host') ?? document.querySelector('matrix-dsl-host');
|
||||
const daemonRoot = dslHost?.getAttribute('daemon-root') ?? '';
|
||||
const dot = this.shadowRoot?.querySelector('#conn-dot') as HTMLElement;
|
||||
const label = this.shadowRoot?.querySelector('#conn-label') as HTMLElement;
|
||||
const rootEl = this.shadowRoot?.querySelector('#daemon-root') as HTMLElement;
|
||||
|
||||
if (daemonRoot && daemonRoot.trim().length > 0) {
|
||||
this._daemonRoot = daemonRoot;
|
||||
if (dot) dot.classList.remove('disconnected');
|
||||
if (label) label.textContent = 'Connected';
|
||||
if (rootEl) rootEl.textContent = daemonRoot;
|
||||
} else {
|
||||
if (dot) dot.classList.add('disconnected');
|
||||
if (label) label.textContent = 'Connecting...';
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
updatePrincipal(principalId: string): void {
|
||||
this._principalId = principalId;
|
||||
const el = this.shadowRoot?.querySelector('#principal-id') as HTMLElement;
|
||||
if (el) el.textContent = principalId;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof customElements !== 'undefined' && !customElements.get('mx-app-shell')) {
|
||||
customElements.define('mx-app-shell', MxAppShell);
|
||||
}
|
||||
1
packages/browser-kit/src/css.d.ts
vendored
Normal file
1
packages/browser-kit/src/css.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module '*.css';
|
||||
13
packages/browser-kit/src/index.ts
Normal file
13
packages/browser-kit/src/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @open-matrix/browser-kit — shared browser infrastructure for Matrix webapps.
|
||||
*
|
||||
* Provides:
|
||||
* - registerMatrixElements() — centralized custom element registration
|
||||
* - MxAppShell — shared page layout with header + status bar
|
||||
* - Theme tokens — import '@open-matrix/browser-kit/theme'
|
||||
* - Vite config factory — import { createMatrixViteConfig } from '@open-matrix/browser-kit/vite'
|
||||
* - Browser shims — import '@open-matrix/browser-kit/shims/crypto' etc.
|
||||
*/
|
||||
|
||||
export { registerMatrixElements } from './register';
|
||||
export { MxAppShell } from './components/MxAppShell';
|
||||
24
packages/browser-kit/src/register.ts
Normal file
24
packages/browser-kit/src/register.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Centralized custom element registration for Matrix webapps.
|
||||
*
|
||||
* Usage:
|
||||
* import { registerMatrixElements } from '@open-matrix/browser-kit';
|
||||
* import { MxSplit } from '@open-matrix/core';
|
||||
* import { MyApp } from './MyApp';
|
||||
*
|
||||
* registerMatrixElements([
|
||||
* ['my-app', MyApp],
|
||||
* ['mx-split', MxSplit],
|
||||
* ]);
|
||||
*/
|
||||
export function registerMatrixElements(
|
||||
elements: Array<[string, CustomElementConstructor]>,
|
||||
): void {
|
||||
if (typeof customElements === 'undefined') return;
|
||||
|
||||
for (const [tag, cls] of elements) {
|
||||
if (!customElements.get(tag)) {
|
||||
customElements.define(tag, cls);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
packages/browser-kit/src/shims/child_process.ts
Normal file
31
packages/browser-kit/src/shims/child_process.ts
Normal file
@ -0,0 +1,31 @@
|
||||
function unsupported(name: string): never {
|
||||
throw new Error(`[director/browser-shim] ${name} is not available in the browser`);
|
||||
}
|
||||
|
||||
export function spawn(): never {
|
||||
return unsupported('child_process.spawn');
|
||||
}
|
||||
|
||||
export function spawnSync(): never {
|
||||
return unsupported('child_process.spawnSync');
|
||||
}
|
||||
|
||||
export function execSync(): never {
|
||||
return unsupported('child_process.execSync');
|
||||
}
|
||||
|
||||
export function execFileSync(): never {
|
||||
return unsupported('child_process.execFileSync');
|
||||
}
|
||||
|
||||
export function execFile(): never {
|
||||
return unsupported('child_process.execFile');
|
||||
}
|
||||
|
||||
export default {
|
||||
spawn,
|
||||
spawnSync,
|
||||
execSync,
|
||||
execFileSync,
|
||||
execFile,
|
||||
};
|
||||
92
packages/browser-kit/src/shims/crypto.ts
Normal file
92
packages/browser-kit/src/shims/crypto.ts
Normal file
@ -0,0 +1,92 @@
|
||||
type HashLike = {
|
||||
update(input: string | Uint8Array, encoding?: string): HashLike;
|
||||
digest(encoding?: 'hex'): string | Uint8Array;
|
||||
};
|
||||
|
||||
function toBytes(input: string | Uint8Array, encoding?: string): Uint8Array {
|
||||
if (input instanceof Uint8Array) return input;
|
||||
if (encoding && encoding !== 'utf8' && encoding !== 'utf-8') {
|
||||
throw new Error(`[director/browser-shim] Unsupported hash encoding: ${encoding}`);
|
||||
}
|
||||
return new TextEncoder().encode(input);
|
||||
}
|
||||
|
||||
function simpleHash(bytes: Uint8Array): string {
|
||||
let h1 = 0x811c9dc5;
|
||||
let h2 = 0x01000193;
|
||||
for (const byte of bytes) {
|
||||
h1 ^= byte;
|
||||
h1 = Math.imul(h1, 0x01000193);
|
||||
h2 ^= byte;
|
||||
h2 = Math.imul(h2, 0x27d4eb2d);
|
||||
}
|
||||
const a = (h1 >>> 0).toString(16).padStart(8, '0');
|
||||
const b = (h2 >>> 0).toString(16).padStart(8, '0');
|
||||
return `${a}${b}${a}${b}${a}${b}${a}${b}`;
|
||||
}
|
||||
|
||||
export function createHash(_algorithm = 'sha256'): HashLike {
|
||||
const chunks: Uint8Array[] = [];
|
||||
return {
|
||||
update(input: string | Uint8Array, encoding?: string): HashLike {
|
||||
chunks.push(toBytes(input, encoding));
|
||||
return this;
|
||||
},
|
||||
digest(encoding?: 'hex'): string | Uint8Array {
|
||||
const size = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const merged = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
const hex = simpleHash(merged);
|
||||
if (!encoding || encoding === 'hex') return hex;
|
||||
return new TextEncoder().encode(hex);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function randomBytes(size: number): Uint8Array {
|
||||
const out = new Uint8Array(size);
|
||||
const nativeCrypto = globalThis.crypto;
|
||||
if (nativeCrypto?.getRandomValues) {
|
||||
nativeCrypto.getRandomValues(out);
|
||||
return out;
|
||||
}
|
||||
for (let i = 0; i < out.length; i += 1) {
|
||||
out[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function randomUUID(): string {
|
||||
const nativeCrypto = globalThis.crypto;
|
||||
if (nativeCrypto?.randomUUID) return nativeCrypto.randomUUID();
|
||||
const bytes = randomBytes(16);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));
|
||||
return [
|
||||
hex.slice(0, 4).join(''),
|
||||
hex.slice(4, 6).join(''),
|
||||
hex.slice(6, 8).join(''),
|
||||
hex.slice(8, 10).join(''),
|
||||
hex.slice(10, 16).join(''),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
const shimCrypto = {
|
||||
createHash,
|
||||
randomBytes,
|
||||
randomUUID,
|
||||
getRandomValues<T extends ArrayBufferView | null>(array: T): T {
|
||||
if (array && 'byteLength' in array) {
|
||||
const view = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
|
||||
view.set(randomBytes(view.length));
|
||||
}
|
||||
return array;
|
||||
},
|
||||
};
|
||||
|
||||
export default shimCrypto;
|
||||
1
packages/browser-kit/src/shims/fs-promises.ts
Normal file
1
packages/browser-kit/src/shims/fs-promises.ts
Normal file
@ -0,0 +1 @@
|
||||
export { readFile, writeFile, mkdir, default } from './fs';
|
||||
21
packages/browser-kit/src/shims/fs.ts
Normal file
21
packages/browser-kit/src/shims/fs.ts
Normal file
@ -0,0 +1,21 @@
|
||||
function unsupported(name: string): never {
|
||||
throw new Error(`[director/browser-shim] ${name} is not available in the browser`);
|
||||
}
|
||||
|
||||
export async function readFile(): Promise<string> {
|
||||
return unsupported('fs.readFile');
|
||||
}
|
||||
|
||||
export async function writeFile(): Promise<void> {
|
||||
return unsupported('fs.writeFile');
|
||||
}
|
||||
|
||||
export async function mkdir(): Promise<void> {
|
||||
return unsupported('fs.mkdir');
|
||||
}
|
||||
|
||||
export default {
|
||||
readFile,
|
||||
writeFile,
|
||||
mkdir,
|
||||
};
|
||||
3
packages/browser-kit/src/shims/open-matrix-core.ts
Normal file
3
packages/browser-kit/src/shims/open-matrix-core.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { MatrixActorHtmlElement } from '@open-matrix/core/framework/MatrixActorHtmlElement';
|
||||
export { RequestReply } from '@open-matrix/core/engine/remoting/RequestReply';
|
||||
export { MxSplit } from '@open-matrix/core/framework/components/MxSplit';
|
||||
26
packages/browser-kit/src/shims/path.ts
Normal file
26
packages/browser-kit/src/shims/path.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export function join(...parts: string[]): string {
|
||||
return parts.filter(Boolean).join('/').replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
export function resolve(...parts: string[]): string {
|
||||
return join(...parts);
|
||||
}
|
||||
|
||||
export function dirname(path: string): string {
|
||||
const normalized = path.replace(/\/+/g, '/');
|
||||
const index = normalized.lastIndexOf('/');
|
||||
return index <= 0 ? '.' : normalized.slice(0, index);
|
||||
}
|
||||
|
||||
export function basename(path: string): string {
|
||||
const normalized = path.replace(/\/+/g, '/');
|
||||
const index = normalized.lastIndexOf('/');
|
||||
return index >= 0 ? normalized.slice(index + 1) : normalized;
|
||||
}
|
||||
|
||||
export default {
|
||||
join,
|
||||
resolve,
|
||||
dirname,
|
||||
basename,
|
||||
};
|
||||
45
packages/browser-kit/src/theme/base.css
Normal file
45
packages/browser-kit/src/theme/base.css
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Matrix Base Styles — applied globally by <mx-app-shell> or imported directly.
|
||||
* Provides reset, scrollbar styling, and utility classes.
|
||||
*/
|
||||
|
||||
/* ── Reset ────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: var(--mx-font-family, system-ui, -apple-system, sans-serif);
|
||||
font-size: var(--mx-font-size-base, 13px);
|
||||
color: var(--mx-color-text, #f1f5f9);
|
||||
background: var(--mx-color-surface, #0f172a);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ────────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar {
|
||||
width: var(--mx-scrollbar-width, 6px);
|
||||
height: var(--mx-scrollbar-width, 6px);
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--mx-scrollbar-track, #020617);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--mx-scrollbar-thumb, #334155);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--mx-scrollbar-thumb-hover, #1e293b);
|
||||
}
|
||||
|
||||
/* ── Links ────────────────────────────────────────────────────────── */
|
||||
a {
|
||||
color: var(--mx-color-link, #82aaff);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
8
packages/browser-kit/src/theme/index.ts
Normal file
8
packages/browser-kit/src/theme/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Matrix Theme — import to inject CSS tokens and base styles.
|
||||
*
|
||||
* Usage in your app's index.ts:
|
||||
* import '@open-matrix/browser-kit/theme';
|
||||
*/
|
||||
import './tokens.css';
|
||||
import './base.css';
|
||||
68
packages/browser-kit/src/theme/tokens.css
Normal file
68
packages/browser-kit/src/theme/tokens.css
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Matrix Design Tokens — unified CSS custom properties for all Matrix webapps.
|
||||
*
|
||||
* Based on Chat's --mx-color-* system, extended with layout and typography tokens.
|
||||
* Import this in your app root or let <mx-app-shell> inject it automatically.
|
||||
*
|
||||
* Usage in components:
|
||||
* background: var(--mx-color-surface);
|
||||
* color: var(--mx-color-text);
|
||||
* border: 1px solid var(--mx-color-border);
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ── Surface colors ────────────────────────────────────────────────── */
|
||||
--mx-color-surface: #0f172a;
|
||||
--mx-color-surface-alt: #1e293b;
|
||||
--mx-color-surface-inset: #020617;
|
||||
--mx-color-surface-hover: #334155;
|
||||
--mx-color-surface-card: #16213e;
|
||||
|
||||
/* ── Text colors ───────────────────────────────────────────────────── */
|
||||
--mx-color-text: #f1f5f9;
|
||||
--mx-color-text-secondary: #94a3b8;
|
||||
--mx-color-text-muted: #475569;
|
||||
--mx-color-link: #82aaff;
|
||||
|
||||
/* ── Semantic colors ───────────────────────────────────────────────── */
|
||||
--mx-color-primary: #3b82f6;
|
||||
--mx-color-primary-hover: #60a5fa;
|
||||
--mx-color-accent: #e94560;
|
||||
--mx-color-success: #22c55e;
|
||||
--mx-color-warning: #f59e0b;
|
||||
--mx-color-danger: #ef4444;
|
||||
--mx-color-error: #ef4444;
|
||||
|
||||
/* ── Border colors ─────────────────────────────────────────────────── */
|
||||
--mx-color-border: #334155;
|
||||
--mx-color-border-light: #1e293b;
|
||||
--mx-color-focus-ring: #3b82f6;
|
||||
|
||||
/* ── Typography ────────────────────────────────────────────────────── */
|
||||
--mx-font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--mx-font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
--mx-font-size-xs: 10px;
|
||||
--mx-font-size-sm: 11px;
|
||||
--mx-font-size-base: 13px;
|
||||
--mx-font-size-md: 14px;
|
||||
--mx-font-size-lg: 18px;
|
||||
|
||||
/* ── Radius ────────────────────────────────────────────────────────── */
|
||||
--mx-radius-sm: 3px;
|
||||
--mx-radius-md: 6px;
|
||||
--mx-radius-lg: 8px;
|
||||
--mx-radius-full: 9999px;
|
||||
|
||||
/* ── Spacing ───────────────────────────────────────────────────────── */
|
||||
--mx-space-xs: 4px;
|
||||
--mx-space-sm: 8px;
|
||||
--mx-space-md: 12px;
|
||||
--mx-space-lg: 16px;
|
||||
--mx-space-xl: 20px;
|
||||
|
||||
/* ── Scrollbar ─────────────────────────────────────────────────────── */
|
||||
--mx-scrollbar-width: 6px;
|
||||
--mx-scrollbar-track: var(--mx-color-surface-inset);
|
||||
--mx-scrollbar-thumb: var(--mx-color-border);
|
||||
--mx-scrollbar-thumb-hover: var(--mx-color-border-light);
|
||||
}
|
||||
81
packages/browser-kit/src/vite/index.ts
Normal file
81
packages/browser-kit/src/vite/index.ts
Normal file
@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Matrix Vite Config Factory — generates a correct vite.config.ts for any Matrix webapp.
|
||||
*
|
||||
* Usage in your app's vite.config.ts:
|
||||
* import { createMatrixViteConfig } from '@open-matrix/browser-kit/vite';
|
||||
* export default createMatrixViteConfig({ appName: 'harness' });
|
||||
*/
|
||||
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import { resolve, dirname } from 'path';
|
||||
|
||||
export interface MatrixViteConfigOptions {
|
||||
appName: string;
|
||||
root?: string;
|
||||
outDir?: string;
|
||||
daemonPort?: number;
|
||||
extraAliases?: Record<string, string>;
|
||||
extraExternals?: string[];
|
||||
}
|
||||
|
||||
export function createMatrixViteConfig(options: MatrixViteConfigOptions, callerDir: string) {
|
||||
const {
|
||||
appName,
|
||||
root = 'src',
|
||||
outDir = 'dist',
|
||||
daemonPort = 3100,
|
||||
} = options;
|
||||
|
||||
const browserKitShims = resolve(dirname(new URL(import.meta.url).pathname), '../shims');
|
||||
|
||||
return defineConfig(({ command, mode }) => {
|
||||
const env = loadEnv(mode, callerDir, '');
|
||||
const daemonOrigin = (env.MATRIX_DAEMON_ORIGIN || `http://127.0.0.1:${daemonPort}`).replace(/\/$/, '');
|
||||
|
||||
return {
|
||||
root: resolve(callerDir, root),
|
||||
publicDir: false,
|
||||
base: `/apps/${appName}/`,
|
||||
build: {
|
||||
outDir: resolve(callerDir, outDir),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: resolve(callerDir, root, 'index.html'),
|
||||
},
|
||||
target: 'es2022',
|
||||
chunkSizeWarningLimit: 800,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@open-matrix/core': resolve(browserKitShims, 'open-matrix-core.ts'),
|
||||
crypto: resolve(browserKitShims, 'crypto.ts'),
|
||||
'node:crypto': resolve(browserKitShims, 'crypto.ts'),
|
||||
child_process: resolve(browserKitShims, 'child_process.ts'),
|
||||
'node:child_process': resolve(browserKitShims, 'child_process.ts'),
|
||||
'fs/promises': resolve(browserKitShims, 'fs-promises.ts'),
|
||||
'node:fs/promises': resolve(browserKitShims, 'fs-promises.ts'),
|
||||
fs: resolve(browserKitShims, 'fs.ts'),
|
||||
'node:fs': resolve(browserKitShims, 'fs.ts'),
|
||||
path: resolve(browserKitShims, 'path.ts'),
|
||||
'node:path': resolve(browserKitShims, 'path.ts'),
|
||||
...options.extraAliases,
|
||||
},
|
||||
},
|
||||
...(command === 'serve'
|
||||
? {
|
||||
server: {
|
||||
proxy: {
|
||||
'/.matrix': { target: daemonOrigin, changeOrigin: true },
|
||||
'/api': { target: daemonOrigin, changeOrigin: true },
|
||||
'/auth': { target: daemonOrigin, changeOrigin: true },
|
||||
'/login': { target: daemonOrigin, changeOrigin: true },
|
||||
'/logout': { target: daemonOrigin, changeOrigin: true },
|
||||
'/healthz': { target: daemonOrigin, changeOrigin: true },
|
||||
'/nats-ws': { target: daemonOrigin, changeOrigin: true, ws: true },
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
19
packages/browser-kit/tsconfig.json
Normal file
19
packages/browser-kit/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"noEmit": false,
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
22
packages/cli/CONTRACT.md
Normal file
22
packages/cli/CONTRACT.md
Normal file
@ -0,0 +1,22 @@
|
||||
# @open-matrix/cli Contract
|
||||
|
||||
`@open-matrix/cli` is scoped to package-author development.
|
||||
|
||||
It may depend on:
|
||||
|
||||
- `@open-matrix/core`
|
||||
- other packages in the SDK package set, when needed
|
||||
- Node standard library modules
|
||||
|
||||
It must not depend on:
|
||||
|
||||
- `@matrix/mx-cli`
|
||||
- `@open-matrix/host-service`
|
||||
- `@open-matrix/host-control`
|
||||
- `@open-matrix/system-*`
|
||||
- `@open-matrix/system-auth`
|
||||
- `@open-matrix/system-gateway-http`
|
||||
- `@open-matrix/nats-auth`
|
||||
- `hivecast`
|
||||
|
||||
User packages must depend on SDK libraries, not on this CLI package.
|
||||
23
packages/cli/README.md
Normal file
23
packages/cli/README.md
Normal file
@ -0,0 +1,23 @@
|
||||
# @open-matrix/cli
|
||||
|
||||
Package-author CLI for the Matrix SDK layer.
|
||||
|
||||
This package owns the SDK/package-author `matrix` binary. It does not depend on
|
||||
Matrix-3 product packages, Host Service, Host Control, system actors, HiveCast,
|
||||
Device Link, or NATS authority packages.
|
||||
|
||||
Initial command surface:
|
||||
|
||||
```bash
|
||||
matrix init [dir]
|
||||
matrix add actor <name>
|
||||
matrix actor <name>
|
||||
matrix configure --key <key> --cloud <url> --space <space>
|
||||
matrix run <entry> --mount <mount>
|
||||
matrix invoke <mount> <op> [json]
|
||||
```
|
||||
|
||||
`matrix run` starts a standalone actor runner in the current folder. `matrix
|
||||
invoke` sends a Matrix request envelope to that runner through its local control
|
||||
port. Managed Runtime Host promotion belongs to the later Matrix-3/Runtime Host
|
||||
repoint phase.
|
||||
40
packages/cli/package.json
Normal file
40
packages/cli/package.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@open-matrix/cli",
|
||||
"version": "0.1.0",
|
||||
"description": "Package-author Matrix CLI for SDK projects.",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"bin": {
|
||||
"matrix": "./dist/bin/matrix.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/cli.d.ts",
|
||||
"import": "./dist/cli.js",
|
||||
"default": "./dist/cli.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json",
|
||||
"README.md",
|
||||
"CONTRACT.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "node --test dist/__tests__/*.test.js",
|
||||
"type-check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-matrix/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
105
packages/cli/src/__tests__/cli.test.ts
Normal file
105
packages/cli/src/__tests__/cli.test.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { main } from '../cli.js';
|
||||
|
||||
test('init, add actor, and configure write package-local SDK state', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'matrix-sdk-cli-test.'));
|
||||
|
||||
await main(['init', root, '--name', 'demo-package']);
|
||||
await main(['add', 'actor', 'greeter', '--dir', root, '--mount', 'demo.greeter']);
|
||||
await main([
|
||||
'configure',
|
||||
'--dir', root,
|
||||
'--key', 'test-key',
|
||||
'--cloud', 'https://example.invalid',
|
||||
'--space', 'demo-space',
|
||||
]);
|
||||
|
||||
const manifest = JSON.parse(await readFile(join(root, 'matrix.json'), 'utf8'));
|
||||
const packageJson = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
||||
assert.equal(packageJson.private, undefined);
|
||||
assert.equal(packageJson.dependencies['@open-matrix/core'], '^0.1.0');
|
||||
assert.equal(manifest.name, 'demo-package');
|
||||
assert.equal(manifest.runtime.entry, './dist/index.js');
|
||||
assert.deepEqual(manifest.runtime.factory, {
|
||||
kind: 'factory',
|
||||
export: 'createStandaloneGreeterActor',
|
||||
instance: {
|
||||
id: 'RUNTIME-HOST-DEMO-GREETER',
|
||||
class: 'service',
|
||||
rootKind: 'component',
|
||||
componentType: 'GreeterActor',
|
||||
autoStart: true,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(manifest.components, [{
|
||||
type: 'GreeterActor',
|
||||
export: 'GreeterActor',
|
||||
mount: 'demo.greeter',
|
||||
surface: 'headless',
|
||||
accepts: ['echo', 'getStatus'],
|
||||
}]);
|
||||
|
||||
const actorSource = await readFile(join(root, 'src', 'GreeterActor.ts'), 'utf8');
|
||||
assert.match(actorSource, /from '@open-matrix\/core'/);
|
||||
const factorySource = await readFile(join(root, 'src', 'create-standalone-GreeterActor.ts'), 'utf8');
|
||||
assert.match(factorySource, /createStandaloneGreeterActor/);
|
||||
assert.match(factorySource, /runtime\.createSupervised\(GreeterActor, mount\)/);
|
||||
const credential = JSON.parse(await readFile(join(root, '.matrix', 'credentials', 'matrix-api-key.json'), 'utf8'));
|
||||
assert.equal(credential.key, 'test-key');
|
||||
assert.equal(credential.cloud, 'https://example.invalid');
|
||||
assert.equal(credential.space, 'demo-space');
|
||||
const credentialMode = (await stat(join(root, '.matrix', 'credentials', 'matrix-api-key.json'))).mode & 0o777;
|
||||
assert.equal(credentialMode, 0o600);
|
||||
});
|
||||
|
||||
test('up delegates a Host-runnable package to an explicit Runtime Host command', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'matrix-sdk-cli-up-test.'));
|
||||
const fakeRuntimeHost = join(root, 'fake-runtime-host.mjs');
|
||||
const calledPath = join(root, 'runtime-host-called.json');
|
||||
|
||||
await main(['init', root, '--name', 'demo-package']);
|
||||
await main(['add', 'actor', 'greeter', '--dir', root, '--mount', 'demo.greeter']);
|
||||
await mkdir(join(root, 'dist'), { recursive: true });
|
||||
await writeFile(join(root, 'dist', 'index.js'), 'export function createStandaloneGreeterActor() {}\n', 'utf8');
|
||||
await writeFile(fakeRuntimeHost, `
|
||||
import { writeFileSync } from 'node:fs';
|
||||
writeFileSync(${JSON.stringify(calledPath)}, JSON.stringify({
|
||||
argv: process.argv.slice(2),
|
||||
cwd: process.cwd(),
|
||||
}, null, 2));
|
||||
`, 'utf8');
|
||||
|
||||
await main([
|
||||
'configure',
|
||||
'--dir', root,
|
||||
'--key', 'test-key',
|
||||
'--cloud', 'https://example.invalid',
|
||||
'--space', 'demo-space',
|
||||
'--runtime-host-bin', fakeRuntimeHost,
|
||||
'--runtime-host-routing', 'workspace-source',
|
||||
]);
|
||||
await main(['up', '--dir', root, '--json']);
|
||||
|
||||
const call = JSON.parse(await readFile(calledPath, 'utf8'));
|
||||
assert.equal(call.cwd, root);
|
||||
assert.deepEqual(call.argv.slice(0, 3), ['--workspace-source', 'up', root]);
|
||||
assert.ok(call.argv.includes('--json'));
|
||||
});
|
||||
|
||||
test('add actor preserves explicit Actor class suffix', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'matrix-sdk-cli-actor-suffix-test.'));
|
||||
|
||||
await main(['init', root, '--name', 'demo-package']);
|
||||
await main(['add', 'actor', 'GreeterActor', '--dir', root, '--mount', 'demo.greeter']);
|
||||
|
||||
const manifest = JSON.parse(await readFile(join(root, 'matrix.json'), 'utf8'));
|
||||
assert.equal(manifest.runtime.factory.export, 'createStandaloneGreeterActor');
|
||||
assert.equal(manifest.runtime.factory.instance.componentType, 'GreeterActor');
|
||||
assert.equal(manifest.components[0].type, 'GreeterActor');
|
||||
await stat(join(root, 'src', 'GreeterActor.ts'));
|
||||
await stat(join(root, 'src', 'create-standalone-GreeterActor.ts'));
|
||||
});
|
||||
8
packages/cli/src/bin/matrix.ts
Normal file
8
packages/cli/src/bin/matrix.ts
Normal file
@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
import { main } from '../cli.js';
|
||||
|
||||
main(process.argv.slice(2)).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
796
packages/cli/src/cli.ts
Normal file
796
packages/cli/src/cli.ts
Normal file
@ -0,0 +1,796 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync, statSync } from 'node:fs';
|
||||
import { basename, dirname, resolve, join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
InMemoryBroker,
|
||||
InMemoryTransport,
|
||||
MatrixActor,
|
||||
MatrixRuntime,
|
||||
TopicRouter,
|
||||
type Unsub,
|
||||
} from '@open-matrix/core';
|
||||
|
||||
type CommandOptions = Record<string, string | boolean>;
|
||||
type ActorConstructor = new () => MatrixActor;
|
||||
type PackageBootstrapFunction = (...args: readonly unknown[]) => Promise<unknown> | unknown;
|
||||
|
||||
interface ParsedArgs {
|
||||
readonly positional: string[];
|
||||
readonly options: CommandOptions;
|
||||
}
|
||||
|
||||
interface RunStatus {
|
||||
readonly pid: number;
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
readonly mount: string;
|
||||
readonly entry: string;
|
||||
readonly exportName: string;
|
||||
readonly startedAt: string;
|
||||
}
|
||||
|
||||
interface PackageRunSpec {
|
||||
readonly packageDir: string;
|
||||
readonly entryPath: string;
|
||||
readonly exportName: string;
|
||||
readonly mount: string;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): ParsedArgs {
|
||||
const positional: string[] = [];
|
||||
const options: CommandOptions = {};
|
||||
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (!arg.startsWith('--')) {
|
||||
positional.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const withoutPrefix = arg.slice(2);
|
||||
const [rawKey, inlineValue] = withoutPrefix.split(/=(.*)/s, 2);
|
||||
const key = rawKey.trim();
|
||||
if (!key) throw new Error(`Invalid option: ${arg}`);
|
||||
if (inlineValue !== undefined) {
|
||||
options[key] = inlineValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith('--')) {
|
||||
options[key] = next;
|
||||
i += 1;
|
||||
} else {
|
||||
options[key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { positional, options };
|
||||
}
|
||||
|
||||
function stringOption(options: CommandOptions, key: string): string | undefined {
|
||||
const value = options[key];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function numberOption(options: CommandOptions, key: string, defaultValue: number): number {
|
||||
const raw = stringOption(options, key);
|
||||
if (!raw) return defaultValue;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`--${key} must be a positive integer`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function workspaceDir(options: CommandOptions): string {
|
||||
return resolve(stringOption(options, 'dir') ?? process.cwd());
|
||||
}
|
||||
|
||||
function matrixDir(root: string): string {
|
||||
return join(root, '.matrix');
|
||||
}
|
||||
|
||||
function ensureDir(path: string): void {
|
||||
mkdirSync(path, { recursive: true });
|
||||
}
|
||||
|
||||
function readJsonFile(path: string): unknown {
|
||||
return JSON.parse(readFileSync(path, 'utf8'));
|
||||
}
|
||||
|
||||
function writeJsonFile(path: string, value: unknown): void {
|
||||
ensureDir(dirname(path));
|
||||
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function normalizePackageName(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'matrix-package';
|
||||
}
|
||||
|
||||
function toPascalCase(input: string): string {
|
||||
const parts = input.trim().split(/[^a-zA-Z0-9]+/).filter(Boolean);
|
||||
const name = parts.map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`).join('');
|
||||
return name || 'Demo';
|
||||
}
|
||||
|
||||
function toActorClassName(input: string): string {
|
||||
const className = toPascalCase(input);
|
||||
return className.endsWith('Actor') ? className : `${className}Actor`;
|
||||
}
|
||||
|
||||
function writeIfMissing(path: string, contents: string): void {
|
||||
if (!existsSync(path)) writeFileSync(path, contents, 'utf8');
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
async function initCommand(options: CommandOptions, positional: string[]): Promise<void> {
|
||||
const root = resolve(positional[0] ?? stringOption(options, 'dir') ?? process.cwd());
|
||||
const packageName = normalizePackageName(stringOption(options, 'name') ?? basename(root));
|
||||
|
||||
ensureDir(root);
|
||||
ensureDir(join(root, 'src'));
|
||||
ensureDir(matrixDir(root));
|
||||
|
||||
writeIfMissing(join(root, 'package.json'), `${JSON.stringify({
|
||||
name: packageName,
|
||||
version: '0.0.0',
|
||||
type: 'module',
|
||||
scripts: {
|
||||
build: 'tsc -p tsconfig.json',
|
||||
},
|
||||
dependencies: {
|
||||
'@open-matrix/core': '^0.1.0',
|
||||
},
|
||||
devDependencies: {
|
||||
typescript: '^5.7.0',
|
||||
},
|
||||
}, null, 2)}\n`);
|
||||
|
||||
writeIfMissing(join(root, 'tsconfig.json'), `${JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: 'ES2022',
|
||||
module: 'NodeNext',
|
||||
moduleResolution: 'NodeNext',
|
||||
strict: true,
|
||||
skipLibCheck: true,
|
||||
outDir: 'dist',
|
||||
},
|
||||
include: ['src/**/*.ts'],
|
||||
}, null, 2)}\n`);
|
||||
|
||||
writeIfMissing(join(root, 'src', 'index.ts'), '// Matrix package exports are added here.\n');
|
||||
writeIfMissing(join(root, 'matrix.json'), `${JSON.stringify({
|
||||
name: packageName,
|
||||
version: '0.0.0',
|
||||
runtime: {
|
||||
language: 'typescript',
|
||||
entry: 'dist/index.js',
|
||||
},
|
||||
components: [],
|
||||
}, null, 2)}\n`);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, command: 'init', dir: root, name: packageName }, null, 2));
|
||||
}
|
||||
|
||||
function readMatrixManifest(root: string): Record<string, unknown> {
|
||||
const manifestPath = join(root, 'matrix.json');
|
||||
if (!existsSync(manifestPath)) throw new Error(`matrix.json not found in ${root}; run matrix init first`);
|
||||
const value = readJsonFile(manifestPath);
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`matrix.json must be a JSON object: ${manifestPath}`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function appendExport(indexPath: string, className: string): void {
|
||||
const line = `export { ${className} } from './${className}.js';`;
|
||||
const current = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : '';
|
||||
if (!current.includes(line)) {
|
||||
writeFileSync(indexPath, `${current.trimEnd()}\n${line}\n`, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function appendFactoryExport(indexPath: string, className: string): void {
|
||||
const line = `export { createStandalone${className} } from './create-standalone-${className}.js';`;
|
||||
const current = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : '';
|
||||
if (!current.includes(line)) {
|
||||
writeFileSync(indexPath, `${current.trimEnd()}\n${line}\n`, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeIdToken(value: string): string {
|
||||
const token = value
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-+/g, '-');
|
||||
return token || 'ACTOR';
|
||||
}
|
||||
|
||||
function actorFactoryTemplate(className: string, mount: string): string {
|
||||
const factoryName = `createStandalone${className}`;
|
||||
return `import { InMemoryBroker, InMemoryTransport, MatrixRuntime } from '@open-matrix/core';
|
||||
|
||||
import { ${className} } from './${className}.js';
|
||||
|
||||
interface StandaloneBootContext {
|
||||
readonly root?: string;
|
||||
readonly mount?: string;
|
||||
}
|
||||
|
||||
interface StandaloneRunnerServices {
|
||||
readonly runtime?: MatrixRuntime;
|
||||
}
|
||||
|
||||
function readTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function requiredStandaloneRoot(requestedRoot: string | undefined, runtime: MatrixRuntime): string {
|
||||
const runtimeRoot = readTrimmedString(runtime.transport.root);
|
||||
const root = requestedRoot ?? runtimeRoot;
|
||||
if (!root) throw new Error('${factoryName} requires bootContext.root or a runtime transport root');
|
||||
return root;
|
||||
}
|
||||
|
||||
export async function ${factoryName}(
|
||||
_overrides: Record<string, unknown> = {},
|
||||
services?: StandaloneRunnerServices,
|
||||
_config?: unknown,
|
||||
bootContext: StandaloneBootContext = {},
|
||||
): Promise<{
|
||||
runtime: MatrixRuntime;
|
||||
actor: ${className};
|
||||
root: string;
|
||||
mount: string;
|
||||
}> {
|
||||
const requestedRoot = readTrimmedString(bootContext.root);
|
||||
const runtime = services?.runtime ?? new MatrixRuntime({
|
||||
transport: new InMemoryTransport(new InMemoryBroker(), {
|
||||
name: '${mount}-standalone',
|
||||
...(requestedRoot ? { root: requestedRoot } : {}),
|
||||
}),
|
||||
logging: false,
|
||||
});
|
||||
const root = requiredStandaloneRoot(requestedRoot, runtime);
|
||||
const mount = readTrimmedString(bootContext.mount) ?? '${mount}';
|
||||
const actor = await runtime.createSupervised(${className}, mount);
|
||||
return { runtime, actor, root, mount };
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
async function addActorCommand(options: CommandOptions, name: string): Promise<void> {
|
||||
const root = workspaceDir(options);
|
||||
const normalized = normalizePackageName(name);
|
||||
const className = toActorClassName(name);
|
||||
const mount = stringOption(options, 'mount') ?? normalized;
|
||||
const sourcePath = join(root, 'src', `${className}.ts`);
|
||||
const factoryPath = join(root, 'src', `create-standalone-${className}.ts`);
|
||||
|
||||
readMatrixManifest(root);
|
||||
ensureDir(join(root, 'src'));
|
||||
writeIfMissing(sourcePath, `import { MatrixActor } from '@open-matrix/core';
|
||||
|
||||
export class ${className} extends MatrixActor {
|
||||
static accepts = {
|
||||
echo: {},
|
||||
getStatus: {},
|
||||
};
|
||||
|
||||
onEcho(payload: { message?: string }) {
|
||||
return {
|
||||
ok: true,
|
||||
message: payload.message ?? null,
|
||||
actor: '${className}',
|
||||
};
|
||||
}
|
||||
|
||||
onGetStatus() {
|
||||
return {
|
||||
ok: true,
|
||||
actor: '${className}',
|
||||
mount: '${mount}',
|
||||
};
|
||||
}
|
||||
}
|
||||
`);
|
||||
appendExport(join(root, 'src', 'index.ts'), className);
|
||||
writeIfMissing(factoryPath, actorFactoryTemplate(className, mount));
|
||||
appendFactoryExport(join(root, 'src', 'index.ts'), className);
|
||||
|
||||
const manifest = readMatrixManifest(root);
|
||||
const components = Array.isArray(manifest.components)
|
||||
? manifest.components.filter(isRecord)
|
||||
: [];
|
||||
const nextComponents = components.filter((entry) => entry.type !== className && entry.mount !== mount);
|
||||
nextComponents.push({
|
||||
type: className,
|
||||
export: className,
|
||||
mount,
|
||||
surface: 'headless',
|
||||
accepts: ['echo', 'getStatus'],
|
||||
});
|
||||
writeJsonFile(join(root, 'matrix.json'), {
|
||||
...manifest,
|
||||
runtime: {
|
||||
...(isRecord(manifest.runtime) ? manifest.runtime : {}),
|
||||
language: 'typescript',
|
||||
entry: './dist/index.js',
|
||||
factory: {
|
||||
kind: 'factory',
|
||||
export: `createStandalone${className}`,
|
||||
instance: {
|
||||
id: `RUNTIME-HOST-${runtimeIdToken(mount)}`,
|
||||
class: 'service',
|
||||
rootKind: 'component',
|
||||
componentType: className,
|
||||
autoStart: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
components: nextComponents,
|
||||
});
|
||||
|
||||
console.log(JSON.stringify({ ok: true, command: 'add actor', dir: root, actor: className, mount }, null, 2));
|
||||
}
|
||||
|
||||
async function configureCommand(options: CommandOptions): Promise<void> {
|
||||
const root = workspaceDir(options);
|
||||
const key = stringOption(options, 'key') ?? process.env.MATRIX_API_KEY ?? process.env.HIVECAST_API_KEY;
|
||||
const cloud = stringOption(options, 'cloud') ?? process.env.MATRIX_CLOUD ?? process.env.HIVECAST_CLOUD;
|
||||
const space = stringOption(options, 'space') ?? process.env.MATRIX_SPACE ?? process.env.HIVECAST_SPACE;
|
||||
const runtimeHostBin = stringOption(options, 'runtime-host-bin') ?? process.env.MATRIX_RUNTIME_HOST_BIN;
|
||||
const runtimeHostRouting = stringOption(options, 'runtime-host-routing') ?? process.env.MATRIX_RUNTIME_HOST_ROUTING;
|
||||
if (!key) throw new Error('MATRIX_API_KEY_REQUIRED: matrix configure requires --key or MATRIX_API_KEY');
|
||||
if (!cloud) throw new Error('MATRIX_CLOUD_REQUIRED: matrix configure requires --cloud or MATRIX_CLOUD');
|
||||
if (!space) throw new Error('MATRIX_SPACE_REQUIRED: matrix configure requires --space or MATRIX_SPACE');
|
||||
if (runtimeHostRouting && !['release', 'workspace-dist', 'workspace-source'].includes(runtimeHostRouting)) {
|
||||
throw new Error(`--runtime-host-routing must be release, workspace-dist, or workspace-source, got ${runtimeHostRouting}`);
|
||||
}
|
||||
|
||||
const configPath = join(matrixDir(root), 'config.json');
|
||||
const credentialPath = join(matrixDir(root), 'credentials', 'matrix-api-key.json');
|
||||
writeJsonFile(configPath, {
|
||||
cloud,
|
||||
space,
|
||||
...(runtimeHostBin ? { runtimeHostBin } : {}),
|
||||
...(runtimeHostRouting ? { runtimeHostRouting } : {}),
|
||||
});
|
||||
writeJsonFile(credentialPath, { key, cloud, space });
|
||||
chmodSync(credentialPath, 0o600);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, command: 'configure', dir: root, cloud, space }, null, 2));
|
||||
}
|
||||
|
||||
function actorConstructorFromExport(value: unknown): ActorConstructor | null {
|
||||
if (typeof value !== 'function') return null;
|
||||
const candidate = value as { prototype?: { initialize?: unknown } };
|
||||
if (typeof candidate.prototype?.initialize !== 'function') return null;
|
||||
return value as ActorConstructor;
|
||||
}
|
||||
|
||||
async function importActor(entry: string, exportName: string): Promise<ActorConstructor> {
|
||||
const moduleUrl = `${pathToFileURL(resolve(entry)).href}?matrix_cli=${Date.now()}`;
|
||||
const mod = await import(moduleUrl) as Record<string, unknown>;
|
||||
const requested = actorConstructorFromExport(mod[exportName]);
|
||||
if (requested) return requested;
|
||||
const available = Object.entries(mod).filter(([, value]) => actorConstructorFromExport(value)).map(([key]) => key);
|
||||
if (available.length === 1) return actorConstructorFromExport(mod[available[0]])!;
|
||||
throw new Error(`No actor class found as export "${exportName}". Available actor exports: ${available.join(', ') || '(none)'}`);
|
||||
}
|
||||
|
||||
async function importPackageBootstrap(entry: string, exportName: string): Promise<PackageBootstrapFunction> {
|
||||
const moduleUrl = `${pathToFileURL(resolve(entry)).href}?matrix_cli=${Date.now()}`;
|
||||
const mod = await import(moduleUrl) as Record<string, unknown>;
|
||||
const value = mod[exportName];
|
||||
if (typeof value !== 'function') {
|
||||
throw new Error(`Package runtime factory export "${exportName}" was not found in ${entry}`);
|
||||
}
|
||||
return value as PackageBootstrapFunction;
|
||||
}
|
||||
|
||||
function readRequestBody(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolveBody, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('error', reject);
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
try {
|
||||
resolveBody(raw.trim() ? JSON.parse(raw) : {});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(text),
|
||||
});
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
async function requestActor(
|
||||
transport: InMemoryTransport,
|
||||
mount: string,
|
||||
op: string,
|
||||
payload: unknown,
|
||||
): Promise<unknown> {
|
||||
const correlationId = `matrix-cli-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const replyTo = `$replies.${correlationId}`;
|
||||
let unsubscribe: Unsub = () => {};
|
||||
const response = new Promise<unknown>((resolveResponse, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error(`REQUEST_TIMEOUT: ${mount}.${op}`));
|
||||
}, 5000);
|
||||
unsubscribe = transport.subscribe(replyTo, (value) => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolveResponse(value);
|
||||
});
|
||||
});
|
||||
|
||||
transport.publish(TopicRouter.inbox(mount), {
|
||||
op,
|
||||
payload,
|
||||
replyTo,
|
||||
correlationId,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function serveRuntime(input: {
|
||||
readonly runtime: MatrixRuntime;
|
||||
readonly transport: InMemoryTransport;
|
||||
readonly root: string;
|
||||
readonly mount: string;
|
||||
readonly entry: string;
|
||||
readonly exportName: string;
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
readonly workspaceRoot: string;
|
||||
}): Promise<void> {
|
||||
const { runtime, transport, root, mount, entry, exportName, host, port, workspaceRoot } = input;
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === 'GET' && req.url === '/healthz') {
|
||||
sendJson(res, 200, { ok: true, mount, root, pid: process.pid });
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'POST' || req.url !== '/invoke') {
|
||||
sendJson(res, 404, { ok: false, error: 'NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
const body = await readRequestBody(req) as { mount?: unknown; op?: unknown; payload?: unknown };
|
||||
const targetMount = typeof body.mount === 'string' && body.mount.trim() ? body.mount.trim() : mount;
|
||||
const op = typeof body.op === 'string' && body.op.trim() ? body.op.trim() : '';
|
||||
if (!op) throw new Error('matrix invoke requires an op');
|
||||
const result = await requestActor(transport, targetMount, op, body.payload);
|
||||
sendJson(res, 200, result);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolveListen) => server.listen(port, host, resolveListen));
|
||||
const status: RunStatus = {
|
||||
pid: process.pid,
|
||||
host,
|
||||
port,
|
||||
mount,
|
||||
entry: resolve(entry),
|
||||
exportName,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
writeJsonFile(join(matrixDir(workspaceRoot), 'run.json'), status);
|
||||
console.log(JSON.stringify({ ok: true, command: 'run', root, ...status }, null, 2));
|
||||
|
||||
const stop = async () => {
|
||||
await runtime.shutdown();
|
||||
server.close();
|
||||
};
|
||||
process.once('SIGINT', () => { void stop().finally(() => process.exit(0)); });
|
||||
process.once('SIGTERM', () => { void stop().finally(() => process.exit(0)); });
|
||||
}
|
||||
|
||||
async function runActorEntryCommand(options: CommandOptions, entry: string): Promise<void> {
|
||||
const mount = stringOption(options, 'mount');
|
||||
if (!mount) throw new Error('matrix run <entry> requires --mount <mount>');
|
||||
const exportName = stringOption(options, 'export') ?? 'default';
|
||||
const host = stringOption(options, 'host') ?? '127.0.0.1';
|
||||
const port = numberOption(options, 'port', 3920);
|
||||
const root = workspaceDir(options);
|
||||
|
||||
const ActorClass = await importActor(entry, exportName);
|
||||
const broker = new InMemoryBroker();
|
||||
const transport = new InMemoryTransport(broker, { name: `matrix-cli:${mount}`, root: 'sdk-local' });
|
||||
const runtime = new MatrixRuntime({ transport, logging: false });
|
||||
await runtime.create(ActorClass, mount);
|
||||
|
||||
await serveRuntime({ runtime, transport, root: 'sdk-local', mount, entry, exportName, host, port, workspaceRoot: root });
|
||||
}
|
||||
|
||||
function resolvePackageRunSpec(packageDir: string, options: CommandOptions): PackageRunSpec {
|
||||
const manifestPath = join(packageDir, 'matrix.json');
|
||||
if (!existsSync(manifestPath)) throw new Error(`matrix.json not found in ${packageDir}; run matrix init first`);
|
||||
const manifest = readJsonFile(manifestPath);
|
||||
if (!isRecord(manifest)) throw new Error(`matrix.json must be a JSON object: ${manifestPath}`);
|
||||
const runtime = isRecord(manifest.runtime) ? manifest.runtime : undefined;
|
||||
const factory = isRecord(runtime?.factory) ? runtime.factory : undefined;
|
||||
if (!factory) {
|
||||
throw new Error(`${manifestPath} must declare runtime.factory before matrix run/up can use package mode`);
|
||||
}
|
||||
const entry = stringOption(options, 'entry') ?? optionalString(factory.entry) ?? optionalString(runtime?.entry);
|
||||
if (!entry) throw new Error(`${manifestPath} runtime.factory requires entry or runtime.entry`);
|
||||
const exportName = stringOption(options, 'export') ?? optionalString(factory.export);
|
||||
if (!exportName) throw new Error(`${manifestPath} runtime.factory requires export`);
|
||||
const instance = isRecord(factory.instance) ? factory.instance : undefined;
|
||||
const mount = stringOption(options, 'mount')
|
||||
?? optionalString(instance?.mount)
|
||||
?? resolveComponentMount(manifest, instance);
|
||||
if (!mount) {
|
||||
throw new Error(`${manifestPath} runtime.factory must resolve a mount through --mount, instance.mount, or components[]`);
|
||||
}
|
||||
const entryPath = resolve(packageDir, entry);
|
||||
if (!existsSync(entryPath)) {
|
||||
throw new Error(`Package runtime entry does not exist: ${entryPath}. Run the package build first.`);
|
||||
}
|
||||
return { packageDir, entryPath, exportName, mount };
|
||||
}
|
||||
|
||||
function resolveComponentMount(manifest: Record<string, unknown>, instance: Record<string, unknown> | undefined): string | undefined {
|
||||
const components = Array.isArray(manifest.components) ? manifest.components.filter(isRecord) : [];
|
||||
const componentType = optionalString(instance?.componentType);
|
||||
if (componentType) {
|
||||
const matching = components.find((component) => optionalString(component.type) === componentType);
|
||||
return optionalString(matching?.mount);
|
||||
}
|
||||
if (components.length === 1) return optionalString(components[0].mount);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function runPackageCommand(options: CommandOptions, packageDir: string): Promise<void> {
|
||||
const spec = resolvePackageRunSpec(packageDir, options);
|
||||
const host = stringOption(options, 'host') ?? '127.0.0.1';
|
||||
const port = numberOption(options, 'port', 3920);
|
||||
const root = workspaceDir(options);
|
||||
const rootName = stringOption(options, 'root') ?? 'sdk-local';
|
||||
const broker = new InMemoryBroker();
|
||||
const transport = new InMemoryTransport(broker, { name: `matrix-cli:${spec.mount}`, root: rootName });
|
||||
const runtime = new MatrixRuntime({ transport, logging: false });
|
||||
const bootstrap = await importPackageBootstrap(spec.entryPath, spec.exportName);
|
||||
await bootstrap({}, { runtime }, undefined, {
|
||||
root: rootName,
|
||||
mount: spec.mount,
|
||||
packageDir: spec.packageDir,
|
||||
entryPath: spec.entryPath,
|
||||
exportName: spec.exportName,
|
||||
});
|
||||
await serveRuntime({
|
||||
runtime,
|
||||
transport,
|
||||
root: rootName,
|
||||
mount: spec.mount,
|
||||
entry: spec.entryPath,
|
||||
exportName: spec.exportName,
|
||||
host,
|
||||
port,
|
||||
workspaceRoot: root,
|
||||
});
|
||||
}
|
||||
|
||||
async function runCommand(options: CommandOptions, positional: string[]): Promise<void> {
|
||||
const root = workspaceDir(options);
|
||||
const target = positional[0] ? resolve(positional[0]) : root;
|
||||
if (existsSync(target) && statSync(target).isDirectory()) {
|
||||
await runPackageCommand(options, target);
|
||||
return;
|
||||
}
|
||||
if (!positional[0]) throw new Error('matrix run requires an entry file or package directory');
|
||||
await runActorEntryCommand(options, positional[0]);
|
||||
}
|
||||
|
||||
async function invokeCommand(options: CommandOptions, positional: string[]): Promise<void> {
|
||||
const root = workspaceDir(options);
|
||||
const mount = positional[0];
|
||||
const op = positional[1];
|
||||
const payloadRaw = positional[2] ?? '{}';
|
||||
if (!mount) throw new Error('matrix invoke requires <mount>');
|
||||
if (!op) throw new Error('matrix invoke requires <op>');
|
||||
const statusPath = join(matrixDir(root), 'run.json');
|
||||
const status = existsSync(statusPath) ? readJsonFile(statusPath) as Partial<RunStatus> : {};
|
||||
const host = stringOption(options, 'host') ?? (typeof status.host === 'string' ? status.host : '127.0.0.1');
|
||||
const port = numberOption(options, 'port', typeof status.port === 'number' ? status.port : 3920);
|
||||
const payload = JSON.parse(payloadRaw);
|
||||
|
||||
const response = await fetch(`http://${host}:${port}/invoke`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ mount, op, payload }),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`matrix invoke failed (${response.status}): ${text}`);
|
||||
console.log(JSON.stringify(JSON.parse(text), null, 2));
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`matrix SDK CLI
|
||||
|
||||
Commands:
|
||||
matrix init [dir] [--name name]
|
||||
matrix add actor <name> [--dir dir] [--mount mount]
|
||||
matrix actor <name> [--dir dir] [--mount mount]
|
||||
matrix configure --key key --cloud url --space space [--dir dir] [--runtime-host-bin path]
|
||||
matrix run [entry-or-dir] [--mount mount] [--export name] [--port port] [--dir dir]
|
||||
matrix invoke <mount> <op> [json] [--port port] [--dir dir]
|
||||
matrix up [dir] --runtime-host-bin path [--runtime-host-routing workspace-source|workspace-dist|release]
|
||||
`);
|
||||
}
|
||||
|
||||
async function upCommand(rawArgs: string[]): Promise<void> {
|
||||
const parsed = parseArgs(rawArgs);
|
||||
const root = workspaceDir(parsed.options);
|
||||
const target = parsed.positional[0] ? resolve(parsed.positional[0]) : root;
|
||||
if (!existsSync(target) || !statSync(target).isDirectory()) {
|
||||
throw new Error(`matrix up target must be a package directory: ${target}`);
|
||||
}
|
||||
resolvePackageRunSpec(target, parsed.options);
|
||||
const config = readPackageLocalConfig(root);
|
||||
const runtimeHostBin = stringOption(parsed.options, 'runtime-host-bin')
|
||||
?? optionalString(config.runtimeHostBin)
|
||||
?? process.env.MATRIX_RUNTIME_HOST_BIN;
|
||||
if (!runtimeHostBin) {
|
||||
throw new Error('MATRIX_RUNTIME_HOST_BIN_REQUIRED: matrix up requires --runtime-host-bin or MATRIX_RUNTIME_HOST_BIN pointing to the Runtime Host matrix command');
|
||||
}
|
||||
const routing = stringOption(parsed.options, 'runtime-host-routing')
|
||||
?? optionalString(config.runtimeHostRouting)
|
||||
?? process.env.MATRIX_RUNTIME_HOST_ROUTING
|
||||
?? 'release';
|
||||
if (!['release', 'workspace-dist', 'workspace-source'].includes(routing)) {
|
||||
throw new Error(`--runtime-host-routing must be release, workspace-dist, or workspace-source, got ${routing}`);
|
||||
}
|
||||
|
||||
const runtimeHostPath = isPathLikeCommand(runtimeHostBin) ? resolve(runtimeHostBin) : runtimeHostBin;
|
||||
const command = runtimeHostPath.endsWith('.js') || runtimeHostPath.endsWith('.mjs')
|
||||
? process.execPath
|
||||
: runtimeHostPath;
|
||||
const prefix = command === process.execPath ? [runtimeHostPath] : [];
|
||||
const routingArgs = routing === 'workspace-source'
|
||||
? ['--workspace-source']
|
||||
: routing === 'workspace-dist'
|
||||
? ['--workspace-dist']
|
||||
: [];
|
||||
const result = spawnSync(command, [
|
||||
...prefix,
|
||||
...routingArgs,
|
||||
'up',
|
||||
target,
|
||||
...filterRuntimeHostArgs(rawArgs, parsed.positional.length > 0),
|
||||
], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`matrix up Runtime Host command failed with exit ${result.status ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readPackageLocalConfig(root: string): Record<string, unknown> {
|
||||
const configPath = join(matrixDir(root), 'config.json');
|
||||
if (!existsSync(configPath)) return {};
|
||||
const value = readJsonFile(configPath);
|
||||
if (!isRecord(value)) throw new Error(`${configPath} must contain a JSON object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function isPathLikeCommand(value: string): boolean {
|
||||
return value.startsWith('.')
|
||||
|| value.startsWith('/')
|
||||
|| value.includes('/');
|
||||
}
|
||||
|
||||
function filterRuntimeHostArgs(rawArgs: readonly string[], hasExplicitTarget: boolean): string[] {
|
||||
const valueOptions = new Set(['--dir', '--runtime-host-bin', '--runtime-host-routing']);
|
||||
const filtered: string[] = [];
|
||||
let consumedTarget = false;
|
||||
for (let i = 0; i < rawArgs.length; i += 1) {
|
||||
const arg = rawArgs[i];
|
||||
if (arg === '--') {
|
||||
filtered.push(...rawArgs.slice(i));
|
||||
break;
|
||||
}
|
||||
const inlineOption = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : '';
|
||||
if (valueOptions.has(arg)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (inlineOption && valueOptions.has(inlineOption)) {
|
||||
continue;
|
||||
}
|
||||
if (hasExplicitTarget && !arg.startsWith('-') && !consumedTarget) {
|
||||
consumedTarget = true;
|
||||
continue;
|
||||
}
|
||||
filtered.push(arg);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export async function main(args: string[]): Promise<void> {
|
||||
const [command, subcommand, ...rest] = args;
|
||||
if (!command || command === '--help' || command === '-h') {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'init') {
|
||||
const parsed = parseArgs([subcommand, ...rest].filter((value): value is string => Boolean(value)));
|
||||
await initCommand(parsed.options, parsed.positional);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'add' && subcommand === 'actor') {
|
||||
const [name, ...tail] = rest;
|
||||
if (!name) throw new Error('matrix add actor requires <name>');
|
||||
const parsed = parseArgs(tail);
|
||||
await addActorCommand(parsed.options, name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'actor') {
|
||||
if (!subcommand) throw new Error('matrix actor requires <name>');
|
||||
const parsed = parseArgs(rest);
|
||||
await addActorCommand(parsed.options, subcommand);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'configure') {
|
||||
const parsed = parseArgs([subcommand, ...rest].filter((value): value is string => Boolean(value)));
|
||||
await configureCommand(parsed.options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'run') {
|
||||
const parsed = parseArgs([subcommand, ...rest].filter((value): value is string => Boolean(value)));
|
||||
await runCommand(parsed.options, parsed.positional);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'invoke') {
|
||||
const parsed = parseArgs([subcommand, ...rest].filter((value): value is string => Boolean(value)));
|
||||
await invokeCommand(parsed.options, parsed.positional);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'up') {
|
||||
await upCommand([subcommand, ...rest].filter((value): value is string => Boolean(value)));
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
20
packages/cli/tsconfig.json
Normal file
20
packages/cli/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node"],
|
||||
"noEmit": false,
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
29
packages/contracts/build.mjs
Normal file
29
packages/contracts/build.mjs
Normal file
@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @open-matrix/contracts build
|
||||
*
|
||||
* Type contracts + one pure runtime function (decideMatrixPlacementBind).
|
||||
* Emits .js + .d.ts via tsc. Part of core-decontamination Phase 2.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { rmSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const distDir = resolve(__dirname, 'dist');
|
||||
const require = createRequire(import.meta.url);
|
||||
const typescriptPackageJson = require.resolve('typescript/package.json', { paths: [__dirname] });
|
||||
const tscBin = resolve(dirname(typescriptPackageJson), 'bin/tsc');
|
||||
|
||||
rmSync(distDir, { recursive: true, force: true });
|
||||
execFileSync(process.execPath, [
|
||||
tscBin,
|
||||
'-p',
|
||||
resolve(__dirname, 'tsconfig.build.json'),
|
||||
], {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
32
packages/contracts/package.json
Normal file
32
packages/contracts/package.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@open-matrix/contracts",
|
||||
"version": "0.1.0",
|
||||
"description": "Type contracts for Matrix substrate (placement, service registry, runtime presence). Almost zero runtime — one pure function (decideMatrixPlacementBind) plus pure types. Zero @open-matrix/core dependency. Extracted from @open-matrix/core during core-decontamination Phase 2 so schema evolution doesn't rebuild the actor kernel.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"./placement": { "types": "./dist/placement.d.ts", "default": "./dist/placement.js" },
|
||||
"./service-registry": { "types": "./dist/service-registry.d.ts", "default": "./dist/service-registry.js" },
|
||||
"./runtime-presence": { "types": "./dist/runtime-presence.d.ts", "default": "./dist/runtime-presence.js" },
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
18
packages/contracts/src/index.ts
Normal file
18
packages/contracts/src/index.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @open-matrix/contracts — re-export barrel.
|
||||
*
|
||||
* Consumers should prefer the subpath imports for tree-shaking and
|
||||
* for clarity about which contract they depend on:
|
||||
*
|
||||
* import type { MatrixMountCardinality } from '@open-matrix/contracts/placement';
|
||||
* import type { ServiceRegistryEntryV1 } from '@open-matrix/contracts/service-registry';
|
||||
* import type { RuntimePresenceRecordV1 } from '@open-matrix/contracts/runtime-presence';
|
||||
*
|
||||
* This barrel exists for consumers that legitimately need multiple
|
||||
* contract surfaces, and as a single re-export point during the
|
||||
* core-decontamination migration.
|
||||
*/
|
||||
|
||||
export * from './placement.js';
|
||||
export * from './service-registry.js';
|
||||
export * from './runtime-presence.js';
|
||||
161
packages/contracts/src/placement.ts
Normal file
161
packages/contracts/src/placement.ts
Normal file
@ -0,0 +1,161 @@
|
||||
export type MatrixMountCardinality =
|
||||
| 'authority-singleton'
|
||||
| 'device-singleton'
|
||||
| 'device-local-control'
|
||||
| 'queue-service'
|
||||
| 'named-instance'
|
||||
| 'replicated-read';
|
||||
|
||||
export interface MatrixPlacementDeclaration {
|
||||
readonly cardinality: MatrixMountCardinality;
|
||||
readonly failover?: 'manual' | 'lease-timeout';
|
||||
readonly queue?: string;
|
||||
readonly physicalMount?: string;
|
||||
}
|
||||
|
||||
export type MatrixPlacementClaimMode =
|
||||
| 'authoritative'
|
||||
| 'local-only'
|
||||
| 'pool-member'
|
||||
| 'mirror';
|
||||
|
||||
export interface RuntimeInventoryPlacementMetadata {
|
||||
readonly cardinality: MatrixMountCardinality;
|
||||
readonly claimMode: MatrixPlacementClaimMode;
|
||||
readonly logicalMount: string;
|
||||
readonly physicalMount: string;
|
||||
readonly callable: boolean;
|
||||
readonly standbyReason?: string;
|
||||
readonly placementEpoch?: number;
|
||||
}
|
||||
|
||||
export type MatrixPlacementBindDenyReason =
|
||||
| 'SINGLETON_MOUNT_NOT_OWNED'
|
||||
| 'AUTHORITY_COORDINATOR_LEASE_NOT_OWNED'
|
||||
| 'UNKNOWN_PLACEMENT'
|
||||
| 'QUEUE_REQUIRED';
|
||||
|
||||
export type AuthorityCoordinatorLeaseStatus =
|
||||
| 'active'
|
||||
| 'released'
|
||||
| 'expired'
|
||||
| 'fenced';
|
||||
|
||||
export type AuthorityCoordinatorLeaseScope =
|
||||
| 'local-home'
|
||||
| 'hub-authority'
|
||||
| 'remote-projection';
|
||||
|
||||
export type AuthorityCoordinatorLeaseAuthority =
|
||||
| 'local'
|
||||
| 'hub';
|
||||
|
||||
export type AuthorityCoordinatorLeaseSource =
|
||||
| 'local-file'
|
||||
| 'hub-projection';
|
||||
|
||||
export interface AuthorityCoordinatorLease {
|
||||
readonly version: 1;
|
||||
readonly authorityRoot: string;
|
||||
readonly leaseId: string;
|
||||
readonly ownerNodeId: string;
|
||||
readonly ownerHostId?: string;
|
||||
readonly ownerInstallId?: string;
|
||||
readonly ownerHostLinkId?: string;
|
||||
readonly placementEpoch: number;
|
||||
readonly acquiredAt: string;
|
||||
readonly heartbeatAt: string;
|
||||
readonly expiresAt: string;
|
||||
readonly status: AuthorityCoordinatorLeaseStatus;
|
||||
readonly scope: AuthorityCoordinatorLeaseScope;
|
||||
readonly leaseAuthority: AuthorityCoordinatorLeaseAuthority;
|
||||
readonly source: AuthorityCoordinatorLeaseSource;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface AuthorityCoordinatorLeaseAcquireInput {
|
||||
readonly authorityRoot: string;
|
||||
readonly ownerNodeId: string;
|
||||
readonly ownerHostId?: string;
|
||||
readonly ownerInstallId?: string;
|
||||
readonly ownerHostLinkId?: string;
|
||||
readonly placementEpoch: number;
|
||||
readonly scope?: AuthorityCoordinatorLeaseScope;
|
||||
readonly leaseAuthority?: AuthorityCoordinatorLeaseAuthority;
|
||||
readonly source?: AuthorityCoordinatorLeaseSource;
|
||||
readonly ttlMs: number;
|
||||
readonly now?: Date;
|
||||
readonly force?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthorityCoordinatorLeaseAcquireResult {
|
||||
readonly ok: boolean;
|
||||
readonly acquired: boolean;
|
||||
readonly lease?: AuthorityCoordinatorLease;
|
||||
readonly currentOwner?: AuthorityCoordinatorLease;
|
||||
readonly reason?:
|
||||
| 'LEASE_HELD_BY_OTHER_OWNER'
|
||||
| 'LEASE_EXPIRED'
|
||||
| 'LEASE_RELEASED'
|
||||
| 'LEASE_FORCED'
|
||||
| 'LEASE_RENEWED'
|
||||
| 'LEASE_ACQUIRED';
|
||||
}
|
||||
|
||||
export interface MatrixPlacementBindRequest {
|
||||
readonly authorityRoot: string;
|
||||
readonly runtimeId?: string;
|
||||
readonly packageName?: string;
|
||||
readonly mount: string;
|
||||
readonly cardinality: MatrixMountCardinality;
|
||||
readonly topologyKind?: string;
|
||||
readonly authorityCoordinatorActive: boolean;
|
||||
readonly ownerNodeId?: string;
|
||||
readonly authorityLease?: AuthorityCoordinatorLease | null;
|
||||
}
|
||||
|
||||
export interface MatrixPlacementBindDecision {
|
||||
readonly allowed: boolean;
|
||||
readonly callable: boolean;
|
||||
readonly reason?: MatrixPlacementBindDenyReason;
|
||||
}
|
||||
|
||||
export interface MatrixPlacementPolicy {
|
||||
canBind(input: MatrixPlacementBindRequest): MatrixPlacementBindDecision;
|
||||
}
|
||||
|
||||
export function decideMatrixPlacementBind(
|
||||
request: MatrixPlacementBindRequest,
|
||||
): MatrixPlacementBindDecision {
|
||||
if (
|
||||
request.cardinality === 'authority-singleton'
|
||||
&& request.authorityCoordinatorActive === false
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
callable: false,
|
||||
reason: 'SINGLETON_MOUNT_NOT_OWNED',
|
||||
};
|
||||
}
|
||||
|
||||
if (request.cardinality === 'authority-singleton' && request.authorityLease) {
|
||||
if (
|
||||
request.authorityLease.status !== 'active'
|
||||
|| (
|
||||
request.ownerNodeId
|
||||
&& request.authorityLease.ownerNodeId !== request.ownerNodeId
|
||||
)
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
callable: false,
|
||||
reason: 'AUTHORITY_COORDINATOR_LEASE_NOT_OWNED',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
callable: true,
|
||||
};
|
||||
}
|
||||
100
packages/contracts/src/runtime-presence.ts
Normal file
100
packages/contracts/src/runtime-presence.ts
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* RuntimePresenceTypes — shared types for namespace-scoped runtime discovery.
|
||||
*
|
||||
* These types define the contract between runtimes that announce their presence
|
||||
* and consumers (Director, system.runtimes, monitoring) that discover them.
|
||||
*
|
||||
* See: ARCHITECTURE/ARCHITECTURE-NAMESPACE-DISCOVERY-AND-RUNTIME-PRESENCE.md
|
||||
*/
|
||||
|
||||
import type { HealthSnapshotV1 } from './service-registry.js';
|
||||
import type { RuntimeInventoryPlacementMetadata } from './placement.js';
|
||||
|
||||
/** What kind of claim a runtime makes on a namespace prefix. */
|
||||
export type RuntimeClaimKind = 'authoritative' | 'proxy' | 'mirror' | 'local-only' | 'pool-member';
|
||||
|
||||
/** How a runtime exposes its actor inventory. */
|
||||
export type RuntimeInventoryMode = 'embedded' | 'pull';
|
||||
|
||||
/** Known runtime types. */
|
||||
export type RuntimeType = 'daemon' | 'browser' | 'cell' | 'container' | 'leaf' | 'worker' | 'folder';
|
||||
|
||||
/** Runtime presence events published on the namespace-scoped presence stream. */
|
||||
export type RuntimePresenceEventV1 = 'announce' | 'heartbeat' | 'departed';
|
||||
|
||||
/** A single actor/component entry in a runtime's inventory. */
|
||||
export interface RuntimeInventoryEntryV1 {
|
||||
/** Mount path within the namespace (e.g., 'system.agents', 'explorer'). */
|
||||
mount: string;
|
||||
/** Actor/component type name (e.g., '_AgentsRoot', 'DirectorTree'). */
|
||||
type?: string;
|
||||
/** What surface this actor provides. */
|
||||
surface?: 'headless' | 'dom' | 'webapp';
|
||||
/** Which runtime hosts this actor. */
|
||||
runtimeId: string;
|
||||
/** What kind of claim this actor represents. */
|
||||
claimKind: RuntimeClaimKind;
|
||||
/** Placement and callability metadata for this actor. */
|
||||
placement?: RuntimeInventoryPlacementMetadata;
|
||||
/** Human-readable description. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Full presence record for a runtime in a namespace. */
|
||||
export interface RuntimePresenceRecordV1 {
|
||||
version: '1';
|
||||
|
||||
/** The namespace this runtime participates in. */
|
||||
namespaceRoot: string;
|
||||
|
||||
/** Unique identity for this runtime instance. */
|
||||
runtimeId: string;
|
||||
|
||||
/** What kind of runtime this is. */
|
||||
runtimeType: RuntimeType;
|
||||
|
||||
/** The NATS transport root this runtime uses. */
|
||||
runtimeRoot: string;
|
||||
|
||||
/** Optional: authority root for deployment/ownership plane. */
|
||||
authorityRoot?: string;
|
||||
|
||||
/** Optional: platform root for HiveCast/hosting plane. */
|
||||
platformRoot?: string;
|
||||
|
||||
/** Optional: human-readable application name. */
|
||||
app?: string;
|
||||
|
||||
/** What namespace prefixes this runtime claims to serve. */
|
||||
claims: Array<{
|
||||
prefix: string;
|
||||
kind: RuntimeClaimKind;
|
||||
}>;
|
||||
|
||||
/** How this runtime exposes its inventory. */
|
||||
inventoryMode: RuntimeInventoryMode;
|
||||
|
||||
/** Inline actor list (when inventoryMode === 'embedded'). */
|
||||
inventory?: RuntimeInventoryEntryV1[];
|
||||
|
||||
/** Mount path of a catalog actor (when inventoryMode === 'pull'). */
|
||||
catalogMount?: string;
|
||||
|
||||
/** Optional: mount path for runtime lifecycle control. */
|
||||
controlMount?: string;
|
||||
|
||||
/** Milliseconds before missing heartbeat means runtime is dead. 0 = no timeout. */
|
||||
heartbeatTtlMs: number;
|
||||
|
||||
/** When this runtime first registered/announced. */
|
||||
registeredAt: number;
|
||||
|
||||
/** Last heartbeat timestamp. */
|
||||
lastHeartbeat: number;
|
||||
|
||||
/** Latest health snapshot from the runtime root actor. */
|
||||
health?: HealthSnapshotV1;
|
||||
|
||||
/** Optional runtime-specific metadata. */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
146
packages/contracts/src/service-registry.ts
Normal file
146
packages/contracts/src/service-registry.ts
Normal file
@ -0,0 +1,146 @@
|
||||
/**
|
||||
* ServiceRegistryTypes — shared V1 contract for live substrate directory entries.
|
||||
*
|
||||
* `system.registry` owns live, bus-addressable service/app/feed/actor/runtime
|
||||
* entries inside one namespace. Durable package metadata, Device enrollment,
|
||||
* identity, credentials, and Host supervision have separate owners.
|
||||
*/
|
||||
|
||||
import type { RuntimeInventoryPlacementMetadata } from './placement.js';
|
||||
|
||||
export type ServiceRegistryEntryKindV1 =
|
||||
| 'actor'
|
||||
| 'service'
|
||||
| 'app'
|
||||
| 'feed'
|
||||
| 'runtime'
|
||||
| 'gateway'
|
||||
| 'connector'
|
||||
| 'resource';
|
||||
|
||||
export type MountClaimModeV1 =
|
||||
| 'authoritative'
|
||||
| 'proxy'
|
||||
| 'mirror'
|
||||
| 'local-only'
|
||||
| 'pool-member';
|
||||
|
||||
export type MountClaimResultV1 =
|
||||
| 'accepted'
|
||||
| 'rejected'
|
||||
| 'collision'
|
||||
| 'joined-pool'
|
||||
| 'standby'
|
||||
| 'leader'
|
||||
| 'mirror'
|
||||
| 'local-only';
|
||||
|
||||
export type ServiceRegistryLoadBalancingModeV1 =
|
||||
| 'none'
|
||||
| 'round-robin'
|
||||
| 'least-loaded'
|
||||
| 'broadcast';
|
||||
|
||||
export type HealthStatusV1 =
|
||||
| 'ok'
|
||||
| 'degraded'
|
||||
| 'error'
|
||||
| 'unknown'
|
||||
| 'starting'
|
||||
| 'stopped';
|
||||
|
||||
export type AppSurfaceTypeV1 = 'page' | 'panel' | 'modal' | 'widget';
|
||||
|
||||
export type RegistryWatchEventTypeV1 = 'upsert' | 'release' | 'renew' | 'expired';
|
||||
|
||||
export interface CapabilityDeclarationV1 {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IntentDeclarationV1 {
|
||||
intent: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ServiceInstanceV1 {
|
||||
accepts: string[];
|
||||
emits: string[];
|
||||
streams: string[];
|
||||
capabilities: CapabilityDeclarationV1[];
|
||||
intents: IntentDeclarationV1[];
|
||||
}
|
||||
|
||||
export interface AppSurfaceV1 {
|
||||
route: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
surface: AppSurfaceTypeV1;
|
||||
openable: boolean;
|
||||
appName?: string;
|
||||
}
|
||||
|
||||
export interface ServiceRegistryPlacementV1 {
|
||||
deviceId?: string;
|
||||
hostId?: string;
|
||||
runtimeId?: string;
|
||||
packageId?: string;
|
||||
actorId?: string;
|
||||
deploymentInstanceId?: string;
|
||||
instanceName?: string;
|
||||
packageRef?: string;
|
||||
cardinality?: RuntimeInventoryPlacementMetadata['cardinality'];
|
||||
claimMode?: RuntimeInventoryPlacementMetadata['claimMode'];
|
||||
logicalMount?: string;
|
||||
physicalMount?: string;
|
||||
callable?: boolean;
|
||||
standbyReason?: string;
|
||||
placementEpoch?: number;
|
||||
}
|
||||
|
||||
export interface MountClaimV1 {
|
||||
mode: MountClaimModeV1;
|
||||
result: MountClaimResultV1;
|
||||
ownerComponentId: string;
|
||||
electionGroup?: string;
|
||||
leaderId?: string;
|
||||
loadBalancing: ServiceRegistryLoadBalancingModeV1;
|
||||
claimedAt: number;
|
||||
renewedAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface HealthSnapshotV1 {
|
||||
status: HealthStatusV1;
|
||||
reason?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ServiceRegistryEntryV1 {
|
||||
version: '1';
|
||||
namespaceRoot: string;
|
||||
mount: string;
|
||||
kind: ServiceRegistryEntryKindV1;
|
||||
service?: ServiceInstanceV1;
|
||||
app?: AppSurfaceV1;
|
||||
placement: ServiceRegistryPlacementV1;
|
||||
claim: MountClaimV1;
|
||||
health: HealthSnapshotV1;
|
||||
ttlMs: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RegistryWatchEventV1 {
|
||||
version: '1';
|
||||
sequence: number;
|
||||
type: RegistryWatchEventTypeV1;
|
||||
namespaceRoot: string;
|
||||
mount: string;
|
||||
at: number;
|
||||
entry?: ServiceRegistryEntryV1;
|
||||
}
|
||||
10
packages/contracts/tsconfig.build.json
Normal file
10
packages/contracts/tsconfig.build.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false
|
||||
}
|
||||
}
|
||||
15
packages/contracts/tsconfig.json
Normal file
15
packages/contracts/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
25
packages/core/CHANGELOG.md
Normal file
25
packages/core/CHANGELOG.md
Normal file
@ -0,0 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0 (2026-02-17)
|
||||
|
||||
Initial release.
|
||||
|
||||
### Features
|
||||
|
||||
- `MatrixActor` — headless actor base class with `static accepts` / `static emits` interface declarations
|
||||
- `MatrixActorHtmlElement` — Web Component base class with shadow DOM, template, and styles
|
||||
- `MatrixRuntime` — runtime orchestration, child creation, lifecycle management
|
||||
- `InMemoryBroker` — in-process message broker for local development
|
||||
- `NatsTransport` — NATS transport for production daemon connectivity
|
||||
- `FederationTransportAdapter` — cross-realm federation transport
|
||||
- `TopicRouter` — topic derivation (`$inbox`, `$events`) for accepts/emits topology
|
||||
- `RequestReply` — request/reply pattern over NATS with correlation IDs
|
||||
- `FlowBuilder` / `FlowEngine` — data flow pipeline construction and execution
|
||||
- `Interpreter` / `parseSExpr` — embedded LISP interpreter for declarative actor composition
|
||||
- `serialize` / `parse` — 5-format serialization (JSON, HTML, S-Expr, Fluent, Topics)
|
||||
- 135 named exports total with full TypeScript declarations
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `@open-matrix/federation` ^0.2.0
|
||||
- `nats.ws` ^1.29.2
|
||||
21
packages/core/LICENSE
Normal file
21
packages/core/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2026 Nicholas Galante
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
82
packages/core/README.md
Normal file
82
packages/core/README.md
Normal file
@ -0,0 +1,82 @@
|
||||
# @matrix/core
|
||||
|
||||
Matrix runtime SDK — actors, Web Components, transports, serialization, and LISP-on-Protocol.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @matrix/core
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Headless Actor (daemon-side)
|
||||
|
||||
```typescript
|
||||
import { MatrixActor } from '@matrix/core';
|
||||
|
||||
class PingActor extends MatrixActor {
|
||||
static accepts = {
|
||||
ping: { message: 'string' },
|
||||
};
|
||||
static emits = {
|
||||
pong: { echo: 'string', ts: 'number' },
|
||||
};
|
||||
|
||||
onPing(payload: { message: string }) {
|
||||
this.emit('pong', { echo: payload.message, ts: Date.now() });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Browser Web Component
|
||||
|
||||
```typescript
|
||||
import { MatrixActorHtmlElement } from '@matrix/core';
|
||||
|
||||
class StatusPanel extends MatrixActorHtmlElement {
|
||||
static accepts = { showStatus: { text: 'string' } };
|
||||
static template = `<div id="status"></div>`;
|
||||
static styles = `#status { font-family: monospace; padding: 8px; }`;
|
||||
|
||||
onShowStatus(payload: { text: string }) {
|
||||
this.shadowRoot!.querySelector('#status')!.textContent = payload.text;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('status-panel', StatusPanel);
|
||||
```
|
||||
|
||||
### Programmatic Runtime
|
||||
|
||||
```typescript
|
||||
import { MatrixRuntime, InMemoryBroker } from '@matrix/core';
|
||||
|
||||
const broker = new InMemoryBroker();
|
||||
const runtime = new MatrixRuntime({ broker });
|
||||
await runtime.start();
|
||||
```
|
||||
|
||||
## What's Included
|
||||
|
||||
| Category | Key Exports |
|
||||
|----------|------------|
|
||||
| **Core** | `MatrixActor`, `Supervisor`, `HeadlessComponentRegistry` |
|
||||
| **Framework** | `MatrixActorHtmlElement`, `MxContract`, `MxInterface`, `MxProxy` |
|
||||
| **Runtime** | `MatrixRuntime`, `HeadlessHost`, `ConsoleLogger` |
|
||||
| **Transports** | `InMemoryBroker`, `NatsTransport`, `FederationTransportAdapter` |
|
||||
| **Messaging** | `TopicRouter`, `SubscriptionBag`, `RequestReply` |
|
||||
| **LISP** | `Interpreter`, `EvalContext`, `parseSExpr` |
|
||||
| **Serialization** | `serialize`, `parse`, 5-format parity |
|
||||
| **Flow** | `FlowBuilder`, `FlowEngine`, `FlowContext` |
|
||||
|
||||
135 named exports total. Full type declarations included.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 20
|
||||
- TypeScript >= 5.0 (for type checking)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
77
packages/core/fix-esm-imports.mjs
Normal file
77
packages/core/fix-esm-imports.mjs
Normal file
@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post-build script: Adds .js extensions to relative import specifiers in compiled ESM output.
|
||||
*
|
||||
* TypeScript with moduleResolution:"Bundler" emits extensionless imports (e.g., from './foo').
|
||||
* Node.js ESM requires explicit .js extensions. This script rewrites all relative imports
|
||||
* in dist/ to add .js extensions where missing.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
|
||||
import { join, extname, dirname, resolve } from 'path';
|
||||
|
||||
const DIST_DIR = new URL('./dist/src/', import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1');
|
||||
|
||||
let fixed = 0;
|
||||
let files = 0;
|
||||
|
||||
function processFile(filePath) {
|
||||
if (!filePath.endsWith('.js') && !filePath.endsWith('.d.ts')) return;
|
||||
files++;
|
||||
|
||||
const fileDir = dirname(filePath);
|
||||
let content = readFileSync(filePath, 'utf8');
|
||||
let changed = false;
|
||||
|
||||
// Match: from './...' or from '../...' (relative imports without .js extension)
|
||||
// Also match: import('./...') (dynamic imports in .d.ts files)
|
||||
// Also match: import './...'; (bare side-effect imports)
|
||||
const addExt = (match, prefix, specifier, suffix) => {
|
||||
const ext = extname(specifier);
|
||||
if (ext === '.js' || ext === '.mjs' || ext === '.cjs' || ext === '.json') {
|
||||
return match;
|
||||
}
|
||||
const resolved = resolve(fileDir, specifier);
|
||||
const fileCandidates = [
|
||||
`${resolved}.js`,
|
||||
`${resolved}.mjs`,
|
||||
`${resolved}.cjs`,
|
||||
`${resolved}.d.ts`,
|
||||
];
|
||||
if (fileCandidates.some((candidate) => existsSync(candidate))) {
|
||||
changed = true;
|
||||
return `${prefix}${specifier}.js${suffix}`;
|
||||
}
|
||||
// Only fall back to /index.js when there is no sibling file target.
|
||||
if (existsSync(resolved) && statSync(resolved).isDirectory()) {
|
||||
changed = true;
|
||||
return `${prefix}${specifier}/index.js${suffix}`;
|
||||
}
|
||||
changed = true;
|
||||
return `${prefix}${specifier}.js${suffix}`;
|
||||
};
|
||||
|
||||
const updated = content
|
||||
.replace(/(from\s+['"])(\.\.?\/[^'"]+)(['"])/g, addExt)
|
||||
.replace(/(import\(['"])(\.\.?\/[^'"]+)(['"]\))/g, addExt)
|
||||
.replace(/(import\s+['"])(\.\.?\/[^'"]+)(['"])/g, addExt);
|
||||
|
||||
if (changed) {
|
||||
writeFileSync(filePath, updated);
|
||||
fixed++;
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
walk(full);
|
||||
} else {
|
||||
processFile(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(DIST_DIR);
|
||||
console.log(`Fixed ${fixed}/${files} files with .js extensions`);
|
||||
363
packages/core/package.json
Normal file
363
packages/core/package.json
Normal file
@ -0,0 +1,363 @@
|
||||
{
|
||||
"name": "@open-matrix/core",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Matrix runtime SDK \u2014 actors, Web Components, transports, serialization, and LISP-on-Protocol",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nicholasgalante/Matrix-3.git",
|
||||
"directory": "packages/core"
|
||||
},
|
||||
"keywords": [
|
||||
"matrix",
|
||||
"actors",
|
||||
"web-components",
|
||||
"nats",
|
||||
"runtime",
|
||||
"sdk"
|
||||
],
|
||||
"main": "./dist/src/index.js",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"import": "./dist/src/index.js",
|
||||
"default": "./dist/src/index.js"
|
||||
},
|
||||
"./_node-compat": {
|
||||
"types": "./dist/src/_node-compat.d.ts",
|
||||
"import": "./dist/src/_node-compat.js",
|
||||
"default": "./dist/src/_node-compat.js"
|
||||
},
|
||||
"./core/IRecordStoreProvider": {
|
||||
"types": "./dist/src/core/IRecordStoreProvider.d.ts",
|
||||
"import": "./dist/src/core/IRecordStoreProvider.js",
|
||||
"default": "./dist/src/core/IRecordStoreProvider.js"
|
||||
},
|
||||
"./core/MatrixActor": {
|
||||
"types": "./dist/src/core/MatrixActor.d.ts",
|
||||
"import": "./dist/src/core/MatrixActor.js",
|
||||
"default": "./dist/src/core/MatrixActor.js"
|
||||
},
|
||||
"./core/MatrixActor.js": {
|
||||
"types": "./dist/src/core/MatrixActor.d.ts",
|
||||
"import": "./dist/src/core/MatrixActor.js",
|
||||
"default": "./dist/src/core/MatrixActor.js"
|
||||
},
|
||||
"./core/composite/IComponentRuntimeMetadata": {
|
||||
"types": "./dist/src/core/composite/IComponentRuntimeMetadata.d.ts",
|
||||
"import": "./dist/src/core/composite/IComponentRuntimeMetadata.js",
|
||||
"default": "./dist/src/core/composite/IComponentRuntimeMetadata.js"
|
||||
},
|
||||
"./core/composite": {
|
||||
"types": "./dist/src/core/composite/index.d.ts",
|
||||
"import": "./dist/src/core/composite/index.js",
|
||||
"default": "./dist/src/core/composite/index.js"
|
||||
},
|
||||
"./core/security/ICapabilityToken": {
|
||||
"types": "./dist/src/core/security/ICapabilityToken.d.ts",
|
||||
"import": "./dist/src/core/security/ICapabilityToken.js",
|
||||
"default": "./dist/src/core/security/ICapabilityToken.js"
|
||||
},
|
||||
"./core/security/ISecurityRealm": {
|
||||
"types": "./dist/src/core/security/ISecurityRealm.d.ts",
|
||||
"import": "./dist/src/core/security/ISecurityRealm.js",
|
||||
"default": "./dist/src/core/security/ISecurityRealm.js"
|
||||
},
|
||||
"./core/security/ITopicClaim": {
|
||||
"types": "./dist/src/core/security/ITopicClaim.d.ts",
|
||||
"import": "./dist/src/core/security/ITopicClaim.js",
|
||||
"default": "./dist/src/core/security/ITopicClaim.js"
|
||||
},
|
||||
"./core/security/PolicyPresets": {
|
||||
"types": "./dist/src/core/security/PolicyPresets.d.ts",
|
||||
"import": "./dist/src/core/security/PolicyPresets.js",
|
||||
"default": "./dist/src/core/security/PolicyPresets.js"
|
||||
},
|
||||
"./core/security/SecurityRealm": {
|
||||
"types": "./dist/src/core/security/SecurityRealm.d.ts",
|
||||
"import": "./dist/src/core/security/SecurityRealm.js",
|
||||
"default": "./dist/src/core/security/SecurityRealm.js"
|
||||
},
|
||||
"./core/security/TopicClaimRegistry": {
|
||||
"types": "./dist/src/core/security/TopicClaimRegistry.d.ts",
|
||||
"import": "./dist/src/core/security/TopicClaimRegistry.js",
|
||||
"default": "./dist/src/core/security/TopicClaimRegistry.js"
|
||||
},
|
||||
"./core/security/defaults": {
|
||||
"types": "./dist/src/core/security/defaults.d.ts",
|
||||
"import": "./dist/src/core/security/defaults.js",
|
||||
"default": "./dist/src/core/security/defaults.js"
|
||||
},
|
||||
"./core/context/RealmContext": {
|
||||
"types": "./dist/src/core/context/RealmContext.d.ts",
|
||||
"import": "./dist/src/core/context/RealmContext.js",
|
||||
"default": "./dist/src/core/context/RealmContext.js"
|
||||
},
|
||||
"./services/SessionInfoService": {
|
||||
"types": "./dist/src/services/SessionInfoService.d.ts",
|
||||
"import": "./dist/src/services/SessionInfoService.js",
|
||||
"default": "./dist/src/services/SessionInfoService.js"
|
||||
},
|
||||
"./engine/core/IMatrixContext": {
|
||||
"types": "./dist/src/engine/core/IMatrixContext.d.ts",
|
||||
"import": "./dist/src/engine/core/IMatrixContext.js",
|
||||
"default": "./dist/src/engine/core/IMatrixContext.js"
|
||||
},
|
||||
"./engine/core/MatrixContext": {
|
||||
"types": "./dist/src/engine/core/MatrixContext.d.ts",
|
||||
"import": "./dist/src/engine/core/MatrixContext.js",
|
||||
"default": "./dist/src/engine/core/MatrixContext.js"
|
||||
},
|
||||
"./engine/messaging/TopicRouter": {
|
||||
"types": "./dist/src/engine/messaging/TopicRouter.d.ts",
|
||||
"import": "./dist/src/engine/messaging/TopicRouter.js",
|
||||
"default": "./dist/src/engine/messaging/TopicRouter.js"
|
||||
},
|
||||
"./engine/remoting/ComponentProxy": {
|
||||
"types": "./dist/src/engine/remoting/ComponentProxy.d.ts",
|
||||
"import": "./dist/src/engine/remoting/ComponentProxy.js",
|
||||
"default": "./dist/src/engine/remoting/ComponentProxy.js"
|
||||
},
|
||||
"./engine/remoting/IIntrospectResult": {
|
||||
"types": "./dist/src/engine/remoting/IIntrospectResult.d.ts",
|
||||
"import": "./dist/src/engine/remoting/IIntrospectResult.js",
|
||||
"default": "./dist/src/engine/remoting/IIntrospectResult.js"
|
||||
},
|
||||
"./engine/remoting/ITransportAdapter": {
|
||||
"types": "./dist/src/engine/remoting/ITransportAdapter.d.ts",
|
||||
"import": "./dist/src/engine/remoting/ITransportAdapter.js",
|
||||
"default": "./dist/src/engine/remoting/ITransportAdapter.js"
|
||||
},
|
||||
"./engine/remoting/RequestReply": {
|
||||
"types": "./dist/src/engine/remoting/RequestReply.d.ts",
|
||||
"import": "./dist/src/engine/remoting/RequestReply.js",
|
||||
"default": "./dist/src/engine/remoting/RequestReply.js"
|
||||
},
|
||||
"./engine/remoting/RequestReply.js": {
|
||||
"types": "./dist/src/engine/remoting/RequestReply.d.ts",
|
||||
"import": "./dist/src/engine/remoting/RequestReply.js",
|
||||
"default": "./dist/src/engine/remoting/RequestReply.js"
|
||||
},
|
||||
"./engine/utils/NameUtils": {
|
||||
"types": "./dist/src/engine/utils/NameUtils.d.ts",
|
||||
"import": "./dist/src/engine/utils/NameUtils.js",
|
||||
"default": "./dist/src/engine/utils/NameUtils.js"
|
||||
},
|
||||
"./flow/FlowBuilder": {
|
||||
"types": "./dist/src/flow/FlowBuilder.d.ts",
|
||||
"import": "./dist/src/flow/FlowBuilder.js",
|
||||
"default": "./dist/src/flow/FlowBuilder.js"
|
||||
},
|
||||
"./flow/FlowEngine": {
|
||||
"types": "./dist/src/flow/FlowEngine.d.ts",
|
||||
"import": "./dist/src/flow/FlowEngine.js",
|
||||
"default": "./dist/src/flow/FlowEngine.js"
|
||||
},
|
||||
"./flow/FlowPlan": {
|
||||
"types": "./dist/src/flow/FlowPlan.d.ts",
|
||||
"import": "./dist/src/flow/FlowPlan.js",
|
||||
"default": "./dist/src/flow/FlowPlan.js"
|
||||
},
|
||||
"./flow/FlowTypes": {
|
||||
"types": "./dist/src/flow/FlowTypes.d.ts",
|
||||
"import": "./dist/src/flow/FlowTypes.js",
|
||||
"default": "./dist/src/flow/FlowTypes.js"
|
||||
},
|
||||
"./flow/viewers": {
|
||||
"types": "./dist/src/flow/viewers/index.d.ts",
|
||||
"import": "./dist/src/flow/viewers/index.js",
|
||||
"default": "./dist/src/flow/viewers/index.js"
|
||||
},
|
||||
"./flow/viewers/ViewTypeRegistry": {
|
||||
"types": "./dist/src/flow/viewers/ViewTypeRegistry.d.ts",
|
||||
"import": "./dist/src/flow/viewers/ViewTypeRegistry.js",
|
||||
"default": "./dist/src/flow/viewers/ViewTypeRegistry.js"
|
||||
},
|
||||
"./framework/ActivityFrame": {
|
||||
"types": "./dist/src/framework/ActivityFrame.d.ts",
|
||||
"import": "./dist/src/framework/ActivityFrame.js",
|
||||
"default": "./dist/src/framework/ActivityFrame.js"
|
||||
},
|
||||
"./framework/IStateStore": {
|
||||
"types": "./dist/src/framework/IStateStore.d.ts",
|
||||
"import": "./dist/src/framework/IStateStore.js",
|
||||
"default": "./dist/src/framework/IStateStore.js"
|
||||
},
|
||||
"./framework/MatrixActorHtmlElement": {
|
||||
"types": "./dist/src/framework/MatrixActorHtmlElement.d.ts",
|
||||
"import": "./dist/src/framework/MatrixActorHtmlElement.js",
|
||||
"default": "./dist/src/framework/MatrixActorHtmlElement.js"
|
||||
},
|
||||
"./framework/PageManifest": {
|
||||
"types": "./dist/src/framework/PageManifest.d.ts",
|
||||
"import": "./dist/src/framework/PageManifest.js",
|
||||
"default": "./dist/src/framework/PageManifest.js"
|
||||
},
|
||||
"./framework/components/MxSplit": {
|
||||
"types": "./dist/src/framework/components/MxSplit.d.ts",
|
||||
"import": "./dist/src/framework/components/MxSplit.js",
|
||||
"default": "./dist/src/framework/components/MxSplit.js"
|
||||
},
|
||||
"./framework/components/MxTheme": {
|
||||
"types": "./dist/src/framework/components/MxTheme.d.ts",
|
||||
"import": "./dist/src/framework/components/MxTheme.js",
|
||||
"default": "./dist/src/framework/components/MxTheme.js"
|
||||
},
|
||||
"./framework/components/MxTopicTag": {
|
||||
"types": "./dist/src/framework/components/MxTopicTag.d.ts",
|
||||
"import": "./dist/src/framework/components/MxTopicTag.js",
|
||||
"default": "./dist/src/framework/components/MxTopicTag.js"
|
||||
},
|
||||
"./framework/components/index": {
|
||||
"types": "./dist/src/framework/components/index.d.ts",
|
||||
"import": "./dist/src/framework/components/index.js",
|
||||
"default": "./dist/src/framework/components/index.js"
|
||||
},
|
||||
"./lisp": {
|
||||
"types": "./dist/src/lisp/index.d.ts",
|
||||
"import": "./dist/src/lisp/index.js",
|
||||
"default": "./dist/src/lisp/index.js"
|
||||
},
|
||||
"./lisp/vlm": {
|
||||
"types": "./dist/src/lisp/vlm/index.d.ts",
|
||||
"import": "./dist/src/lisp/vlm/index.js",
|
||||
"default": "./dist/src/lisp/vlm/index.js"
|
||||
},
|
||||
"./runtime/ComponentFactory": {
|
||||
"types": "./dist/src/runtime/ComponentFactory.d.ts",
|
||||
"import": "./dist/src/runtime/ComponentFactory.js",
|
||||
"default": "./dist/src/runtime/ComponentFactory.js"
|
||||
},
|
||||
"./runtime/InstanceBootstrapContext": {
|
||||
"types": "./dist/src/runtime/InstanceBootstrapContext.d.ts",
|
||||
"import": "./dist/src/runtime/InstanceBootstrapContext.js",
|
||||
"default": "./dist/src/runtime/InstanceBootstrapContext.js"
|
||||
},
|
||||
"./runtime/InstanceBootstrapContext.js": {
|
||||
"types": "./dist/src/runtime/InstanceBootstrapContext.d.ts",
|
||||
"import": "./dist/src/runtime/InstanceBootstrapContext.js",
|
||||
"default": "./dist/src/runtime/InstanceBootstrapContext.js"
|
||||
},
|
||||
"./runtime/InstanceBootstrapRefs": {
|
||||
"types": "./dist/src/runtime/InstanceBootstrapRefs.d.ts",
|
||||
"import": "./dist/src/runtime/InstanceBootstrapRefs.js",
|
||||
"default": "./dist/src/runtime/InstanceBootstrapRefs.js"
|
||||
},
|
||||
"./runtime/InstanceBootstrapRefs.js": {
|
||||
"types": "./dist/src/runtime/InstanceBootstrapRefs.d.ts",
|
||||
"import": "./dist/src/runtime/InstanceBootstrapRefs.js",
|
||||
"default": "./dist/src/runtime/InstanceBootstrapRefs.js"
|
||||
},
|
||||
"./runtime/MatrixRuntime": {
|
||||
"types": "./dist/src/runtime/MatrixRuntime.d.ts",
|
||||
"import": "./dist/src/runtime/MatrixRuntime.js",
|
||||
"default": "./dist/src/runtime/MatrixRuntime.js"
|
||||
},
|
||||
"./runtime/MatrixRuntime.js": {
|
||||
"types": "./dist/src/runtime/MatrixRuntime.d.ts",
|
||||
"import": "./dist/src/runtime/MatrixRuntime.js",
|
||||
"default": "./dist/src/runtime/MatrixRuntime.js"
|
||||
},
|
||||
"./runtime/MountedInstanceMetadata": {
|
||||
"types": "./dist/src/runtime/MountedInstanceMetadata.d.ts",
|
||||
"import": "./dist/src/runtime/MountedInstanceMetadata.js",
|
||||
"default": "./dist/src/runtime/MountedInstanceMetadata.js"
|
||||
},
|
||||
"./runtime/MountedInstanceMetadata.js": {
|
||||
"types": "./dist/src/runtime/MountedInstanceMetadata.d.ts",
|
||||
"import": "./dist/src/runtime/MountedInstanceMetadata.js",
|
||||
"default": "./dist/src/runtime/MountedInstanceMetadata.js"
|
||||
},
|
||||
"./runtime/PackageConfigResolver": {
|
||||
"types": "./dist/src/runtime/PackageConfigResolver.d.ts",
|
||||
"import": "./dist/src/runtime/PackageConfigResolver.js",
|
||||
"default": "./dist/src/runtime/PackageConfigResolver.js"
|
||||
},
|
||||
"./serialization": {
|
||||
"types": "./dist/src/serialization/index.d.ts",
|
||||
"import": "./dist/src/serialization/index.js",
|
||||
"default": "./dist/src/serialization/index.js"
|
||||
},
|
||||
"./serialization/IComponentSchema": {
|
||||
"types": "./dist/src/serialization/IComponentSchema.d.ts",
|
||||
"import": "./dist/src/serialization/IComponentSchema.js",
|
||||
"default": "./dist/src/serialization/IComponentSchema.js"
|
||||
},
|
||||
"./transport/InMemoryBroker": {
|
||||
"types": "./dist/src/transport/InMemoryBroker.d.ts",
|
||||
"import": "./dist/src/transport/InMemoryBroker.js",
|
||||
"default": "./dist/src/transport/InMemoryBroker.js"
|
||||
},
|
||||
"./transport/InMemoryBroker.js": {
|
||||
"types": "./dist/src/transport/InMemoryBroker.d.ts",
|
||||
"import": "./dist/src/transport/InMemoryBroker.js",
|
||||
"default": "./dist/src/transport/InMemoryBroker.js"
|
||||
},
|
||||
"./transport/InMemoryTransport": {
|
||||
"types": "./dist/src/transport/InMemoryTransport.d.ts",
|
||||
"import": "./dist/src/transport/InMemoryTransport.js",
|
||||
"default": "./dist/src/transport/InMemoryTransport.js"
|
||||
},
|
||||
"./transport/InMemoryTransport.js": {
|
||||
"types": "./dist/src/transport/InMemoryTransport.d.ts",
|
||||
"import": "./dist/src/transport/InMemoryTransport.js",
|
||||
"default": "./dist/src/transport/InMemoryTransport.js"
|
||||
},
|
||||
"./transport/NatsTransport": {
|
||||
"types": "./dist/src/transport/NatsTransport.d.ts",
|
||||
"import": "./dist/src/transport/NatsTransport.js",
|
||||
"default": "./dist/src/transport/NatsTransport.js"
|
||||
},
|
||||
"./transport/NatsTransport.js": {
|
||||
"types": "./dist/src/transport/NatsTransport.d.ts",
|
||||
"import": "./dist/src/transport/NatsTransport.js",
|
||||
"default": "./dist/src/transport/NatsTransport.js"
|
||||
},
|
||||
"./transport/TransportProxy": {
|
||||
"types": "./dist/src/transport/TransportProxy.d.ts",
|
||||
"import": "./dist/src/transport/TransportProxy.js",
|
||||
"default": "./dist/src/transport/TransportProxy.js"
|
||||
},
|
||||
"./transport/createBrowserNatsTransport": {
|
||||
"types": "./dist/src/transport/createBrowserNatsTransport.d.ts",
|
||||
"import": "./dist/src/transport/createBrowserNatsTransport.js",
|
||||
"default": "./dist/src/transport/createBrowserNatsTransport.js"
|
||||
},
|
||||
"./transport/createBrowserNatsTransport.js": {
|
||||
"types": "./dist/src/transport/createBrowserNatsTransport.d.ts",
|
||||
"import": "./dist/src/transport/createBrowserNatsTransport.js",
|
||||
"default": "./dist/src/transport/createBrowserNatsTransport.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true,maxRetries:3,retryDelay:50});require('fs').rmSync('.tsbuildinfo',{force:true})\" && tsc -p tsconfig.build.json && node fix-esm-imports.mjs",
|
||||
"test": "node ../../scripts/run-node-tests.cjs -- src/**/*.spec.ts",
|
||||
"test:ci": "node ../../scripts/run-node-tests.cjs -- src/**/*.spec.ts",
|
||||
"test:dom": "node ../../scripts/run-node-tests.cjs -- src/**/*.spec.ts",
|
||||
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||
"type-check": "tsc -p tsconfig.build.json --noEmit",
|
||||
"test:unit": "tsx --test tests/unit/core/**/*.spec.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist/src",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"dependencies": {
|
||||
"@open-matrix/contracts": "workspace:*",
|
||||
"@open-matrix/federation": "workspace:*",
|
||||
"@nats-io/nats-core": "^3.3.1",
|
||||
"nats.ws": "^1.30.3",
|
||||
"@open-matrix/omega-core": "workspace:*",
|
||||
"morphdom": "^2.7.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.15.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
2
packages/core/src/_node-compat.d.ts
vendored
Normal file
2
packages/core/src/_node-compat.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
declare const g: any;
|
||||
//# sourceMappingURL=_node-compat.d.ts.map
|
||||
1
packages/core/src/_node-compat.d.ts.map
Normal file
1
packages/core/src/_node-compat.d.ts.map
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"_node-compat.d.ts","sourceRoot":"","sources":["_node-compat.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,CAAC,EAAiB,GAAG,CAAC"}
|
||||
274
packages/core/src/_node-compat.ts
Normal file
274
packages/core/src/_node-compat.ts
Normal file
@ -0,0 +1,274 @@
|
||||
// src/_node-compat.ts
|
||||
//
|
||||
// DOM stubs for Node.js environments.
|
||||
// When headless tests import the barrel (src/index.ts), browser-targeting classes
|
||||
// like MatrixActorHtmlElement (extends HTMLElement) and MxInterface (customElements.define)
|
||||
// fail because the DOM API doesn't exist. This shim provides enough surface
|
||||
// for class declarations to evaluate AND for headless component tests to run.
|
||||
//
|
||||
// In the browser this file is a silent no-op — all globals already exist.
|
||||
|
||||
export {};
|
||||
|
||||
interface NodeCompatShadowRoot {
|
||||
mode: string;
|
||||
innerHTML: string;
|
||||
getElementById: (id?: string) => null;
|
||||
querySelector: (selector?: string) => null;
|
||||
querySelectorAll: (selector?: string) => [];
|
||||
appendChild: <T>(node: T) => T;
|
||||
removeChild: <T>(node: T) => T;
|
||||
replaceChildren: () => void;
|
||||
addEventListener: (type: string, handler: EventListenerOrEventListenerObject) => void;
|
||||
removeEventListener: (type: string, handler: EventListenerOrEventListenerObject) => void;
|
||||
dispatchEvent: (event: Event) => boolean;
|
||||
contains: (node: unknown) => boolean;
|
||||
childNodes: unknown[];
|
||||
children: unknown[];
|
||||
firstChild: null;
|
||||
}
|
||||
|
||||
interface NodeCompatTemplateElement {
|
||||
innerHTML: string;
|
||||
style?: {
|
||||
setProperty: (name: string, value: string) => void;
|
||||
getPropertyValue: (name: string) => string;
|
||||
removeProperty: (name: string) => void;
|
||||
};
|
||||
content: {
|
||||
childNodes: unknown[];
|
||||
querySelectorAll: (selector?: string) => [];
|
||||
};
|
||||
tagName: string;
|
||||
getAttribute: (name?: string) => null;
|
||||
setAttribute: (name?: string, value?: string) => void;
|
||||
}
|
||||
|
||||
interface NodeCompatDocument {
|
||||
createElement: (tag: string) => NodeCompatTemplateElement;
|
||||
createDocumentFragment: () => { appendChild: <T>(node: T) => T; childNodes: unknown[] };
|
||||
querySelector: (selector?: string) => null;
|
||||
querySelectorAll: (selector?: string) => [];
|
||||
body: {
|
||||
appendChild: <T>(node: T) => T;
|
||||
removeChild: <T>(node: T) => T;
|
||||
};
|
||||
}
|
||||
|
||||
interface NodeCompatCustomElementsRegistry {
|
||||
define: (name: string, ctor?: CustomElementConstructor) => void;
|
||||
get: (name: string) => CustomElementConstructor | undefined;
|
||||
getName?: (ctor: CustomElementConstructor) => string | null;
|
||||
upgrade?: (root: Node) => void;
|
||||
whenDefined?: (name: string) => Promise<CustomElementConstructor>;
|
||||
}
|
||||
|
||||
type NodeCompatGlobal = typeof globalThis & {
|
||||
HTMLElement?: typeof HTMLElement;
|
||||
document?: NodeCompatDocument;
|
||||
customElements?: NodeCompatCustomElementsRegistry;
|
||||
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
|
||||
cancelAnimationFrame?: (handle: number) => void;
|
||||
};
|
||||
|
||||
const g = globalThis as NodeCompatGlobal;
|
||||
|
||||
if (typeof g.HTMLElement === 'undefined') {
|
||||
const NodeCompatHTMLElement = class {
|
||||
private _attrs: Map<string, string> = new Map();
|
||||
private _children: unknown[] = [];
|
||||
private _style: Map<string, string> = new Map();
|
||||
shadowRoot: NodeCompatShadowRoot | null = null;
|
||||
localName = '';
|
||||
tagName = '';
|
||||
id = '';
|
||||
innerHTML = '';
|
||||
textContent = '';
|
||||
className = '';
|
||||
parentElement: Element | null = null;
|
||||
style = {
|
||||
setProperty: (name: string, value: string) => {
|
||||
this._style.set(name, value);
|
||||
},
|
||||
getPropertyValue: (name: string) => this._style.get(name) ?? '',
|
||||
removeProperty: (name: string) => {
|
||||
this._style.delete(name);
|
||||
},
|
||||
};
|
||||
|
||||
// NamedNodeMap-like iterable for _bridgeAttributes
|
||||
get attributes(): Iterable<{ name: string; value: string }> {
|
||||
return Array.from(this._attrs.entries()).map(([name, value]) => ({ name, value }));
|
||||
}
|
||||
|
||||
attachShadow(_opts?: { mode: string }): NodeCompatShadowRoot {
|
||||
const childNodes: unknown[] = [];
|
||||
const root: NodeCompatShadowRoot = {
|
||||
mode: 'open',
|
||||
innerHTML: '',
|
||||
getElementById: () => null,
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
appendChild: <T>(n: T) => {
|
||||
childNodes.push(n);
|
||||
return n;
|
||||
},
|
||||
removeChild: <T>(n: T) => {
|
||||
const index = childNodes.indexOf(n);
|
||||
if (index >= 0) childNodes.splice(index, 1);
|
||||
return n;
|
||||
},
|
||||
replaceChildren: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => true,
|
||||
contains: (node: unknown) => childNodes.includes(node),
|
||||
childNodes,
|
||||
children: childNodes,
|
||||
firstChild: null,
|
||||
};
|
||||
this.shadowRoot = root;
|
||||
return root;
|
||||
}
|
||||
|
||||
getAttribute(name: string): string | null {
|
||||
return this._attrs.get(name) ?? null;
|
||||
}
|
||||
|
||||
setAttribute(name: string, value: string): void {
|
||||
this._attrs.set(name, value);
|
||||
if (name === 'id') this.id = value;
|
||||
}
|
||||
|
||||
hasAttribute(name: string): boolean {
|
||||
return this._attrs.has(name);
|
||||
}
|
||||
|
||||
removeAttribute(name: string): void {
|
||||
this._attrs.delete(name);
|
||||
}
|
||||
|
||||
appendChild<T>(node: T): T {
|
||||
this._children.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
removeChild<T>(node: T): T {
|
||||
const idx = this._children.indexOf(node);
|
||||
if (idx >= 0) this._children.splice(idx, 1);
|
||||
return node;
|
||||
}
|
||||
|
||||
get children(): unknown[] { return this._children; }
|
||||
get childNodes(): unknown[] { return this._children; }
|
||||
closest(_selector: string): Element | null { return null; }
|
||||
querySelector(_selector: string): Element | null { return null; }
|
||||
querySelectorAll(_selector: string): Element[] { return []; }
|
||||
addEventListener(_type: string, _handler: EventListenerOrEventListenerObject): void {}
|
||||
removeEventListener(_type: string, _handler: EventListenerOrEventListenerObject): void {}
|
||||
dispatchEvent(_event: Event): boolean { return true; }
|
||||
};
|
||||
g.HTMLElement = NodeCompatHTMLElement as unknown as typeof HTMLElement;
|
||||
}
|
||||
|
||||
if (typeof g.document === 'undefined') {
|
||||
// Minimal DOM shim for Node.js — intentionally incomplete, only what the framework uses.
|
||||
// These casts are necessary because TypeScript's DOM types are far richer than this shim.
|
||||
g.document = {
|
||||
createElement(tag: string): NodeCompatTemplateElement {
|
||||
const lowerTag = tag.toLowerCase();
|
||||
const definedCtor = g.customElements?.get?.(lowerTag);
|
||||
if (definedCtor) {
|
||||
const element = new definedCtor() as HTMLElement & { localName?: string; tagName?: string };
|
||||
element.localName = lowerTag;
|
||||
element.tagName = lowerTag.toUpperCase();
|
||||
return element as unknown as NodeCompatTemplateElement;
|
||||
}
|
||||
const content = {
|
||||
childNodes: [] as unknown[],
|
||||
querySelectorAll: (_selector?: string) => [] as unknown as [],
|
||||
};
|
||||
const element = new g.HTMLElement() as HTMLElement & NodeCompatTemplateElement & {
|
||||
localName?: string;
|
||||
tagName?: string;
|
||||
content?: typeof content;
|
||||
};
|
||||
element.localName = lowerTag;
|
||||
element.tagName = lowerTag.toUpperCase();
|
||||
if (lowerTag === 'template') {
|
||||
element.content = content;
|
||||
}
|
||||
return Object.assign(element, {
|
||||
innerHTML: '',
|
||||
getAttribute: () => null,
|
||||
setAttribute: () => {},
|
||||
});
|
||||
},
|
||||
createDocumentFragment() {
|
||||
return { appendChild: <T>(n: T) => n, childNodes: [] as unknown[] };
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [] as unknown as [];
|
||||
},
|
||||
body: { appendChild: <T>(n: T) => n, removeChild: <T>(n: T) => n },
|
||||
} as NodeCompatDocument as typeof g.document;
|
||||
}
|
||||
|
||||
if (typeof g.customElements === 'undefined') {
|
||||
const registry = new Map<string, CustomElementConstructor>();
|
||||
const pending = new Map<string, Array<(ctor: CustomElementConstructor) => void>>();
|
||||
g.customElements = {
|
||||
define(name: string, ctor?: CustomElementConstructor) {
|
||||
if (!ctor) {
|
||||
throw new TypeError(`Custom element '${name}' requires a constructor`);
|
||||
}
|
||||
if (registry.has(name)) return;
|
||||
registry.set(name, ctor);
|
||||
const waiters = pending.get(name);
|
||||
if (waiters) {
|
||||
pending.delete(name);
|
||||
for (const resolve of waiters) {
|
||||
resolve(ctor);
|
||||
}
|
||||
}
|
||||
},
|
||||
get(name: string) {
|
||||
return registry.get(name);
|
||||
},
|
||||
getName(ctor: CustomElementConstructor) {
|
||||
for (const [name, current] of registry.entries()) {
|
||||
if (current === ctor) return name;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
upgrade() {},
|
||||
whenDefined(name: string) {
|
||||
const existing = registry.get(name);
|
||||
if (existing) return Promise.resolve(existing);
|
||||
return new Promise<CustomElementConstructor>((resolve) => {
|
||||
const waiters = pending.get(name);
|
||||
if (waiters) {
|
||||
waiters.push(resolve);
|
||||
return;
|
||||
}
|
||||
pending.set(name, [resolve]);
|
||||
});
|
||||
},
|
||||
} as NodeCompatCustomElementsRegistry as typeof g.customElements;
|
||||
}
|
||||
|
||||
if (typeof g.requestAnimationFrame === 'undefined') {
|
||||
g.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
const handle = setTimeout(() => callback(Date.now()), 16);
|
||||
return Number(handle);
|
||||
}) as typeof g.requestAnimationFrame;
|
||||
}
|
||||
|
||||
if (typeof g.cancelAnimationFrame === 'undefined') {
|
||||
g.cancelAnimationFrame = ((handle: number) => {
|
||||
clearTimeout(handle);
|
||||
}) as typeof g.cancelAnimationFrame;
|
||||
}
|
||||
50
packages/core/src/app-route/AppRouteView.ts
Normal file
50
packages/core/src/app-route/AppRouteView.ts
Normal file
@ -0,0 +1,50 @@
|
||||
export type AppRouteSource =
|
||||
| 'gateway'
|
||||
| 'system.devices'
|
||||
| 'runtime-registry'
|
||||
| 'projection';
|
||||
|
||||
export type AppRouteServedBy =
|
||||
| 'linked-device'
|
||||
| 'current-host'
|
||||
| 'platform-local'
|
||||
| 'auto-selected'
|
||||
| 'unavailable';
|
||||
|
||||
export type AppRouteTargetSource =
|
||||
| 'explicit'
|
||||
| 'namespace'
|
||||
| 'auto'
|
||||
| 'linked-device'
|
||||
| 'current-host'
|
||||
| 'linked-device-route'
|
||||
| 'linked-device-offline'
|
||||
| 'linked-device-app-route-missing'
|
||||
| 'linked-device-asset-mount-unreachable'
|
||||
| 'linked-device-runtime-control-unavailable'
|
||||
| 'current-host-route'
|
||||
| 'dashboard-section';
|
||||
|
||||
export interface IAppRouteView {
|
||||
readonly appName: string;
|
||||
readonly url: string;
|
||||
readonly routePrefix: string;
|
||||
readonly routeKey?: string;
|
||||
readonly authorityRoot?: string;
|
||||
readonly runtimeTargetRoot?: string;
|
||||
readonly runtimeWireRoot?: string;
|
||||
readonly deviceId?: string;
|
||||
readonly runtimeHostViewId?: string;
|
||||
readonly runtimeId?: string;
|
||||
readonly packageRef?: string;
|
||||
readonly routeSource: AppRouteSource;
|
||||
readonly servedBy: AppRouteServedBy;
|
||||
readonly targetSource?: AppRouteTargetSource;
|
||||
readonly requestedTarget?: string;
|
||||
readonly actualTarget?: string;
|
||||
readonly assetMount?: string;
|
||||
readonly origin?: string;
|
||||
readonly runtimeControlHealthStatus?: string;
|
||||
readonly runtimeControlHealthReason?: string;
|
||||
readonly recoveryReason?: string;
|
||||
}
|
||||
42
packages/core/src/contracts/WellKnownServiceBindings.ts
Normal file
42
packages/core/src/contracts/WellKnownServiceBindings.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import type { WellKnownServiceBinding } from '../engine/core/ScopedIntentResolver';
|
||||
import {
|
||||
PlatformAuthContract,
|
||||
PlatformCapabilitiesContract,
|
||||
UserAgentsContract,
|
||||
UserDevicesContract,
|
||||
UserRegistryContract,
|
||||
UserRuntimesContract,
|
||||
} from './WellKnownServiceContracts';
|
||||
|
||||
export const WELL_KNOWN_SERVICE_BINDINGS: readonly WellKnownServiceBinding[] = [
|
||||
{
|
||||
contractId: UserRegistryContract.id,
|
||||
defaultScope: 'user',
|
||||
mount: 'system.registry',
|
||||
},
|
||||
{
|
||||
contractId: UserDevicesContract.id,
|
||||
defaultScope: 'account',
|
||||
mount: 'system.devices',
|
||||
},
|
||||
{
|
||||
contractId: UserRuntimesContract.id,
|
||||
defaultScope: 'user',
|
||||
mount: 'system.runtimes',
|
||||
},
|
||||
{
|
||||
contractId: UserAgentsContract.id,
|
||||
defaultScope: 'user',
|
||||
mount: 'system.agents',
|
||||
},
|
||||
{
|
||||
contractId: PlatformAuthContract.id,
|
||||
defaultScope: 'platform',
|
||||
mount: 'system.auth',
|
||||
},
|
||||
{
|
||||
contractId: PlatformCapabilitiesContract.id,
|
||||
defaultScope: 'platform',
|
||||
mount: 'system.capabilities',
|
||||
},
|
||||
];
|
||||
190
packages/core/src/contracts/WellKnownServiceContracts.ts
Normal file
190
packages/core/src/contracts/WellKnownServiceContracts.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import type { ServiceContract } from '../engine/core/ServiceContract';
|
||||
|
||||
export const UserRegistryContract: ServiceContract = {
|
||||
id: 'matrix.user.registry',
|
||||
description: 'Registry for the active user Space.',
|
||||
ops: {
|
||||
'registry.list': {
|
||||
description: 'List actor/service claims for the active user Space.',
|
||||
payload: {},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: true },
|
||||
claims: { type: 'array', required: false },
|
||||
entries: { type: 'array', required: false },
|
||||
},
|
||||
},
|
||||
'registry.query': {
|
||||
description: 'Query actor/service claims for the active user Space.',
|
||||
payload: {
|
||||
namespaceRoot: { type: 'string', required: false },
|
||||
authorityRoot: { type: 'string', required: false },
|
||||
principalId: { type: 'string', required: false },
|
||||
busToken: { type: 'string', required: false },
|
||||
includeStale: { type: 'boolean', required: false },
|
||||
includeOffline: { type: 'boolean', required: false },
|
||||
},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: true },
|
||||
claims: { type: 'array', required: false },
|
||||
entries: { type: 'array', required: false },
|
||||
events: { type: 'array', required: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const UserDevicesContract: ServiceContract = {
|
||||
id: 'matrix.user.devices',
|
||||
description: 'Account-level linked Device inventory for the authenticated user.',
|
||||
ops: {
|
||||
'devices.list': {
|
||||
description: 'List linked Devices for the authenticated account.',
|
||||
payload: {
|
||||
authorityRoot: { type: 'string', required: false },
|
||||
principalId: { type: 'string', required: false },
|
||||
busToken: { type: 'string', required: false },
|
||||
includeOffline: { type: 'boolean', required: false },
|
||||
},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: true },
|
||||
devices: { type: 'array', required: true },
|
||||
},
|
||||
},
|
||||
'devices.get': {
|
||||
description: 'Read one linked Device for the authenticated account.',
|
||||
payload: {
|
||||
deviceId: { type: 'string', required: true },
|
||||
},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: true },
|
||||
device: { type: 'object', required: false },
|
||||
},
|
||||
},
|
||||
'devices.revoke': {
|
||||
description: 'Revoke/unlink one account-level Device Link.',
|
||||
payload: {
|
||||
authorityRoot: { type: 'string', required: false },
|
||||
deviceId: { type: 'string', required: true },
|
||||
principalId: { type: 'string', required: false },
|
||||
busToken: { type: 'string', required: false },
|
||||
},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const UserRuntimesContract: ServiceContract = {
|
||||
id: 'matrix.user.runtimes',
|
||||
description: 'Runtime inventory/control-plane service for the active user Space.',
|
||||
ops: {
|
||||
'runtimes.list': {
|
||||
description: 'List runtimes for the active user Space.',
|
||||
payload: {
|
||||
namespaceRoot: { type: 'string', required: false },
|
||||
authorityRoot: { type: 'string', required: false },
|
||||
principalId: { type: 'string', required: false },
|
||||
busToken: { type: 'string', required: false },
|
||||
includeStale: { type: 'boolean', required: false },
|
||||
includeOffline: { type: 'boolean', required: false },
|
||||
},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: true },
|
||||
runtimes: { type: 'array', required: true },
|
||||
},
|
||||
},
|
||||
'runtimes.registered': {
|
||||
description: 'List registered runtimes for the active user Space.',
|
||||
payload: {
|
||||
namespaceRoot: { type: 'string', required: false },
|
||||
authorityRoot: { type: 'string', required: false },
|
||||
includeStale: { type: 'boolean', required: false },
|
||||
},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: true },
|
||||
runtimes: { type: 'array', required: true },
|
||||
},
|
||||
},
|
||||
'$introspect': {
|
||||
description: 'Introspect a runtime control-plane mount.',
|
||||
payload: {
|
||||
depth: { type: 'string', required: false },
|
||||
},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: false },
|
||||
mount: { type: 'string', required: false },
|
||||
children: { type: 'array', required: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const UserAgentsContract: ServiceContract = {
|
||||
id: 'matrix.user.agents',
|
||||
description: 'Agent/session service for the active user Space.',
|
||||
ops: {
|
||||
'$sessionList': {
|
||||
description: 'List agent sessions for the active user Space.',
|
||||
payload: {},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: false },
|
||||
sessions: { type: 'array', required: true },
|
||||
},
|
||||
},
|
||||
'session_state.read': {
|
||||
description: 'Read agent session state.',
|
||||
payload: {
|
||||
sessionId: { type: 'string', required: true },
|
||||
},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: false },
|
||||
state: { type: 'object', required: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const PlatformAuthContract: ServiceContract = {
|
||||
id: 'matrix.platform.auth',
|
||||
description: 'Platform authentication/session service.',
|
||||
ops: {
|
||||
'session.current': {
|
||||
description: 'Read current authenticated platform session.',
|
||||
payload: {},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: true },
|
||||
principal: { type: 'object', required: false },
|
||||
},
|
||||
},
|
||||
'auth.accountDevices.list': {
|
||||
description: 'List account-level registered Devices for the authenticated principal.',
|
||||
payload: {
|
||||
principalId: { type: 'string', required: false },
|
||||
busToken: { type: 'string', required: false },
|
||||
includeGrants: { type: 'boolean', required: false },
|
||||
},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: true },
|
||||
devices: { type: 'array', required: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const PlatformCapabilitiesContract: ServiceContract = {
|
||||
id: 'matrix.platform.capabilities',
|
||||
description: 'Platform capability resolution and grant service.',
|
||||
ops: {
|
||||
'capability.check': {
|
||||
description: 'Check whether the caller has a capability.',
|
||||
payload: {
|
||||
capability: { type: 'string', required: true },
|
||||
},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: true },
|
||||
allowed: { type: 'boolean', required: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
60
packages/core/src/core/CheatDetector.d.ts
vendored
Normal file
60
packages/core/src/core/CheatDetector.d.ts
vendored
Normal file
@ -0,0 +1,60 @@
|
||||
type CheatType = 'P1' | 'P2' | 'P3' | 'P4' | 'P5' | 'P6';
|
||||
interface CheatViolation {
|
||||
type: CheatType;
|
||||
message: string;
|
||||
stack?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
type CheatHandler = (violation: CheatViolation) => void;
|
||||
declare class CheatDetectorImpl {
|
||||
private enabled;
|
||||
private violations;
|
||||
private handlers;
|
||||
private mode;
|
||||
/**
|
||||
* Enable cheat detection.
|
||||
* @param mode - 'warn' logs to console, 'throw' throws error, 'collect' silently collects
|
||||
*/
|
||||
enable(mode?: 'warn' | 'throw' | 'collect'): void;
|
||||
disable(): void;
|
||||
isEnabled(): boolean;
|
||||
/**
|
||||
* Report a cheat violation.
|
||||
*/
|
||||
report(type: CheatType, message: string): void;
|
||||
/**
|
||||
* Get all collected violations.
|
||||
*/
|
||||
getViolations(): CheatViolation[];
|
||||
/**
|
||||
* Clear collected violations.
|
||||
*/
|
||||
clear(): void;
|
||||
/**
|
||||
* Add a violation handler.
|
||||
*/
|
||||
onViolation(handler: CheatHandler): () => void;
|
||||
/**
|
||||
* Assert no violations occurred (for tests).
|
||||
*/
|
||||
assertNoViolations(): void;
|
||||
/**
|
||||
* P1: Wrap runtime to detect _components access.
|
||||
*/
|
||||
wrapRuntime<T extends object>(runtime: T): T;
|
||||
/**
|
||||
* P2: Check if a value is a component reference (should be mount string).
|
||||
*/
|
||||
checkNotComponentRef(value: unknown, context: string): void;
|
||||
/**
|
||||
* P5: Detect _localServices usage.
|
||||
*/
|
||||
reportLocalServiceBypass(mount: string): void;
|
||||
/**
|
||||
* P6: Detect ghost casting.
|
||||
*/
|
||||
reportGhostCasting(shellTag: string): void;
|
||||
}
|
||||
export declare const CheatDetector: CheatDetectorImpl;
|
||||
export {};
|
||||
//# sourceMappingURL=CheatDetector.d.ts.map
|
||||
1
packages/core/src/core/CheatDetector.d.ts.map
Normal file
1
packages/core/src/core/CheatDetector.d.ts.map
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"CheatDetector.d.ts","sourceRoot":"","sources":["CheatDetector.ts"],"names":[],"mappings":"AAaA,KAAK,SAAS,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAEzD,UAAU,cAAc;IACtB,IAAI,EAAE,SAAS,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,KAAK,YAAY,GAAG,CAAC,SAAS,EAAE,cAAc,KAAK,IAAI,CAAC;AAExD,cAAM,iBAAiB;IACrB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,UAAU,CAAwB;IAC1C,OAAO,CAAC,QAAQ,CAAsB;IACtC,OAAO,CAAC,IAAI,CAAwC;IAEpD;;;OAGG;IACH,MAAM,CAAC,IAAI,GAAE,MAAM,GAAG,OAAO,GAAG,SAAkB,GAAG,IAAI;IAMzD,OAAO,IAAI,IAAI;IAIf,SAAS,IAAI,OAAO;IAIpB;;OAEG;IACH,MAAM,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI;IAgC9C;;OAEG;IACH,aAAa,IAAI,cAAc,EAAE;IAIjC;;OAEG;IACH,KAAK,IAAI,IAAI;IAIb;;OAEG;IACH,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,IAAI;IAQ9C;;OAEG;IACH,kBAAkB,IAAI,IAAI;IAa1B;;OAEG;IACH,WAAW,CAAC,CAAC,SAAS,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC;IAa5C;;OAEG;IACH,oBAAoB,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI;IAiB3D;;OAEG;IACH,wBAAwB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAI7C;;OAEG;IACH,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;CAG3C;AAGD,eAAO,MAAM,aAAa,mBAA0B,CAAC"}
|
||||
184
packages/core/src/core/CheatDetector.ts
Normal file
184
packages/core/src/core/CheatDetector.ts
Normal file
@ -0,0 +1,184 @@
|
||||
// src/core/CheatDetector.ts
|
||||
//
|
||||
// Detects architectural violations ("cheats") at runtime.
|
||||
// Enable via: CheatDetector.enable() or MATRIX_STRICT=1 env var
|
||||
//
|
||||
// Cheats detected:
|
||||
// - P1: Accessing runtime._components directly
|
||||
// - P2: Storing component references instead of mount paths
|
||||
// - P3: Using __parent chain
|
||||
// - P4: Accessing children map directly
|
||||
// - P5: Bypassing transport with _localServices
|
||||
// - P6: Casting to get internal component from shell
|
||||
|
||||
type CheatType = 'P1' | 'P2' | 'P3' | 'P4' | 'P5' | 'P6';
|
||||
|
||||
interface CheatViolation {
|
||||
type: CheatType;
|
||||
message: string;
|
||||
stack?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type CheatHandler = (violation: CheatViolation) => void;
|
||||
|
||||
class CheatDetectorImpl {
|
||||
private enabled = false;
|
||||
private violations: CheatViolation[] = [];
|
||||
private handlers: CheatHandler[] = [];
|
||||
private mode: 'warn' | 'throw' | 'collect' = 'warn';
|
||||
|
||||
/**
|
||||
* Enable cheat detection.
|
||||
* @param mode - 'warn' logs to console, 'throw' throws error, 'collect' silently collects
|
||||
*/
|
||||
enable(mode: 'warn' | 'throw' | 'collect' = 'warn'): void {
|
||||
this.enabled = true;
|
||||
this.mode = mode;
|
||||
// eslint-disable-next-line no-console -- developer-facing cheat detector mode announcement in kernel code
|
||||
console.log(`[CheatDetector] Enabled in '${mode}' mode`);
|
||||
}
|
||||
|
||||
disable(): void {
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a cheat violation.
|
||||
*/
|
||||
report(type: CheatType, message: string): void {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const violation: CheatViolation = {
|
||||
type,
|
||||
message,
|
||||
stack: new Error().stack,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.violations.push(violation);
|
||||
|
||||
// Notify handlers
|
||||
for (const handler of this.handlers) {
|
||||
try {
|
||||
handler(violation);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Handle based on mode
|
||||
switch (this.mode) {
|
||||
case 'throw':
|
||||
throw new Error(`[CHEAT DETECTED] ${type}: ${message}`);
|
||||
case 'warn':
|
||||
// eslint-disable-next-line no-console -- cheat detector violations must surface without actor context
|
||||
console.warn(`[CHEAT DETECTED] ${type}: ${message}`);
|
||||
break;
|
||||
case 'collect':
|
||||
// Silent collection
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all collected violations.
|
||||
*/
|
||||
getViolations(): CheatViolation[] {
|
||||
return [...this.violations];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear collected violations.
|
||||
*/
|
||||
clear(): void {
|
||||
this.violations = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a violation handler.
|
||||
*/
|
||||
onViolation(handler: CheatHandler): () => void {
|
||||
this.handlers.push(handler);
|
||||
return () => {
|
||||
const idx = this.handlers.indexOf(handler);
|
||||
if (idx >= 0) this.handlers.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert no violations occurred (for tests).
|
||||
*/
|
||||
assertNoViolations(): void {
|
||||
if (this.violations.length > 0) {
|
||||
const summary = this.violations
|
||||
.map(v => ` ${v.type}: ${v.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`CheatDetector found ${this.violations.length} violation(s):\n${summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Specific Cheat Detectors
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* P1: Wrap runtime to detect _components access.
|
||||
*/
|
||||
wrapRuntime<T extends object>(runtime: T): T {
|
||||
if (!this.enabled) return runtime;
|
||||
|
||||
return new Proxy(runtime, {
|
||||
get: (target, prop, receiver) => {
|
||||
if (prop === '_components') {
|
||||
this.report('P1', 'Direct access to runtime._components');
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* P2: Check if a value is a component reference (should be mount string).
|
||||
*/
|
||||
checkNotComponentRef(value: unknown, context: string): void {
|
||||
if (!this.enabled) return;
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
// Check for MatrixActor-like shape
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof obj.initialize === 'function' ||
|
||||
typeof obj.dispose === 'function' ||
|
||||
typeof obj.onIntrospect === 'function' ||
|
||||
obj._context !== undefined
|
||||
) {
|
||||
this.report('P2', `Storing component reference instead of mount path: ${context}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P5: Detect _localServices usage.
|
||||
*/
|
||||
reportLocalServiceBypass(mount: string): void {
|
||||
this.report('P5', `Bypassing transport with _localServices for: ${mount}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* P6: Detect ghost casting.
|
||||
*/
|
||||
reportGhostCasting(shellTag: string): void {
|
||||
this.report('P6', `Casting shell to get internal component: ${shellTag}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const CheatDetector = new CheatDetectorImpl();
|
||||
|
||||
// Auto-enable if env var set
|
||||
if (typeof process !== 'undefined' && process.env?.MATRIX_STRICT === '1') {
|
||||
CheatDetector.enable('throw');
|
||||
}
|
||||
51
packages/core/src/core/ComponentRegistry.d.ts
vendored
Normal file
51
packages/core/src/core/ComponentRegistry.d.ts
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
import type { IComponentRegistry, ComponentConstructor, ComponentLoader } from './IComponentRegistry';
|
||||
/**
|
||||
* ComponentRegistry - Hierarchical component registry implementation.
|
||||
*
|
||||
* NO SINGLETONS - this is instantiated per-context/per-composite.
|
||||
*
|
||||
* Features:
|
||||
* - Parent chain inheritance (child shadows parent)
|
||||
* - Lazy loading via dynamic imports
|
||||
* - Module path resolution
|
||||
* - Caching of resolved constructors
|
||||
*/
|
||||
export declare class HeadlessComponentRegistry implements IComponentRegistry {
|
||||
private readonly _registrations;
|
||||
private readonly _resolved;
|
||||
readonly parent?: IComponentRegistry;
|
||||
constructor(parent?: IComponentRegistry);
|
||||
/**
|
||||
* Resolve a tag to a component constructor.
|
||||
*/
|
||||
resolve(tag: string): Promise<ComponentConstructor | undefined>;
|
||||
/**
|
||||
* Register a component loader.
|
||||
*/
|
||||
register(tag: string, loader: ComponentLoader): void;
|
||||
/**
|
||||
* Check if tag is registered anywhere in the chain.
|
||||
*/
|
||||
has(tag: string): boolean;
|
||||
/**
|
||||
* List tags registered locally.
|
||||
*/
|
||||
localTags(): string[];
|
||||
/**
|
||||
* List all available tags (local + parent chain).
|
||||
*/
|
||||
allTags(): string[];
|
||||
/**
|
||||
* Create a child registry inheriting from this one.
|
||||
*/
|
||||
createChild(): IComponentRegistry;
|
||||
/**
|
||||
* Resolve a loader to a constructor.
|
||||
*/
|
||||
private _resolveLoader;
|
||||
/**
|
||||
* Create a root registry with no parent.
|
||||
*/
|
||||
static createRoot(): IComponentRegistry;
|
||||
}
|
||||
//# sourceMappingURL=ComponentRegistry.d.ts.map
|
||||
1
packages/core/src/core/ComponentRegistry.d.ts.map
Normal file
1
packages/core/src/core/ComponentRegistry.d.ts.map
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"ComponentRegistry.d.ts","sourceRoot":"","sources":["ComponentRegistry.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,kBAAkB,EAClB,oBAAoB,EACpB,eAAe,EAChB,MAAM,sBAAsB,CAAC;AAU9B;;;;;;;;;;GAUG;AACH,qBAAa,yBAA0B,YAAW,kBAAkB;IAClE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAsC;IACrE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA2C;IACrE,QAAQ,CAAC,MAAM,CAAC,EAAE,kBAAkB,CAAC;gBAEzB,MAAM,CAAC,EAAE,kBAAkB;IAIvC;;OAEG;IACG,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC;IA0BrE;;OAEG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI;IAOpD;;OAEG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAWzB;;OAEG;IACH,SAAS,IAAI,MAAM,EAAE;IAIrB;;OAEG;IACH,OAAO,IAAI,MAAM,EAAE;IAUnB;;OAEG;IACH,WAAW,IAAI,kBAAkB;IAIjC;;OAEG;YACW,cAAc;IA0D5B;;OAEG;IACH,MAAM,CAAC,UAAU,IAAI,kBAAkB;CAGxC"}
|
||||
186
packages/core/src/core/ComponentRegistry.ts
Normal file
186
packages/core/src/core/ComponentRegistry.ts
Normal file
@ -0,0 +1,186 @@
|
||||
// src/core/ComponentRegistry.ts
|
||||
|
||||
import type {
|
||||
IComponentRegistry,
|
||||
ComponentConstructor,
|
||||
ComponentLoader,
|
||||
} from './IComponentRegistry';
|
||||
|
||||
/**
|
||||
* Interface for dynamically imported module result.
|
||||
*/
|
||||
interface IDynamicModule {
|
||||
default?: ComponentConstructor;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* ComponentRegistry - Hierarchical component registry implementation.
|
||||
*
|
||||
* NO SINGLETONS - this is instantiated per-context/per-composite.
|
||||
*
|
||||
* Features:
|
||||
* - Parent chain inheritance (child shadows parent)
|
||||
* - Lazy loading via dynamic imports
|
||||
* - Module path resolution
|
||||
* - Caching of resolved constructors
|
||||
*/
|
||||
export class HeadlessComponentRegistry implements IComponentRegistry {
|
||||
private readonly _registrations = new Map<string, ComponentLoader>();
|
||||
private readonly _resolved = new Map<string, ComponentConstructor>();
|
||||
readonly parent?: IComponentRegistry;
|
||||
|
||||
constructor(parent?: IComponentRegistry) {
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tag to a component constructor.
|
||||
*/
|
||||
async resolve(tag: string): Promise<ComponentConstructor | undefined> {
|
||||
const normalizedTag = tag.toLowerCase();
|
||||
|
||||
// Check local cache first
|
||||
if (this._resolved.has(normalizedTag)) {
|
||||
return this._resolved.get(normalizedTag);
|
||||
}
|
||||
|
||||
// Check local registrations
|
||||
if (this._registrations.has(normalizedTag)) {
|
||||
const loader = this._registrations.get(normalizedTag)!;
|
||||
const ctor = await this._resolveLoader(loader);
|
||||
if (ctor) {
|
||||
this._resolved.set(normalizedTag, ctor);
|
||||
return ctor;
|
||||
}
|
||||
}
|
||||
|
||||
// Check parent chain
|
||||
if (this.parent) {
|
||||
return this.parent.resolve(normalizedTag);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a component loader.
|
||||
*/
|
||||
register(tag: string, loader: ComponentLoader): void {
|
||||
const normalizedTag = tag.toLowerCase();
|
||||
this._registrations.set(normalizedTag, loader);
|
||||
// Clear cache in case it was resolved before
|
||||
this._resolved.delete(normalizedTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tag is registered anywhere in the chain.
|
||||
*/
|
||||
has(tag: string): boolean {
|
||||
const normalizedTag = tag.toLowerCase();
|
||||
if (this._registrations.has(normalizedTag)) {
|
||||
return true;
|
||||
}
|
||||
if (this.parent) {
|
||||
return this.parent.has(normalizedTag);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* List tags registered locally.
|
||||
*/
|
||||
localTags(): string[] {
|
||||
return Array.from(this._registrations.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available tags (local + parent chain).
|
||||
*/
|
||||
allTags(): string[] {
|
||||
const tags = new Set<string>(this._registrations.keys());
|
||||
if (this.parent) {
|
||||
for (const tag of this.parent.allTags()) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
return Array.from(tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child registry inheriting from this one.
|
||||
*/
|
||||
createChild(): IComponentRegistry {
|
||||
return new HeadlessComponentRegistry(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a loader to a constructor.
|
||||
*/
|
||||
private async _resolveLoader(loader: ComponentLoader): Promise<ComponentConstructor | undefined> {
|
||||
// Direct constructor
|
||||
if (typeof loader === 'function' && 'prototype' in loader && loader.prototype) {
|
||||
// Check if it's a class (has prototype with constructor)
|
||||
try {
|
||||
// If it looks like a class constructor (not an arrow function)
|
||||
const loaderProto = loader.prototype as { constructor?: unknown };
|
||||
if (loader.toString().startsWith('class') || loaderProto.constructor === loader) {
|
||||
return loader as ComponentConstructor;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to try as dynamic import
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic import function
|
||||
if (typeof loader === 'function') {
|
||||
try {
|
||||
const result = await (loader as () => Promise<unknown>)();
|
||||
// Handle { default: Constructor } or direct Constructor
|
||||
if (result && typeof result === 'object' && 'default' in result) {
|
||||
const moduleResult = result as { default: unknown };
|
||||
if (typeof moduleResult.default === 'function') {
|
||||
return moduleResult.default as ComponentConstructor;
|
||||
}
|
||||
}
|
||||
if (typeof result === 'function') {
|
||||
return result as ComponentConstructor;
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console -- kernel component loading failures need direct diagnostics
|
||||
console.error(`[ComponentRegistry] Failed to load component:`, err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Module path string
|
||||
if (typeof loader === 'string') {
|
||||
try {
|
||||
const module = await import(loader) as IDynamicModule;
|
||||
if (module.default) {
|
||||
return module.default;
|
||||
}
|
||||
// Try to find first exported class
|
||||
for (const key of Object.keys(module)) {
|
||||
const exported = module[key];
|
||||
if (typeof exported === 'function' && 'prototype' in exported && exported.prototype) {
|
||||
return exported as ComponentConstructor;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console -- kernel module import failures need direct diagnostics
|
||||
console.error(`[ComponentRegistry] Failed to import '${loader}':`, err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a root registry with no parent.
|
||||
*/
|
||||
static createRoot(): IComponentRegistry {
|
||||
return new HeadlessComponentRegistry();
|
||||
}
|
||||
}
|
||||
61
packages/core/src/core/IComponentRegistry.d.ts
vendored
Normal file
61
packages/core/src/core/IComponentRegistry.d.ts
vendored
Normal file
@ -0,0 +1,61 @@
|
||||
import type { MatrixActor } from './MatrixActor';
|
||||
/**
|
||||
* Component constructor type.
|
||||
*/
|
||||
export type ComponentConstructor = new () => MatrixActor;
|
||||
/**
|
||||
* Component loader - can be direct class, dynamic import function, or module path.
|
||||
*/
|
||||
export type ComponentLoader = ComponentConstructor | (() => Promise<ComponentConstructor>) | (() => Promise<{
|
||||
default: ComponentConstructor;
|
||||
}>) | string;
|
||||
/**
|
||||
* IComponentRegistry - Hierarchical component registry interface.
|
||||
*
|
||||
* KEY PRINCIPLE: NO SINGLETONS
|
||||
* - Each composite can have its own registry
|
||||
* - Child registries inherit from parent
|
||||
* - Local registrations shadow parent registrations
|
||||
* - Passed through context, never global
|
||||
*/
|
||||
export interface IComponentRegistry {
|
||||
/**
|
||||
* Resolve a tag name to a component constructor.
|
||||
* Searches local registry first, then parent chain.
|
||||
* May trigger dynamic import if loader is a function/path.
|
||||
*
|
||||
* @param tag - Tag name (e.g., 'my-component')
|
||||
* @returns Component constructor or undefined if not found
|
||||
*/
|
||||
resolve(tag: string): Promise<ComponentConstructor | undefined>;
|
||||
/**
|
||||
* Register a component in this registry.
|
||||
* Available to this registry and all descendants.
|
||||
*
|
||||
* @param tag - Tag name
|
||||
* @param loader - Component class, dynamic import function, or module path
|
||||
*/
|
||||
register(tag: string, loader: ComponentLoader): void;
|
||||
/**
|
||||
* Check if a tag is registered (in this registry or ancestors).
|
||||
*/
|
||||
has(tag: string): boolean;
|
||||
/**
|
||||
* List all tags registered in this registry (not including parent).
|
||||
*/
|
||||
localTags(): string[];
|
||||
/**
|
||||
* List all tags available (including parent chain).
|
||||
*/
|
||||
allTags(): string[];
|
||||
/**
|
||||
* Create a child registry that inherits from this one.
|
||||
* Local registrations in child shadow parent registrations.
|
||||
*/
|
||||
createChild(): IComponentRegistry;
|
||||
/**
|
||||
* Get the parent registry (if any).
|
||||
*/
|
||||
readonly parent?: IComponentRegistry;
|
||||
}
|
||||
//# sourceMappingURL=IComponentRegistry.d.ts.map
|
||||
1
packages/core/src/core/IComponentRegistry.d.ts.map
Normal file
1
packages/core/src/core/IComponentRegistry.d.ts.map
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"IComponentRegistry.d.ts","sourceRoot":"","sources":["IComponentRegistry.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEjD;;GAEG;AACH,MAAM,MAAM,oBAAoB,GAAG,UAAU,WAAW,CAAC;AAEzD;;GAEG;AACH,MAAM,MAAM,eAAe,GACvB,oBAAoB,GACpB,CAAC,MAAM,OAAO,CAAC,oBAAoB,CAAC,CAAC,GACrC,CAAC,MAAM,OAAO,CAAC;IAAE,OAAO,EAAE,oBAAoB,CAAA;CAAE,CAAC,CAAC,GAClD,MAAM,CAAC;AAEX;;;;;;;;GAQG;AACH,MAAM,WAAW,kBAAkB;IACjC;;;;;;;OAOG;IACH,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC,CAAC;IAEhE;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAAC;IAErD;;OAEG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAE1B;;OAEG;IACH,SAAS,IAAI,MAAM,EAAE,CAAC;IAEtB;;OAEG;IACH,OAAO,IAAI,MAAM,EAAE,CAAC;IAEpB;;;OAGG;IACH,WAAW,IAAI,kBAAkB,CAAC;IAElC;;OAEG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,kBAAkB,CAAC;CACtC"}
|
||||
73
packages/core/src/core/IComponentRegistry.ts
Normal file
73
packages/core/src/core/IComponentRegistry.ts
Normal file
@ -0,0 +1,73 @@
|
||||
// src/core/IComponentRegistry.ts
|
||||
|
||||
import type { MatrixActor } from './MatrixActor';
|
||||
|
||||
/**
|
||||
* Component constructor type.
|
||||
*/
|
||||
export type ComponentConstructor = new () => MatrixActor;
|
||||
|
||||
/**
|
||||
* Component loader - can be direct class, dynamic import function, or module path.
|
||||
*/
|
||||
export type ComponentLoader =
|
||||
| ComponentConstructor
|
||||
| (() => Promise<ComponentConstructor>)
|
||||
| (() => Promise<{ default: ComponentConstructor }>)
|
||||
| string;
|
||||
|
||||
/**
|
||||
* IComponentRegistry - Hierarchical component registry interface.
|
||||
*
|
||||
* KEY PRINCIPLE: NO SINGLETONS
|
||||
* - Each composite can have its own registry
|
||||
* - Child registries inherit from parent
|
||||
* - Local registrations shadow parent registrations
|
||||
* - Passed through context, never global
|
||||
*/
|
||||
export interface IComponentRegistry {
|
||||
/**
|
||||
* Resolve a tag name to a component constructor.
|
||||
* Searches local registry first, then parent chain.
|
||||
* May trigger dynamic import if loader is a function/path.
|
||||
*
|
||||
* @param tag - Tag name (e.g., 'my-component')
|
||||
* @returns Component constructor or undefined if not found
|
||||
*/
|
||||
resolve(tag: string): Promise<ComponentConstructor | undefined>;
|
||||
|
||||
/**
|
||||
* Register a component in this registry.
|
||||
* Available to this registry and all descendants.
|
||||
*
|
||||
* @param tag - Tag name
|
||||
* @param loader - Component class, dynamic import function, or module path
|
||||
*/
|
||||
register(tag: string, loader: ComponentLoader): void;
|
||||
|
||||
/**
|
||||
* Check if a tag is registered (in this registry or ancestors).
|
||||
*/
|
||||
has(tag: string): boolean;
|
||||
|
||||
/**
|
||||
* List all tags registered in this registry (not including parent).
|
||||
*/
|
||||
localTags(): string[];
|
||||
|
||||
/**
|
||||
* List all tags available (including parent chain).
|
||||
*/
|
||||
allTags(): string[];
|
||||
|
||||
/**
|
||||
* Create a child registry that inherits from this one.
|
||||
* Local registrations in child shadow parent registrations.
|
||||
*/
|
||||
createChild(): IComponentRegistry;
|
||||
|
||||
/**
|
||||
* Get the parent registry (if any).
|
||||
*/
|
||||
readonly parent?: IComponentRegistry;
|
||||
}
|
||||
35
packages/core/src/core/IRecordStoreProvider.ts
Normal file
35
packages/core/src/core/IRecordStoreProvider.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* IRecordStoreProvider — pluggable per-package record-store factory.
|
||||
*
|
||||
* The runtime registers an implementation on the root context via
|
||||
* RECORD_STORE_PROVIDER_KEY. PackageRootActor reads the provider from its
|
||||
* cascading context and asks for a per-package store at bootstrap time. The
|
||||
* provider returns whatever value the consumer of RECORD_STORE_KEY expects
|
||||
* (today: a database handle; tomorrow: an IRecordStore wrapper or null).
|
||||
*
|
||||
* Core declares the seam. Implementations live in their own packages
|
||||
* (e.g., @open-matrix/system-sqlite). Core never imports a sibling
|
||||
* implementation package.
|
||||
*/
|
||||
|
||||
export interface IRecordStoreProvider {
|
||||
/**
|
||||
* Produce a per-package record store, or return null if unavailable.
|
||||
*
|
||||
* Same observable contract as the legacy inline sqlite call inside
|
||||
* PackageRootActor: same matrixHome resolution, same domain key, same
|
||||
* eventual setService(RECORD_STORE_KEY, value) value shape.
|
||||
*
|
||||
* Implementations must not throw for "no backend available" cases —
|
||||
* return null and let PackageRootActor swallow it identically to the
|
||||
* pre-seam behavior.
|
||||
*/
|
||||
provide(matrixHome: string, domain: string): Promise<unknown | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service-registry key for the IRecordStoreProvider. The runtime registers
|
||||
* the implementation under this key on the root context; PackageRootActor
|
||||
* reads it via context.getService(RECORD_STORE_PROVIDER_KEY).
|
||||
*/
|
||||
export const RECORD_STORE_PROVIDER_KEY = Symbol.for('matrix.record-store.provider');
|
||||
635
packages/core/src/core/MatrixActor.d.ts
vendored
Normal file
635
packages/core/src/core/MatrixActor.d.ts
vendored
Normal file
@ -0,0 +1,635 @@
|
||||
import type { IMatrixContext } from '../engine/core/IMatrixContext';
|
||||
import { SubscriptionBag } from '../engine/messaging/SubscriptionBag';
|
||||
import { ActorBehavior } from './actor/ActorBehavior';
|
||||
import type { IMailbox, IExitSignal } from '../engine/mailbox/IMailbox';
|
||||
import { LamportClock } from '../engine/messaging/LamportClock';
|
||||
import { ChildManager } from './children/ChildManager';
|
||||
import { type ISupervisionPolicy } from './Supervision';
|
||||
import { SupervisorBehavior } from './actor/SupervisorBehavior';
|
||||
import { SecurityRealm, type ISupervisorSecurityPolicy } from './security';
|
||||
import type { ICapabilityToken } from './security/ICapabilityToken';
|
||||
import type { ICompositeIntrospectChild, IComponentTreeDescriptor, Position, IAddChildOptions, IDynamicComponentDefinition } from './composite';
|
||||
export type MessageHandler = (payload: unknown, meta: {
|
||||
topic: string;
|
||||
ts: number;
|
||||
}) => void | Promise<void>;
|
||||
export type ComponentHandler = (payload: unknown, meta: {
|
||||
topic: string;
|
||||
ts: number;
|
||||
}) => unknown | Promise<unknown>;
|
||||
/**
|
||||
* Deep binding descriptor — produced by the DSL compiler.
|
||||
* Maps a descendant event to a handler on the template author's actor.
|
||||
*/
|
||||
export interface DeepBindingDescriptor {
|
||||
/** Dot-delimited descendant path relative to this actor, e.g., 'split.preview.syntax' */
|
||||
relativePath: string;
|
||||
/** Event name, e.g., 'HighlightReady' */
|
||||
event: string;
|
||||
/** Handler method name on this actor, e.g., 'onHighlightReadyFromSyntax' */
|
||||
handler: string;
|
||||
}
|
||||
export type { Position } from './composite';
|
||||
export declare abstract class MatrixActor {
|
||||
/**
|
||||
* Index signature for dynamic handler method access.
|
||||
* Enables type-safe lookup of onFoo handlers by name.
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
/**
|
||||
* Static metadata declaring what DOMAIN commands this actor accepts.
|
||||
* System commands ($join, $leave, introspect, $ping) are auto-subscribed.
|
||||
* Override in subclass.
|
||||
*/
|
||||
static accepts: Record<string, unknown>;
|
||||
/**
|
||||
* Static metadata declaring what events this actor emits.
|
||||
* Override in subclass.
|
||||
*/
|
||||
static emits: Record<string, unknown>;
|
||||
/**
|
||||
* Theme tokens this actor declares support for.
|
||||
* Each key is a token name (without --mx- prefix), value is metadata.
|
||||
* Used by the $tokens handler and surfaced in $introspect / oracle snapshots.
|
||||
* Subclass declarations merge with (and override) parent declarations.
|
||||
*/
|
||||
static tokens: Record<string, {
|
||||
default: string;
|
||||
description: string;
|
||||
}>;
|
||||
/**
|
||||
* Output schemas per op. Describes what each handler returns.
|
||||
* Surfaced via $introspect { depth: 'full' } alongside acceptsSchema.
|
||||
*/
|
||||
static outputSchemas?: Record<string, Record<string, unknown>>;
|
||||
/**
|
||||
* Error specifications per op. Documents known error conditions.
|
||||
* Each entry is an array of { code, description } objects.
|
||||
*/
|
||||
static errorSpecs?: Record<string, Array<{
|
||||
code: string;
|
||||
description: string;
|
||||
}>>;
|
||||
/**
|
||||
* Optional self-documenting examples surfaced via $introspect { depth: 'full' }.
|
||||
* Per-op examples with input/output pairs for AI agent discovery.
|
||||
*/
|
||||
static examples?: Record<string, Array<{
|
||||
input: Record<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
}>>;
|
||||
/**
|
||||
* Optional intent filters for capability-based resolution.
|
||||
*/
|
||||
static intentFilters?: unknown[];
|
||||
/**
|
||||
* Higher-level skill bundles that compose multiple accepts into meaningful actions.
|
||||
* Skills are the primary discovery surface for AI agents (R18).
|
||||
* Each key is a skill name, value describes what it does and which ops it composes.
|
||||
*
|
||||
* See: ARCHITECTURE-SKILLS.md §2
|
||||
*/
|
||||
static skills: Record<string, {
|
||||
description: string;
|
||||
ops?: string[];
|
||||
examples?: Array<{
|
||||
input: Record<string, unknown>;
|
||||
expected?: string;
|
||||
}>;
|
||||
}>;
|
||||
/**
|
||||
* Optional state schema for named type matching.
|
||||
*/
|
||||
static stateSchema?: Record<string, unknown>;
|
||||
/**
|
||||
* Persistence scope for this actor's state.
|
||||
* Determines the KV key prefix used by NatsStateTap and the cascade
|
||||
* resolution order used by BlackboardActor on read.
|
||||
*
|
||||
* Scopes (most specific → least specific):
|
||||
* session — per-session (default). Survives F5 reload.
|
||||
* user — per-user. Survives new sessions. Use for preferences.
|
||||
* page — per-page/tab. Scoped to a single browser page.
|
||||
* workspace — per-workspace. Shared across workspace members.
|
||||
* org — per-organization. Company defaults.
|
||||
* root — per-root. Root-wide shared state.
|
||||
* global — cross-root. Federation-wide.
|
||||
*/
|
||||
static stateScope: 'session' | 'user' | 'page' | 'workspace' | 'org' | 'root' | 'realm' | 'global';
|
||||
/**
|
||||
* Whether this actor's state should be persisted to KV.
|
||||
* Set to false for ephemeral actors (loading spinners, animations).
|
||||
*/
|
||||
static persistState: boolean;
|
||||
/** The injected context */
|
||||
protected _context?: IMatrixContext;
|
||||
/** Optional fact-plane emit capability token. */
|
||||
protected _factCapability: ICapabilityToken | null;
|
||||
/** Subscription cleanup bag */
|
||||
protected _subs: SubscriptionBag;
|
||||
/** Replica mounts for multi-bind state replication */
|
||||
protected _replicaMounts: string[];
|
||||
/** Lamport logical clock for causal message ordering */
|
||||
protected _lamportClock: LamportClock;
|
||||
/** Actor mailbox for exclusive message receiving */
|
||||
protected _mailbox?: IMailbox;
|
||||
/** Composed actor behavior (mailbox + dispatch) */
|
||||
protected _actor?: ActorBehavior;
|
||||
/** LISP dispatch strategy (if using LISP handlers) */
|
||||
private _lispStrategy?;
|
||||
/** Shell metadata injected by MatrixActorHtmlElement for introspect */
|
||||
_shellInfo?: {
|
||||
shellTag: string;
|
||||
moduleUrl?: string;
|
||||
description?: string;
|
||||
};
|
||||
/** Shell element reference — set by MatrixActorHtmlElement during _receiveContext.
|
||||
* Used by $source-code to return the shell's source (LLM-written code) instead of
|
||||
* the inner actor's source (minified DynamicActor). */
|
||||
_shellElement?: unknown;
|
||||
/** Whether the actor has been initialized */
|
||||
private _initialized;
|
||||
/** Whether the actor has been bootstrapped */
|
||||
private _bootstrapped;
|
||||
/** Whether the actor has been disposed */
|
||||
private _disposed;
|
||||
/** Callback for shell/adapter to receive state change notifications (FP-0.5). */
|
||||
_shellUpdateCallback: (() => void) | null;
|
||||
/** Reference to the shell element's ShadowRoot, so schema handlers can access DOM (e.g. iframes). */
|
||||
_shellRoot: ShadowRoot | null;
|
||||
/** Convenience getter for schema handler code: `this.shell.querySelector(...)` */
|
||||
get shell(): ShadowRoot | null;
|
||||
/** Promise that resolves when actor is ready */
|
||||
readonly ready: Promise<void>;
|
||||
private _resolveReady;
|
||||
/** Actor name/id */
|
||||
private _name;
|
||||
/** Child Manager — NOT allocated until first use */
|
||||
protected _children?: ChildManager;
|
||||
/** Supervisor behavior — NOT allocated until first use */
|
||||
protected _supervisor?: SupervisorBehavior;
|
||||
/** Security realm — NOT allocated until first use */
|
||||
protected _securityRealm?: SecurityRealm;
|
||||
/** Child event binding tracking */
|
||||
private _childBindings;
|
||||
/** Monitor references for tracked children */
|
||||
private _monitors;
|
||||
/** Dynamic child metadata */
|
||||
private _dynamicMetadata;
|
||||
/** Observable state store */
|
||||
protected _state: Record<string, unknown>;
|
||||
/** Optional CQRS-style command history for inspector tooling. */
|
||||
protected _commandHistory: Array<{
|
||||
op: string;
|
||||
ts: number;
|
||||
from?: string;
|
||||
}>;
|
||||
/** Optional CQRS-style event history for inspector tooling. */
|
||||
protected _eventHistory: Array<{
|
||||
op: string;
|
||||
ts: number;
|
||||
}>;
|
||||
protected _historyLimit: number;
|
||||
constructor();
|
||||
get name(): string;
|
||||
get mount(): string;
|
||||
get context(): IMatrixContext | undefined;
|
||||
get isInitialized(): boolean;
|
||||
get isReady(): boolean;
|
||||
get isDisposed(): boolean;
|
||||
get mailbox(): IMailbox | undefined;
|
||||
get lamportClock(): LamportClock;
|
||||
get replicaMounts(): readonly string[];
|
||||
get state(): Readonly<Record<string, unknown>>;
|
||||
/** Get the child manager (undefined if no children have been added) */
|
||||
protected get children(): ChildManager | undefined;
|
||||
/**
|
||||
* Initialize the actor with a context.
|
||||
*
|
||||
* @param context - The matrix context
|
||||
* @param name - Optional actor name
|
||||
*/
|
||||
initialize(context: IMatrixContext, name?: string): Promise<void>;
|
||||
/**
|
||||
* Lazy-initialize child management infrastructure.
|
||||
* Called on first addChild() or $join — zero overhead if never used.
|
||||
*/
|
||||
protected _ensureChildManagement(): void;
|
||||
/**
|
||||
* Handle $join handshake from child shells.
|
||||
* Lazy-initializes child management on first join.
|
||||
*/
|
||||
on$join(payload: {
|
||||
id: string;
|
||||
mount: string;
|
||||
componentClass?: string;
|
||||
props?: unknown;
|
||||
}): void;
|
||||
/**
|
||||
* Handle $leave when a child disconnects voluntarily.
|
||||
*/
|
||||
on$leave(_payload: {
|
||||
id: string;
|
||||
mount: string;
|
||||
}): void;
|
||||
/**
|
||||
* Handle $ping liveness check.
|
||||
*/
|
||||
on$ping(_payload?: unknown): {
|
||||
status: string;
|
||||
mount: string;
|
||||
};
|
||||
/**
|
||||
* Handle $join_ack from parent (when this actor is a child).
|
||||
* Stub in Phase 20a-1 — full implementation in MatrixActorHtmlElement.
|
||||
*/
|
||||
on$joinAck(_payload: unknown): void;
|
||||
/**
|
||||
* Handle $reply for request/reply pattern.
|
||||
* Stub in Phase 20a-1 — full implementation in FP-20b.
|
||||
*/
|
||||
on$reply(_payload: unknown): void;
|
||||
/**
|
||||
* Alias for protocol-style $introspect operation.
|
||||
* Supports payload shape: { depth: 'full' }.
|
||||
*/
|
||||
on$introspect(payload?: {
|
||||
depth?: 'basic' | 'full';
|
||||
}): ReturnType<MatrixActor['onIntrospect']>;
|
||||
/**
|
||||
* $source-code — Return this actor's source code via constructor.toString().
|
||||
*/
|
||||
on$sourceCode(): {
|
||||
source: string;
|
||||
mount: string;
|
||||
tag?: string;
|
||||
};
|
||||
/**
|
||||
* System-plane state snapshot for Actor Inspector.
|
||||
*/
|
||||
on$getState(): Record<string, unknown>;
|
||||
/**
|
||||
* System-plane history snapshot for CQRS log viewers.
|
||||
*/
|
||||
on$getHistory(): {
|
||||
commands: Array<{
|
||||
op: string;
|
||||
ts: number;
|
||||
from?: string;
|
||||
}>;
|
||||
events: Array<{
|
||||
op: string;
|
||||
ts: number;
|
||||
}>;
|
||||
};
|
||||
/**
|
||||
* System-plane handler for activity frames pushed via `activityTo`.
|
||||
* Default is no-op. Browser elements and subclasses override for UI feedback.
|
||||
*/
|
||||
on$activity(_payload: unknown): void;
|
||||
/**
|
||||
* G11: Query effective preferences for this actor's mount.
|
||||
* Delegates to system.preferences service via RequestReply.
|
||||
*/
|
||||
on$preferences(payload?: {
|
||||
key?: string;
|
||||
}): Promise<Record<string, unknown>>;
|
||||
/**
|
||||
* G11: Query effective config for this actor's mount.
|
||||
* Delegates to system.config service via RequestReply.
|
||||
*/
|
||||
on$config(payload?: {
|
||||
key?: string;
|
||||
}): Promise<Record<string, unknown>>;
|
||||
/**
|
||||
* Factory: create a child actor from a code string.
|
||||
*
|
||||
* Headless (daemon) path: evals the code into a class, uses addChild()
|
||||
* to create, initialize, and register the child in ChildManager.
|
||||
*
|
||||
* MatrixActorHtmlElement overrides this with DOM concerns (custom element
|
||||
* registration, shadow DOM insertion, versioned tag names).
|
||||
*/
|
||||
onFactoryCreateFromCode(payload: {
|
||||
name: string;
|
||||
code: string;
|
||||
}): Promise<Record<string, unknown>>;
|
||||
/**
|
||||
* factory.load — Load a saved component from ComponentStoreActor.
|
||||
*
|
||||
* Invokes component.get-source on system.component-store,
|
||||
* then delegates to factory.create-from-code.
|
||||
*/
|
||||
onFactoryLoad(payload: {
|
||||
name: string;
|
||||
}): Promise<Record<string, unknown>>;
|
||||
/**
|
||||
* component.save — Extract source from a running child and persist it.
|
||||
*
|
||||
* Finds the child by mount segment, invokes $source-code on it,
|
||||
* then sends component.persist to daemon.system.component-store.
|
||||
*/
|
||||
onComponentSave(payload: {
|
||||
mount: string;
|
||||
name?: string;
|
||||
surface?: 'dom' | 'headless';
|
||||
description?: string;
|
||||
}): Promise<Record<string, unknown>>;
|
||||
/**
|
||||
* Framework op handler: $prompt (FP-0.4).
|
||||
*
|
||||
* Makes any MatrixActor promptable — forwards the prompt to the daemon.
|
||||
* MatrixActorHtmlElement overrides this with a richer implementation
|
||||
* (snapshot tree, session management, blocking mode).
|
||||
*
|
||||
* For dynamically created components this is the minimum needed to
|
||||
* receive and forward a prompt to the agent system.
|
||||
*/
|
||||
on$prompt(payload: Record<string, unknown>): Promise<Record<string, unknown>>;
|
||||
/**
|
||||
* Placeholder protocol hook for dynamic viewer/control creation.
|
||||
* Concrete runtimes may override this behavior.
|
||||
*/
|
||||
on$createControl(payload?: unknown): {
|
||||
ok: false;
|
||||
op: '$createControl';
|
||||
message: string;
|
||||
requested?: unknown;
|
||||
};
|
||||
/**
|
||||
* Introspect handler — returns actor capabilities.
|
||||
* Always available on every actor.
|
||||
*/
|
||||
protected onIntrospect(payload?: {
|
||||
depth?: 'basic' | 'full';
|
||||
}): {
|
||||
mount: string;
|
||||
componentClass: string;
|
||||
accepts: string[];
|
||||
emits: string[];
|
||||
tracks: string[];
|
||||
acceptsSchema: Record<string, unknown>;
|
||||
emitsSchema: Record<string, unknown>;
|
||||
tokens: string[];
|
||||
tokensSchema?: Record<string, {
|
||||
default: string;
|
||||
description: string;
|
||||
}>;
|
||||
children?: Array<ICompositeIntrospectChild>;
|
||||
shellTag?: string;
|
||||
moduleUrl?: string;
|
||||
description?: string;
|
||||
outputSchemas?: Record<string, Record<string, unknown>>;
|
||||
errorSpecs?: Record<string, Array<{
|
||||
code: string;
|
||||
description: string;
|
||||
}>>;
|
||||
examples?: Record<string, Array<{
|
||||
input: Record<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
}>>;
|
||||
testVectors?: unknown[];
|
||||
documentation?: string;
|
||||
intentFilters?: unknown[];
|
||||
skills?: string[];
|
||||
skillsSchema?: Record<string, unknown>;
|
||||
stateSchema?: Record<string, unknown>;
|
||||
stateScope?: string;
|
||||
persistState?: boolean;
|
||||
};
|
||||
/**
|
||||
* Update observable state and emit change events.
|
||||
* Emits $stateChanged with full state, and $propertyChanged per changed key.
|
||||
*/
|
||||
setState(patch: Partial<Record<string, unknown>>): void;
|
||||
/**
|
||||
* Emit an event from this actor.
|
||||
*
|
||||
* Publishes to:
|
||||
* 1. $events topic (MxEnvelope) — new canonical path
|
||||
* 2. Per-op emits topic (legacy enriched payload) — backward compat until 20b-6
|
||||
* 3. Replica mounts (both formats)
|
||||
*/
|
||||
protected emit(event: string, payload?: unknown): void;
|
||||
/**
|
||||
* Send a command to another actor by mount path (internal).
|
||||
*
|
||||
* Publishes MxEnvelope to target's $inbox.
|
||||
*/
|
||||
protected sendTo(targetMount: string, command: string, payload?: unknown): void;
|
||||
/**
|
||||
* Send a command with three calling conventions:
|
||||
*
|
||||
* 1. Direct child: sendCommand('tree', 'ExpandDir', { path: '/src' })
|
||||
* 2. Relative path: sendCommand('./split.tree', 'ExpandDir', { path: '/src' })
|
||||
* 3. Tracked domain (2-arg): sendCommand('ExpandDir', { path: '/src' })
|
||||
* — only supported in MatrixActorHtmlElement (view → tracked domain actor)
|
||||
*
|
||||
* Publishes MxEnvelope to target's $inbox.
|
||||
*/
|
||||
sendCommand(toOrCmd: string, cmdOrData?: string | unknown, data?: unknown): void;
|
||||
/**
|
||||
* Cross-root RPC. Delegates to FederationPeer via transport adapter.
|
||||
*/
|
||||
invoke(rootPath: string, op: string, data?: unknown): Promise<unknown>;
|
||||
/**
|
||||
* Publish a fact into the shared blackboard plane.
|
||||
*
|
||||
* Requires an emit-capable token in `_factCapability`.
|
||||
* Throws synchronously when capability is missing.
|
||||
*/
|
||||
private _isFactDomainLiteralSegment;
|
||||
private _isFactDomainVariableSegment;
|
||||
private _splitFactDomainSegments;
|
||||
private _matchesFactCapabilitySubject;
|
||||
private _canEmitFactDomain;
|
||||
publishFact(domain: string, data: Record<string, unknown>): Promise<number>;
|
||||
/**
|
||||
* Read the latest fact for a domain from the shared fact plane.
|
||||
*/
|
||||
readFact(domain: string): Promise<{
|
||||
data: unknown;
|
||||
revision: number;
|
||||
} | null>;
|
||||
/**
|
||||
* Subscribe to fact updates.
|
||||
*
|
||||
* Uses context-native subscription when available; otherwise falls back to
|
||||
* polling via readFact() to support transport layers without push hooks.
|
||||
*/
|
||||
subscribeFact(domain: string, handler: (data: unknown) => void): () => void;
|
||||
/**
|
||||
* Add a child actor.
|
||||
* Lazy-initializes ChildManager, SupervisorBehavior, SecurityRealm on first call.
|
||||
*
|
||||
* @param id - Unique child identifier
|
||||
* @param ActorClass - Actor class to instantiate
|
||||
* @param options - Slot, position, props, bindings
|
||||
* @returns Mount path of the created child
|
||||
*/
|
||||
addChild<T extends MatrixActor>(id: string, ActorClass: new () => T, options?: IAddChildOptions): Promise<string>;
|
||||
/**
|
||||
* Remove a child actor by id.
|
||||
*/
|
||||
removeChild(id: string): Promise<void>;
|
||||
/**
|
||||
* Get mount path of a child by id.
|
||||
*/
|
||||
getChildMount(id: string): string | undefined;
|
||||
/**
|
||||
* Get all child mount paths in a slot, in order.
|
||||
*/
|
||||
getChildMountsInSlot(slot?: string): string[];
|
||||
/**
|
||||
* Get child ids in order.
|
||||
*/
|
||||
childIds(slot?: string): string[];
|
||||
/**
|
||||
* Alias for childIds().
|
||||
*/
|
||||
getChildIds(slot?: string): string[];
|
||||
/**
|
||||
* Move a child to a new position within its slot.
|
||||
*/
|
||||
moveChild(id: string, position: Position): Promise<void>;
|
||||
/**
|
||||
* Send a command to a child by id (internal convenience).
|
||||
*/
|
||||
protected sendToChild(childId: string, command: string, payload?: unknown): void;
|
||||
/**
|
||||
* Configure supervision policy.
|
||||
*/
|
||||
setSupervisionPolicy(policy: ISupervisionPolicy): void;
|
||||
/**
|
||||
* Configure security policy.
|
||||
*/
|
||||
setSecurityPolicy(policy: ISupervisorSecurityPolicy): void;
|
||||
/**
|
||||
* Configure both supervision and security policies.
|
||||
*/
|
||||
setSecurityRealm(supervisionPolicy: ISupervisionPolicy, securityPolicy: ISupervisorSecurityPolicy): void;
|
||||
/**
|
||||
* Handle an exit signal from a child actor.
|
||||
* Applies supervision policy: restart, stop, escalate, or ignore.
|
||||
*/
|
||||
handleChildExit(signal: IExitSignal): Promise<void>;
|
||||
/**
|
||||
* Wire bindings from the template to ANY descendant's events.
|
||||
* Used by the DSL compiler to connect template-level event bindings
|
||||
* to handler methods on the template author's actor.
|
||||
*
|
||||
* Unlike _subscribeToChildEvents() (direct children, handler cascade),
|
||||
* deep bindings use EXPLICIT handler mapping only — no cascade.
|
||||
*/
|
||||
_wireDeepBindings(bindings: DeepBindingDescriptor[]): void;
|
||||
/**
|
||||
* Protocol handler for addDynamicChild command.
|
||||
*/
|
||||
onAddDynamicChild(params: IDynamicComponentDefinition): Promise<{
|
||||
mount: string;
|
||||
adapterId: string;
|
||||
shellTag: string;
|
||||
}>;
|
||||
/**
|
||||
* Protocol handler for listDynamicChildren command.
|
||||
*/
|
||||
onListDynamicChildren(): Array<{
|
||||
id: string;
|
||||
mount: string;
|
||||
shellTag?: string;
|
||||
codeHash?: string;
|
||||
createdAt?: number;
|
||||
}>;
|
||||
/**
|
||||
* Protocol handler for removeDynamicChild command.
|
||||
*/
|
||||
onRemoveDynamicChild(params: {
|
||||
id: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
}>;
|
||||
/**
|
||||
* Add a dynamic child from code at runtime.
|
||||
*/
|
||||
addDynamicChild(params: IDynamicComponentDefinition): Promise<{
|
||||
mount: string;
|
||||
adapterId: string;
|
||||
shellTag: string;
|
||||
}>;
|
||||
/**
|
||||
* List all dynamic children.
|
||||
*/
|
||||
listDynamicChildren(): Array<{
|
||||
id: string;
|
||||
mount: string;
|
||||
shellTag?: string;
|
||||
codeHash?: string;
|
||||
createdAt?: number;
|
||||
}>;
|
||||
/**
|
||||
* Serialize the component tree to a descriptor.
|
||||
*/
|
||||
toDescriptor(): IComponentTreeDescriptor;
|
||||
/**
|
||||
* Enable actor mode — serialized message processing via mailbox.
|
||||
*/
|
||||
protected enableActorMode(): void;
|
||||
/**
|
||||
* Add a replica binding for state replication.
|
||||
*/
|
||||
addReplicaBind(remoteMount: string): Promise<void>;
|
||||
/**
|
||||
* Remove a replica binding.
|
||||
*/
|
||||
removeReplicaBind(remoteMount: string): void;
|
||||
/** Override for initialization logic. */
|
||||
protected onBootstrap(): Promise<void>;
|
||||
/** Override for cleanup logic. */
|
||||
protected onDispose(): void;
|
||||
/** Hook called when context changes (remount). */
|
||||
protected _onContextChanged(): Promise<void>;
|
||||
/**
|
||||
* Dispose the actor and clean up all resources.
|
||||
*/
|
||||
dispose(): void;
|
||||
private _bootstrap;
|
||||
/**
|
||||
* Subscribe to inbound messages via $inbox topic.
|
||||
*
|
||||
* Builds a dispatch table from static accepts + system commands.
|
||||
* Routes all inbound MxEnvelopes through a unified dispatch function.
|
||||
*
|
||||
* Single subscription: mount/$inbox — receives MxEnvelope format.
|
||||
*/
|
||||
private _subscribeToInbox;
|
||||
/**
|
||||
* Subscribe to events from a child actor.
|
||||
* Uses ComponentClass.emits to know what topics to subscribe to.
|
||||
*
|
||||
* Handler resolution order:
|
||||
* 1. Declarative binding from options.bindings (highest priority)
|
||||
* 2. Specific: onEventFromChildId
|
||||
* 3. Generic: onEvent
|
||||
*/
|
||||
private _subscribeToChildEvents;
|
||||
/**
|
||||
* Subscribe to child events using stored metadata (for remount).
|
||||
*/
|
||||
private _subscribeToChildEventsFromMeta;
|
||||
/**
|
||||
* Subscribe to exit signals from a child actor.
|
||||
*/
|
||||
private _monitorChild;
|
||||
/**
|
||||
* Remount all children with new derived contexts.
|
||||
*/
|
||||
protected _remountChildren(): Promise<void>;
|
||||
private _unbindAll;
|
||||
private _toSerializable;
|
||||
private _pushBoundedHistory;
|
||||
private _attachLamportClock;
|
||||
private _receiveLamportClock;
|
||||
private _compileDynamicComponent;
|
||||
private _hashCode;
|
||||
}
|
||||
//# sourceMappingURL=MatrixActor.d.ts.map
|
||||
1
packages/core/src/core/MatrixActor.d.ts.map
Normal file
1
packages/core/src/core/MatrixActor.d.ts.map
Normal file
File diff suppressed because one or more lines are too long
3915
packages/core/src/core/MatrixActor.ts
Normal file
3915
packages/core/src/core/MatrixActor.ts
Normal file
File diff suppressed because it is too large
Load Diff
87
packages/core/src/core/MockRuntime.d.ts
vendored
Normal file
87
packages/core/src/core/MockRuntime.d.ts
vendored
Normal file
@ -0,0 +1,87 @@
|
||||
import type { IMatrixContext } from '../engine/core/IMatrixContext';
|
||||
import type { IMatrixRuntime } from '../engine/core/IMatrixRuntime';
|
||||
import type { IComponentDescriptor } from '../engine/core/IComponentDescriptor';
|
||||
import type { IChildDescriptor } from '../engine/core/IChildDescriptor';
|
||||
import type { IExternalAdapterOptions } from './composite/IExternalAdapterOptions';
|
||||
import { MatrixActor } from './MatrixActor';
|
||||
import type { IMailbox } from '../engine/mailbox/IMailbox';
|
||||
/**
|
||||
* Component tracking info (matches MatrixRuntime structure).
|
||||
*/
|
||||
interface ComponentInfo {
|
||||
id: string;
|
||||
component: MatrixActor;
|
||||
mount: string;
|
||||
}
|
||||
/**
|
||||
* MockRuntime - Minimal runtime for testing.
|
||||
*
|
||||
* Implements IMatrixRuntime with minimal functionality for unit tests
|
||||
* that don't need full runtime features.
|
||||
*
|
||||
* Uses same internal structure as MatrixRuntime (_components Map)
|
||||
* so MatrixActor can access tracked components.
|
||||
*
|
||||
* Usage in tests:
|
||||
* ```typescript
|
||||
* const runtime = new MockRuntime();
|
||||
* const context = MatrixContext.create('test', transport, runtime);
|
||||
* ```
|
||||
*/
|
||||
export declare class MockRuntime implements IMatrixRuntime {
|
||||
readonly type: 'headless' | 'browser';
|
||||
readonly supportsDom: boolean;
|
||||
readonly _components: Map<string, ComponentInfo>;
|
||||
private readonly _mailboxes;
|
||||
createChild(parentContext: IMatrixContext, id: string, descriptor: IComponentDescriptor): Promise<string>;
|
||||
parseTemplate(_html: string): Promise<IChildDescriptor[]>;
|
||||
/**
|
||||
* Create an exclusive mailbox for a component (IMatrixRuntime interface).
|
||||
*/
|
||||
createMailbox(mount: string): IMailbox;
|
||||
/**
|
||||
* Get a component by mount path.
|
||||
* @deprecated This is a CHEAT - actors should use transport for communication.
|
||||
* Use RequestReply.execute() or sendTo() instead of getting direct references.
|
||||
* This method exists only for framework/testing infrastructure.
|
||||
*/
|
||||
getComponent<T extends MatrixActor>(mount: string): T | undefined;
|
||||
/**
|
||||
* Check if a component exists by mount path.
|
||||
* This is the PROPER way to check existence without getting a reference.
|
||||
*/
|
||||
hasComponent(mount: string): boolean;
|
||||
/**
|
||||
* Remove and dispose a component.
|
||||
*/
|
||||
remove(mount: string): Promise<void>;
|
||||
/**
|
||||
* Register an external adapter (no-op for mock).
|
||||
*
|
||||
* Mock runtime doesn't support DOM or visual adapters.
|
||||
* Returns undefined to indicate no adapter was created.
|
||||
*/
|
||||
registerExternalAdapter(_id: string, _ComponentClass: new () => MatrixActor, _options?: IExternalAdapterOptions): string | undefined;
|
||||
/**
|
||||
* Unregister an external adapter (no-op for mock).
|
||||
*/
|
||||
unregisterExternalAdapter(_tag: string): void;
|
||||
/**
|
||||
* Check if an external adapter is registered (always false for mock).
|
||||
*/
|
||||
hasExternalAdapter(_tag: string): boolean;
|
||||
/**
|
||||
* Check if dynamic component compilation is allowed.
|
||||
*
|
||||
* MockRuntime is for testing, so always allows dynamic compilation.
|
||||
*
|
||||
* @returns true (always for mock/testing)
|
||||
*/
|
||||
isDynamicCompilationAllowed(): boolean;
|
||||
/**
|
||||
* Clear all tracked components.
|
||||
*/
|
||||
clear(): void;
|
||||
}
|
||||
export {};
|
||||
//# sourceMappingURL=MockRuntime.d.ts.map
|
||||
1
packages/core/src/core/MockRuntime.d.ts.map
Normal file
1
packages/core/src/core/MockRuntime.d.ts.map
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"MockRuntime.d.ts","sourceRoot":"","sources":["MockRuntime.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AACpE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AACpE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,qCAAqC,CAAC;AAChF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AACxE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,qCAAqC,CAAC;AACnF,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE5C,OAAO,KAAK,EAAE,QAAQ,EAAiB,MAAM,4BAA4B,CAAC;AAE1E;;GAEG;AACH,UAAU,aAAa;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,EAAE,WAAW,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;CACf;AA+DD;;;;;;;;;;;;;;GAcG;AACH,qBAAa,WAAY,YAAW,cAAc;IAChD,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,SAAS,CAAc;IACnD,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAS;IAGtC,QAAQ,CAAC,WAAW,6BAAoC;IAGxD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAkC;IAEvD,WAAW,CACf,aAAa,EAAE,cAAc,EAC7B,EAAE,EAAE,MAAM,EACV,UAAU,EAAE,oBAAoB,GAC/B,OAAO,CAAC,MAAM,CAAC;IA0BZ,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAI/D;;OAEG;IACH,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,QAAQ;IAUtC;;;;;OAKG;IACH,YAAY,CAAC,CAAC,SAAS,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAMjE;;;OAGG;IACH,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO;IAIpC;;OAEG;IACG,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAmB1C;;;;;OAKG;IACH,uBAAuB,CACrB,GAAG,EAAE,MAAM,EACX,eAAe,EAAE,UAAU,WAAW,EACtC,QAAQ,CAAC,EAAE,uBAAuB,GACjC,MAAM,GAAG,SAAS;IAIrB;;OAEG;IACH,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAI7C;;OAEG;IACH,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAQzC;;;;;;OAMG;IACH,2BAA2B,IAAI,OAAO;IAOtC;;OAEG;IACH,KAAK,IAAI,IAAI;CAGd"}
|
||||
249
packages/core/src/core/MockRuntime.ts
Normal file
249
packages/core/src/core/MockRuntime.ts
Normal file
@ -0,0 +1,249 @@
|
||||
// src/core/MockRuntime.ts
|
||||
|
||||
import type { IMatrixContext } from '../engine/core/IMatrixContext';
|
||||
import type { IMatrixRuntime } from '../engine/core/IMatrixRuntime';
|
||||
import type { IComponentDescriptor } from '../engine/core/IComponentDescriptor';
|
||||
import type { IChildDescriptor } from '../engine/core/IChildDescriptor';
|
||||
import type { IExternalAdapterOptions } from './composite/IExternalAdapterOptions';
|
||||
import { MatrixActor } from './MatrixActor';
|
||||
import { CheatDetector } from './CheatDetector';
|
||||
import type { IMailbox, IActorMessage } from '../engine/mailbox/IMailbox';
|
||||
|
||||
/**
|
||||
* Component tracking info (matches MatrixRuntime structure).
|
||||
*/
|
||||
interface ComponentInfo {
|
||||
id: string;
|
||||
component: MatrixActor;
|
||||
mount: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple mock mailbox for testing.
|
||||
*/
|
||||
class MockMailbox implements IMailbox {
|
||||
readonly address: string;
|
||||
private _queue: IActorMessage[] = [];
|
||||
private _claimed = false;
|
||||
private _closed = false;
|
||||
|
||||
constructor(address: string) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
get depth(): number { return this._queue.length; }
|
||||
get closed(): boolean { return this._closed; }
|
||||
get claimed(): boolean { return this._claimed; }
|
||||
|
||||
claim(): void {
|
||||
if (this._closed) throw new Error('Mailbox is closed');
|
||||
if (this._claimed) throw new Error('Mailbox already claimed');
|
||||
this._claimed = true;
|
||||
}
|
||||
|
||||
send(message: IActorMessage): void {
|
||||
if (this._closed) return;
|
||||
this._queue.push(message);
|
||||
}
|
||||
|
||||
async receive(): Promise<IActorMessage> {
|
||||
if (!this._claimed) throw new Error('Mailbox not claimed');
|
||||
if (this._closed) throw new Error('Mailbox closed');
|
||||
// Simple blocking behavior for mock
|
||||
while (this._queue.length === 0 && !this._closed) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
if (this._closed) throw new Error('Mailbox closed');
|
||||
return this._queue.shift()!;
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterableIterator<IActorMessage> {
|
||||
const self = this;
|
||||
return {
|
||||
async next(): Promise<IteratorResult<IActorMessage>> {
|
||||
if (self._closed) return { done: true, value: undefined };
|
||||
try {
|
||||
const value = await self.receive();
|
||||
return { done: false, value };
|
||||
} catch {
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
},
|
||||
[Symbol.asyncIterator]() { return this; },
|
||||
};
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this._closed = true;
|
||||
this._queue = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MockRuntime - Minimal runtime for testing.
|
||||
*
|
||||
* Implements IMatrixRuntime with minimal functionality for unit tests
|
||||
* that don't need full runtime features.
|
||||
*
|
||||
* Uses same internal structure as MatrixRuntime (_components Map)
|
||||
* so MatrixActor can access tracked components.
|
||||
*
|
||||
* Usage in tests:
|
||||
* ```typescript
|
||||
* const runtime = new MockRuntime();
|
||||
* const context = MatrixContext.create('test', transport, runtime);
|
||||
* ```
|
||||
*/
|
||||
export class MockRuntime implements IMatrixRuntime {
|
||||
readonly type: 'headless' | 'browser' = 'headless';
|
||||
readonly supportsDom: boolean = false;
|
||||
|
||||
// Track created components - uses _components to match MatrixRuntime
|
||||
readonly _components = new Map<string, ComponentInfo>();
|
||||
|
||||
// Track mailboxes for actor communication
|
||||
private readonly _mailboxes = new Map<string, MockMailbox>();
|
||||
|
||||
async createChild(
|
||||
parentContext: IMatrixContext,
|
||||
id: string,
|
||||
descriptor: IComponentDescriptor
|
||||
): Promise<string> {
|
||||
const { componentClass } = descriptor;
|
||||
// NOTE: props are NOT directly assigned to component!
|
||||
// Props flow through lifecycle.childAdded event → shell._initialProps → handler
|
||||
// This maintains the architecture where all data flows through transport.
|
||||
|
||||
// Create the component
|
||||
const component = new componentClass() as MatrixActor;
|
||||
|
||||
// Derive context
|
||||
const childContext = parentContext.derive(id);
|
||||
|
||||
// Initialize
|
||||
await component.initialize(childContext, id);
|
||||
|
||||
// Track with same structure as MatrixRuntime
|
||||
const info: ComponentInfo = {
|
||||
id,
|
||||
component,
|
||||
mount: childContext.mount,
|
||||
};
|
||||
this._components.set(childContext.mount, info);
|
||||
|
||||
return childContext.mount;
|
||||
}
|
||||
|
||||
async parseTemplate(_html: string): Promise<IChildDescriptor[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an exclusive mailbox for a component (IMatrixRuntime interface).
|
||||
*/
|
||||
createMailbox(mount: string): IMailbox {
|
||||
const address = `${mount}.$inbox`;
|
||||
if (this._mailboxes.has(address)) {
|
||||
return this._mailboxes.get(address)!;
|
||||
}
|
||||
const mailbox = new MockMailbox(address);
|
||||
this._mailboxes.set(address, mailbox);
|
||||
return mailbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a component by mount path.
|
||||
* @deprecated This is a CHEAT - actors should use transport for communication.
|
||||
* Use RequestReply.execute() or sendTo() instead of getting direct references.
|
||||
* This method exists only for framework/testing infrastructure.
|
||||
*/
|
||||
getComponent<T extends MatrixActor>(mount: string): T | undefined {
|
||||
// Report cheat if enabled
|
||||
CheatDetector.report('P1', `MockRuntime.getComponent accessing component directly: ${mount}`);
|
||||
return this._components.get(mount)?.component as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a component exists by mount path.
|
||||
* This is the PROPER way to check existence without getting a reference.
|
||||
*/
|
||||
hasComponent(mount: string): boolean {
|
||||
return this._components.has(mount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and dispose a component.
|
||||
*/
|
||||
async remove(mount: string): Promise<void> {
|
||||
const info = this._components.get(mount);
|
||||
if (!info) return;
|
||||
|
||||
try {
|
||||
info.component.dispose();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console -- mock runtime cleanup failures need direct test diagnostics
|
||||
console.error(`[MockRuntime] Error disposing component:`, err);
|
||||
}
|
||||
|
||||
this._components.delete(mount);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EXTERNAL ADAPTER PATTERN (No-op for mock)
|
||||
// Mock runtime doesn't support external visual adapters.
|
||||
// See: Vision/33-EXTERNAL-ADAPTER-PATTERN.md
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Register an external adapter (no-op for mock).
|
||||
*
|
||||
* Mock runtime doesn't support DOM or visual adapters.
|
||||
* Returns undefined to indicate no adapter was created.
|
||||
*/
|
||||
registerExternalAdapter(
|
||||
_id: string,
|
||||
_ComponentClass: new () => MatrixActor,
|
||||
_options?: IExternalAdapterOptions
|
||||
): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister an external adapter (no-op for mock).
|
||||
*/
|
||||
unregisterExternalAdapter(_tag: string): void {
|
||||
// No-op: mock runtime doesn't support external adapters
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an external adapter is registered (always false for mock).
|
||||
*/
|
||||
hasExternalAdapter(_tag: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SECURITY POLICY
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Check if dynamic component compilation is allowed.
|
||||
*
|
||||
* MockRuntime is for testing, so always allows dynamic compilation.
|
||||
*
|
||||
* @returns true (always for mock/testing)
|
||||
*/
|
||||
isDynamicCompilationAllowed(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// P1: _getChildComponentInternal DELETED
|
||||
// Use transport-based communication instead
|
||||
|
||||
/**
|
||||
* Clear all tracked components.
|
||||
*/
|
||||
clear(): void {
|
||||
this._components.clear();
|
||||
}
|
||||
}
|
||||
226
packages/core/src/core/PackageRootActor.ts
Normal file
226
packages/core/src/core/PackageRootActor.ts
Normal file
@ -0,0 +1,226 @@
|
||||
/**
|
||||
* PackageRootActor — base class for package root actors.
|
||||
*
|
||||
* Like a NestJS Module: owns a DI scope, declares children (providers),
|
||||
* manages lifecycle, provides shared resources (storage, config).
|
||||
*
|
||||
* The root actor mounts at the package namespace (e.g., system.observability).
|
||||
* Children mount under it (e.g., system.observability.logging). Children
|
||||
* inherit the root's context via cascade — if root registers a store,
|
||||
* children find it automatically.
|
||||
*
|
||||
* See: ARCHITECTURE/ARCHITECTURE-PACKAGE-ACTORS.md
|
||||
*/
|
||||
|
||||
import { MatrixActor, type ActorStatusSnapshotV1 } from './MatrixActor';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PackageManifestProps {
|
||||
/** Components to spawn as children. */
|
||||
components?: Array<{
|
||||
type: string;
|
||||
mount: string;
|
||||
props?: Record<string, unknown>;
|
||||
}>;
|
||||
/** Storage configuration from manifest. */
|
||||
storage?: {
|
||||
sqlite?: {
|
||||
domain: string;
|
||||
schema?: string;
|
||||
};
|
||||
};
|
||||
/** Package metadata. */
|
||||
packageName?: string;
|
||||
packageVersion?: string;
|
||||
}
|
||||
|
||||
// ── Base Class ───────────────────────────────────────────────────────────
|
||||
|
||||
export class PackageRootActor extends MatrixActor {
|
||||
/** Override in subclass: npm package name. */
|
||||
static package = '';
|
||||
/** Override in subclass: bus namespace (root mount path). */
|
||||
static namespace = '';
|
||||
|
||||
static override accepts = {
|
||||
'$children': { description: 'List child actors in this package' },
|
||||
'$restart': { description: 'Restart all children' },
|
||||
'$status': { description: 'Package health + storage stats' },
|
||||
};
|
||||
|
||||
protected _manifestProps: PackageManifestProps = {};
|
||||
private _childMounts: string[] = [];
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
override async onBootstrap(): Promise<void> {
|
||||
// Props are passed by PackageLoader from the manifest
|
||||
this._manifestProps = ((this as unknown as { _props?: PackageManifestProps })._props) ?? {};
|
||||
|
||||
// 1. Initialize package-scoped storage (if declared)
|
||||
await this._initStorage();
|
||||
|
||||
// 2. Spawn children declared in manifest
|
||||
await this._spawnChildren();
|
||||
}
|
||||
|
||||
override onDispose(): void {
|
||||
// Children are auto-disposed by the actor tree (ChildManager)
|
||||
this._childMounts = [];
|
||||
}
|
||||
|
||||
// ── Storage ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create package-scoped storage and register on this context.
|
||||
* Children find it via cascade — they call context.getService(RECORD_STORE_KEY)
|
||||
* and walk up to THIS context, finding the package's store (not the global one).
|
||||
*/
|
||||
private async _initStorage(): Promise<void> {
|
||||
const storage = this._manifestProps.storage?.sqlite;
|
||||
if (!storage) return;
|
||||
|
||||
// Ask system.sqlite to ensure the domain's database exists
|
||||
try {
|
||||
const { RequestReply } = await import('../engine/remoting/RequestReply');
|
||||
await RequestReply.execute(
|
||||
this._context!, 'system.sqlite', 'sqlite.init-schema',
|
||||
{
|
||||
domain: storage.domain,
|
||||
schema: storage.schema || '',
|
||||
packageName: this._manifestProps.packageName,
|
||||
},
|
||||
{ timeoutMs: 10000 },
|
||||
);
|
||||
} catch {
|
||||
// system.sqlite may not be available yet — storage init deferred
|
||||
}
|
||||
|
||||
// Create in-process per-package store via the registered provider seam
|
||||
// (RECORD_STORE_PROVIDER_KEY) and register the result on THIS context
|
||||
// so children inherit via cascade. The runtime owns provider registration;
|
||||
// core never reaches into a sibling implementation package.
|
||||
try {
|
||||
const { RECORD_STORE_PROVIDER_KEY } = await import('./IRecordStoreProvider');
|
||||
const { RECORD_STORE_KEY } = await import('../framework/InteractionRecord');
|
||||
|
||||
const provider = this._context!.getService(RECORD_STORE_PROVIDER_KEY) as
|
||||
| { provide(matrixHome: string, domain: string): Promise<unknown | null> }
|
||||
| undefined;
|
||||
if (!provider || typeof provider.provide !== 'function') {
|
||||
// No provider registered — storage deferred (matches pre-seam behavior
|
||||
// where missing sqlite was swallowed by the surrounding try/catch).
|
||||
return;
|
||||
}
|
||||
|
||||
const { join } = await import('node:path');
|
||||
const matrixHome = process.env.MATRIX_HOME || join(process.cwd(), '.matrix');
|
||||
|
||||
const store = await provider.provide(matrixHome, storage.domain);
|
||||
if (store == null) {
|
||||
// Provider declined (e.g., no SQLite backend on this host) — defer.
|
||||
return;
|
||||
}
|
||||
|
||||
this._context!.setService(RECORD_STORE_KEY, store);
|
||||
} catch {
|
||||
// Provider lookup or invocation failed — storage deferred (same as
|
||||
// the pre-seam swallow on dynamic-import failure).
|
||||
}
|
||||
}
|
||||
|
||||
// ── Child Spawning ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Spawn children declared in the manifest. Each child mounts as
|
||||
* namespace.childMount (e.g., system.observability.logging).
|
||||
*/
|
||||
private async _spawnChildren(): Promise<void> {
|
||||
const components = this._manifestProps.components ?? [];
|
||||
const registry = this._context?.runtime as unknown as {
|
||||
_componentRegistry?: { resolve(type: string): (new () => MatrixActor) | undefined };
|
||||
};
|
||||
|
||||
for (const component of components) {
|
||||
try {
|
||||
// Resolve the component class from the registry
|
||||
const ActorClass = registry?._componentRegistry?.resolve(component.type);
|
||||
if (!ActorClass) {
|
||||
continue; // Type not registered — skip silently
|
||||
}
|
||||
|
||||
// Mount as child: component.mount is relative (e.g., "logging")
|
||||
const childMount = await this.addChild(
|
||||
component.mount,
|
||||
ActorClass as new () => MatrixActor,
|
||||
{ props: component.props },
|
||||
);
|
||||
this._childMounts.push(childMount);
|
||||
} catch {
|
||||
// Child failed to mount — continue with others
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** $children — list package contents (like Assembly.GetTypes()). */
|
||||
on$children() {
|
||||
const ctor = this.constructor as typeof PackageRootActor;
|
||||
return {
|
||||
ok: true,
|
||||
package: ctor.package,
|
||||
namespace: ctor.namespace,
|
||||
children: this._childMounts,
|
||||
count: this._childMounts.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** $restart — dispose all children and re-spawn from manifest. */
|
||||
async on$restart() {
|
||||
// Dispose existing children
|
||||
if (this._children) {
|
||||
for (const mount of [...this._childMounts]) {
|
||||
try {
|
||||
const childId = mount.split('.').pop() || mount;
|
||||
this._children.removeChild(childId);
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
this._childMounts = [];
|
||||
|
||||
// Re-spawn
|
||||
await this._spawnChildren();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
restarted: true,
|
||||
children: this._childMounts,
|
||||
count: this._childMounts.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** $status — package health + storage stats. */
|
||||
on$status(): ActorStatusSnapshotV1 & {
|
||||
ok: true;
|
||||
package: string;
|
||||
namespace: string;
|
||||
version: string | undefined;
|
||||
children: number;
|
||||
storage: string | null;
|
||||
} {
|
||||
const ctor = this.constructor as typeof PackageRootActor;
|
||||
const base = super.on$status();
|
||||
return {
|
||||
...base,
|
||||
ok: true,
|
||||
kind: 'package-root',
|
||||
package: ctor.package,
|
||||
namespace: ctor.namespace,
|
||||
version: this._manifestProps.packageVersion,
|
||||
children: this._childMounts.length,
|
||||
storage: this._manifestProps.storage?.sqlite?.domain ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
285
packages/core/src/core/RootActor.ts
Normal file
285
packages/core/src/core/RootActor.ts
Normal file
@ -0,0 +1,285 @@
|
||||
/**
|
||||
* RootActor — the runtime's public face in the actor tree.
|
||||
*
|
||||
* Sits at mount "" (empty string = root). Makes the runtime addressable,
|
||||
* introspectable, and uniform with every other actor in the hierarchy.
|
||||
*
|
||||
* Unix analog: init (PID 1) — first process, supervises all top-level services.
|
||||
*
|
||||
* This actor does NOT replace MatrixRuntime — it wraps it, providing the
|
||||
* actor-model interface on top of the runtime's internal machinery.
|
||||
*/
|
||||
import { MatrixActor, type ActorStatusSnapshotV1 } from './MatrixActor.js';
|
||||
import type { IComponentRuntimeMetadata } from './composite/IComponentRuntimeMetadata.js';
|
||||
|
||||
type RuntimeMetaContext = {
|
||||
realm?: string;
|
||||
};
|
||||
|
||||
type RuntimeRegisteredDeploymentInstance = {
|
||||
id: string;
|
||||
packageName: string;
|
||||
mount: string;
|
||||
rootKind: string;
|
||||
class: string;
|
||||
state: string;
|
||||
packageRevision?: string;
|
||||
componentType?: string;
|
||||
};
|
||||
|
||||
type RuntimeRegisteredPackageRequirement = {
|
||||
packageName: string;
|
||||
revision?: string;
|
||||
sourceOverride?: string;
|
||||
};
|
||||
|
||||
type RuntimeRegisteredSnapshot = {
|
||||
componentTags: string[];
|
||||
supervisedRoots: string[];
|
||||
requiredPackages?: RuntimeRegisteredPackageRequirement[];
|
||||
deploymentInstances?: RuntimeRegisteredDeploymentInstance[];
|
||||
};
|
||||
|
||||
export class RootActor extends MatrixActor {
|
||||
|
||||
static accepts = {
|
||||
'$children': { description: 'List running child actors under this runtime' },
|
||||
'$status': { description: 'Get runtime health and inventory summary' },
|
||||
'runtime.status': { description: 'Get full runtime introspection data', options: 'object?' },
|
||||
'runtime.services': { description: 'List registered service endpoints', options: 'object?' },
|
||||
'runtime.actors': { description: 'List all mounted actors with status', options: 'object?' },
|
||||
'runtime.registered': {
|
||||
description: 'List runtime-registered component tags, supervised roots, required packages, and deployment instances',
|
||||
options: 'object?',
|
||||
},
|
||||
'runtime.start': { mount: 'string' },
|
||||
'runtime.stop': { mount: 'string' },
|
||||
'runtime.reload': { mount: 'string' },
|
||||
'runtime.remove': { mount: 'string' },
|
||||
'runtime.restart': { mount: 'string' },
|
||||
};
|
||||
|
||||
static description = 'Root actor — the runtime itself. Mount: "" (empty string).';
|
||||
|
||||
private _startTime = Date.now();
|
||||
|
||||
/**
|
||||
* Snapshot of all component mounts known to the runtime.
|
||||
* Set externally by MatrixRuntime after construction.
|
||||
*/
|
||||
_componentSnapshot: () => Array<{ id: string; mount: string; metadata?: IComponentRuntimeMetadata }> = () => [];
|
||||
|
||||
/**
|
||||
* Service registry snapshot — returns registered service keys.
|
||||
* Set externally by MatrixRuntime.
|
||||
*/
|
||||
_serviceSnapshot: () => Record<string, string> = () => ({});
|
||||
|
||||
/**
|
||||
* Runtime metadata (realm, version, type).
|
||||
* Set externally by MatrixRuntime.
|
||||
*/
|
||||
_runtimeMeta: () => Record<string, unknown> = () => ({});
|
||||
|
||||
/**
|
||||
* Runtime registration snapshot — registered component tags and supervised roots.
|
||||
* Set externally by MatrixRuntime.
|
||||
*/
|
||||
_registeredSnapshot: () => RuntimeRegisteredSnapshot = () => ({
|
||||
componentTags: [],
|
||||
supervisedRoots: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* Root component lifecycle hooks. Set externally by MatrixRuntime.
|
||||
*/
|
||||
_startComponent: (mount: string) => Promise<boolean> = async () => {
|
||||
throw new Error('Runtime start hook not configured');
|
||||
};
|
||||
|
||||
_stopComponent: (mount: string) => Promise<boolean> = async () => {
|
||||
throw new Error('Runtime stop hook not configured');
|
||||
};
|
||||
|
||||
_reloadComponent: (mount: string) => Promise<void> = async () => {
|
||||
throw new Error('Runtime reload hook not configured');
|
||||
};
|
||||
|
||||
_removeComponent: (mount: string) => Promise<void> = async () => {
|
||||
throw new Error('Runtime remove hook not configured');
|
||||
};
|
||||
|
||||
_restartComponent: (mount: string) => Promise<void> = async () => {
|
||||
throw new Error('Runtime restart hook not configured');
|
||||
};
|
||||
|
||||
// ── $introspect override ─────────────────────────────────────────
|
||||
|
||||
protected override onIntrospect(payload?: { depth?: 'basic' | 'full' }) {
|
||||
const base = super.onIntrospect(payload);
|
||||
const meta = this._runtimeMeta();
|
||||
const components = this._componentSnapshot();
|
||||
|
||||
return {
|
||||
...base,
|
||||
type: 'runtime',
|
||||
realm: meta.realm ?? (this._context as RuntimeMetaContext | undefined)?.realm ?? 'unknown',
|
||||
version: meta.version ?? '0.0.0',
|
||||
runtimeType: meta.type ?? 'headless',
|
||||
running: meta.running ?? true,
|
||||
uptime: Date.now() - this._startTime,
|
||||
actors: components.length,
|
||||
registeredComponents: this._registeredSnapshot().componentTags.length,
|
||||
supervisedRoots: this._registeredSnapshot().supervisedRoots,
|
||||
services: this._serviceSnapshot(),
|
||||
// Override children with actual component list
|
||||
children: components.map(c => ({
|
||||
id: c.id,
|
||||
mount: c.mount,
|
||||
slot: '',
|
||||
componentClass: 'unknown',
|
||||
accepts: [],
|
||||
emits: [],
|
||||
tracks: [],
|
||||
metadata: c.metadata,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ── $ping override ───────────────────────────────────────────────
|
||||
|
||||
override on$ping() {
|
||||
return { status: 'ok', mount: '', type: 'runtime' };
|
||||
}
|
||||
|
||||
// ── Domain ops ───────────────────────────────────────────────────
|
||||
|
||||
onRuntimeStatus() {
|
||||
return this.onIntrospect({ depth: 'full' });
|
||||
}
|
||||
|
||||
onRuntimeServices() {
|
||||
return this._serviceSnapshot();
|
||||
}
|
||||
|
||||
onRuntimeActors() {
|
||||
return this._componentSnapshot();
|
||||
}
|
||||
|
||||
onRuntimeRegistered() {
|
||||
return this._registeredSnapshot();
|
||||
}
|
||||
|
||||
on$children() {
|
||||
const children = this._componentSnapshot().map((entry) => entry.mount);
|
||||
return {
|
||||
ok: true,
|
||||
children,
|
||||
count: children.length,
|
||||
};
|
||||
}
|
||||
|
||||
on$status(): ActorStatusSnapshotV1 & {
|
||||
ok: true;
|
||||
type: 'runtime';
|
||||
runtimeType: unknown;
|
||||
realm: unknown;
|
||||
running: unknown;
|
||||
actors: number;
|
||||
registeredComponents: number;
|
||||
supervisedRoots: string[];
|
||||
services: string[];
|
||||
} {
|
||||
const meta = this._runtimeMeta();
|
||||
const registered = this._registeredSnapshot();
|
||||
const services = this._serviceSnapshot();
|
||||
const actors = this._componentSnapshot();
|
||||
const base = super.on$status();
|
||||
|
||||
// Slice 1b (control-actor-nav): this is the runtime root actor's own
|
||||
// $status payload field, NOT a registry runtime-directory claim. The
|
||||
// runtime directory authority is `system.runtimes` (RuntimeManagerActor);
|
||||
// consumers must NOT use this kind field for runtime discovery.
|
||||
return {
|
||||
...base,
|
||||
ok: true,
|
||||
kind: 'runtime',
|
||||
type: 'runtime',
|
||||
runtimeType: meta.type ?? 'headless',
|
||||
realm: meta.realm ?? (this._context as RuntimeMetaContext | undefined)?.realm ?? 'unknown',
|
||||
running: meta.running ?? true,
|
||||
uptime: Date.now() - this._startTime,
|
||||
actors: actors.length,
|
||||
registeredComponents: registered.componentTags.length,
|
||||
supervisedRoots: registered.supervisedRoots,
|
||||
services: Object.keys(services),
|
||||
};
|
||||
}
|
||||
|
||||
async onRuntimeRemove(payload: { mount?: string }) {
|
||||
const mount = typeof payload?.mount === 'string' ? payload.mount.trim() : '';
|
||||
if (!mount) {
|
||||
throw new Error('runtime.remove requires a non-empty mount');
|
||||
}
|
||||
await this._removeComponent(mount);
|
||||
return {
|
||||
ok: true,
|
||||
removed: true,
|
||||
mount,
|
||||
};
|
||||
}
|
||||
|
||||
async onRuntimeStart(payload: { mount?: string }) {
|
||||
const mount = typeof payload?.mount === 'string' ? payload.mount.trim() : '';
|
||||
if (!mount) {
|
||||
throw new Error('runtime.start requires a non-empty mount');
|
||||
}
|
||||
const started = await this._startComponent(mount);
|
||||
return {
|
||||
ok: true,
|
||||
mount,
|
||||
started,
|
||||
alreadyRunning: !started,
|
||||
};
|
||||
}
|
||||
|
||||
async onRuntimeStop(payload: { mount?: string }) {
|
||||
const mount = typeof payload?.mount === 'string' ? payload.mount.trim() : '';
|
||||
if (!mount) {
|
||||
throw new Error('runtime.stop requires a non-empty mount');
|
||||
}
|
||||
const stopped = await this._stopComponent(mount);
|
||||
return {
|
||||
ok: true,
|
||||
mount,
|
||||
stopped,
|
||||
alreadyStopped: !stopped,
|
||||
};
|
||||
}
|
||||
|
||||
async onRuntimeReload(payload: { mount?: string }) {
|
||||
const mount = typeof payload?.mount === 'string' ? payload.mount.trim() : '';
|
||||
if (!mount) {
|
||||
throw new Error('runtime.reload requires a non-empty mount');
|
||||
}
|
||||
await this._reloadComponent(mount);
|
||||
return {
|
||||
ok: true,
|
||||
mount,
|
||||
reloaded: true,
|
||||
};
|
||||
}
|
||||
|
||||
async onRuntimeRestart(payload: { mount?: string }) {
|
||||
const mount = typeof payload?.mount === 'string' ? payload.mount.trim() : '';
|
||||
if (!mount) {
|
||||
throw new Error('runtime.restart requires a non-empty mount');
|
||||
}
|
||||
await this._restartComponent(mount);
|
||||
return {
|
||||
ok: true,
|
||||
restarted: true,
|
||||
mount,
|
||||
};
|
||||
}
|
||||
}
|
||||
178
packages/core/src/core/Supervision.d.ts
vendored
Normal file
178
packages/core/src/core/Supervision.d.ts
vendored
Normal file
@ -0,0 +1,178 @@
|
||||
import type { IExitSignal, IDownSignal, IMonitorRef } from '../engine/mailbox/IMailbox';
|
||||
export type { IExitSignal, IDownSignal, IMonitorRef };
|
||||
/**
|
||||
* Error information passed to supervision handlers.
|
||||
*/
|
||||
export interface ISupervisionError {
|
||||
/** Child id that failed */
|
||||
childId: string;
|
||||
/** Error message */
|
||||
message: string;
|
||||
/** Stack trace (if available) */
|
||||
stack?: string;
|
||||
/** Original error object - use instanceof or type guards to check type */
|
||||
original?: unknown;
|
||||
/** Timestamp */
|
||||
ts: number;
|
||||
/** Number of times this child has been restarted */
|
||||
restartCount: number;
|
||||
}
|
||||
/**
|
||||
* Actions that can be taken in response to a child error.
|
||||
*/
|
||||
export type SupervisionAction = 'restart' | 'stop' | 'escalate' | 'ignore';
|
||||
/**
|
||||
* Built-in supervision strategies.
|
||||
*/
|
||||
export type BuiltinStrategy = 'log' | 'restart' | 'restart-all' | 'stop' | 'escalate';
|
||||
/**
|
||||
* Custom supervision function.
|
||||
*/
|
||||
export type SupervisionFn = (error: ISupervisionError) => SupervisionAction | Promise<SupervisionAction>;
|
||||
/**
|
||||
* Supervision strategy - can be built-in string or custom function.
|
||||
*/
|
||||
export type SupervisionStrategy = BuiltinStrategy | SupervisionFn;
|
||||
/**
|
||||
* Restart limits configuration.
|
||||
*/
|
||||
export interface IRestartLimits {
|
||||
/** Maximum restarts within time window */
|
||||
maxRestarts: number;
|
||||
/** Time window in milliseconds */
|
||||
withinMs: number;
|
||||
}
|
||||
/**
|
||||
* Supervision policy for a composite component.
|
||||
*/
|
||||
export interface ISupervisionPolicy {
|
||||
/** Strategy for handling errors */
|
||||
strategy: SupervisionStrategy;
|
||||
/** Restart limits (for 'restart' strategy) */
|
||||
restartLimits?: IRestartLimits;
|
||||
/** Delay before restart in ms */
|
||||
restartDelayMs?: number;
|
||||
/** Whether to bubble errors up via emit */
|
||||
emitErrors?: boolean;
|
||||
}
|
||||
/**
|
||||
* Default supervision policy.
|
||||
*/
|
||||
export declare const DEFAULT_SUPERVISION_POLICY: ISupervisionPolicy;
|
||||
/**
|
||||
* Supervisor - manages error handling for child components.
|
||||
*
|
||||
* ## DESIGN NOTE: Exit Signals via Topics (Joe Armstrong's Concern)
|
||||
*
|
||||
* Exit signals are delivered via pub/sub topics, not actor mailboxes:
|
||||
* ```typescript
|
||||
* // In MatrixActor._monitorChild():
|
||||
* const exitTopic = `${childInfo.mount}.$exit`;
|
||||
* transport.subscribe(exitTopic, handleExitSignal);
|
||||
* ```
|
||||
*
|
||||
* **Why topics instead of mailboxes:**
|
||||
*
|
||||
* 1. **Multiple observers** - In Erlang, multiple processes can monitor
|
||||
* the same process. Topics enable parent + siblings + external observers
|
||||
* to all receive the exit signal.
|
||||
*
|
||||
* 2. **System-level vs application-level** - Exit signals are runtime
|
||||
* infrastructure, not application messages. Erlang's monitors are also
|
||||
* delivered by the runtime, not via the actor's message queue.
|
||||
*
|
||||
* 3. **Separation of concerns** - The mailbox is for business logic messages.
|
||||
* Supervision is orthogonal infrastructure.
|
||||
*
|
||||
* **Tradeoff:**
|
||||
* - Topics: Anyone who knows the topic can listen (no exclusivity)
|
||||
* - Mailbox: Exclusive delivery, but only one supervisor per actor
|
||||
*
|
||||
* **If mailbox-based supervision is needed**, the IDownSignal could be
|
||||
* sent to the supervisor's mailbox as a message:
|
||||
* ```typescript
|
||||
* supervisorMailbox.send({
|
||||
* command: '$down',
|
||||
* payload: { target: 'child.$inbox', reason: 'error' },
|
||||
* meta: { ts: Date.now() }
|
||||
* });
|
||||
* ```
|
||||
* This would require supervisors to run processMailbox() loops.
|
||||
*/
|
||||
export declare class Supervisor {
|
||||
private readonly _policy;
|
||||
private readonly _restartCounts;
|
||||
private readonly _restartTimestamps;
|
||||
constructor(policy?: ISupervisionPolicy);
|
||||
/**
|
||||
* Handle an error from a child.
|
||||
* Returns the action to take.
|
||||
*/
|
||||
handleError(error: ISupervisionError): Promise<SupervisionAction>;
|
||||
/**
|
||||
* Check if child is over restart limit.
|
||||
*/
|
||||
private _isOverRestartLimit;
|
||||
/**
|
||||
* Record a restart for tracking limits.
|
||||
*/
|
||||
private _recordRestart;
|
||||
/**
|
||||
* Get restart delay in ms.
|
||||
*/
|
||||
getRestartDelayMs(): number;
|
||||
/**
|
||||
* Check if errors should be emitted.
|
||||
*/
|
||||
shouldEmitErrors(): boolean;
|
||||
/**
|
||||
* Check if strategy is 'restart-all'.
|
||||
*/
|
||||
isRestartAllStrategy(): boolean;
|
||||
/**
|
||||
* Reset restart tracking for a child.
|
||||
*/
|
||||
resetChild(childId: string): void;
|
||||
/**
|
||||
* Reset all tracking.
|
||||
*/
|
||||
reset(): void;
|
||||
/**
|
||||
* Convert an actor IExitSignal to a SupervisionError.
|
||||
*
|
||||
* This bridges the actor mailbox system (IExitSignal) with the
|
||||
* supervision system (SupervisionError), enabling unified error handling.
|
||||
*
|
||||
* @param signal - Exit signal from actor termination
|
||||
* @returns SupervisionError for processing by handleError()
|
||||
*/
|
||||
exitSignalToError(signal: IExitSignal): ISupervisionError;
|
||||
/**
|
||||
* Handle an actor exit signal.
|
||||
*
|
||||
* Convenience method that converts the signal and determines action.
|
||||
*
|
||||
* @param signal - Exit signal from actor termination
|
||||
* @returns Action to take
|
||||
*/
|
||||
handleExitSignal(signal: IExitSignal): Promise<SupervisionAction>;
|
||||
}
|
||||
/**
|
||||
* Create a IDownSignal from an IExitSignal.
|
||||
*
|
||||
* Used by supervisors to notify monitors about child termination.
|
||||
*
|
||||
* @param exit - Exit signal from terminated actor
|
||||
* @param observerMount - Mount path of the observer
|
||||
* @returns IDownSignal to send to observers
|
||||
*/
|
||||
export declare function createDownSignal(exit: IExitSignal, observerMount: string): IDownSignal;
|
||||
/**
|
||||
* Create a IMonitorRef for tracking monitors.
|
||||
*
|
||||
* @param target - Mount path being monitored
|
||||
* @param observer - Mount path of observer
|
||||
* @returns IMonitorRef for tracking
|
||||
*/
|
||||
export declare function createMonitorRef(target: string, observer: string): IMonitorRef;
|
||||
//# sourceMappingURL=Supervision.d.ts.map
|
||||
1
packages/core/src/core/Supervision.d.ts.map
Normal file
1
packages/core/src/core/Supervision.d.ts.map
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"Supervision.d.ts","sourceRoot":"","sources":["Supervision.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAGxF,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,EAAE,CAAC;AAEtD;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,2BAA2B;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,oBAAoB;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,iCAAiC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gBAAgB;IAChB,EAAE,EAAE,MAAM,CAAC;IACX,oDAAoD;IACpD,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,MAAM,iBAAiB,GACzB,SAAS,GACT,MAAM,GACN,UAAU,GACV,QAAQ,CAAC;AAEb;;GAEG;AACH,MAAM,MAAM,eAAe,GACvB,KAAK,GACL,SAAS,GACT,aAAa,GACb,MAAM,GACN,UAAU,CAAC;AAEf;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,KAAK,EAAE,iBAAiB,KAAK,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;AAEzG;;GAEG;AACH,MAAM,MAAM,mBAAmB,GAAG,eAAe,GAAG,aAAa,CAAC;AAElE;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,0CAA0C;IAC1C,WAAW,EAAE,MAAM,CAAC;IACpB,kCAAkC;IAClC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,mCAAmC;IACnC,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,8CAA8C;IAC9C,aAAa,CAAC,EAAE,cAAc,CAAC;IAC/B,iCAAiC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2CAA2C;IAC3C,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED;;GAEG;AACH,eAAO,MAAM,0BAA0B,EAAE,kBAGxC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;IAC7C,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA6B;IAC5D,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA+B;gBAEtD,MAAM,GAAE,kBAA+C;IAInE;;;OAGG;IACG,WAAW,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAmDvE;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAa3B;;OAEG;IACH,OAAO,CAAC,cAAc;IAkBtB;;OAEG;IACH,iBAAiB,IAAI,MAAM;IAI3B;;OAEG;IACH,gBAAgB,IAAI,OAAO;IAI3B;;OAEG;IACH,oBAAoB,IAAI,OAAO;IAI/B;;OAEG;IACH,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAKjC;;OAEG;IACH,KAAK,IAAI,IAAI;IASb;;;;;;;;OAQG;IACH,iBAAiB,CAAC,MAAM,EAAE,WAAW,GAAG,iBAAiB;IAczD;;;;;;;OAOG;IACG,gBAAgB,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,iBAAiB,CAAC;CASxE;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,GAAG,WAAW,CAOtF;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,WAAW,CAM9E"}
|
||||
344
packages/core/src/core/Supervision.ts
Normal file
344
packages/core/src/core/Supervision.ts
Normal file
@ -0,0 +1,344 @@
|
||||
// src/core/Supervision.ts
|
||||
|
||||
import type { IExitSignal, IDownSignal, IMonitorRef } from '../engine/mailbox/IMailbox';
|
||||
|
||||
// Re-export mailbox types for convenience
|
||||
export type { IExitSignal, IDownSignal, IMonitorRef };
|
||||
|
||||
/**
|
||||
* Error information passed to supervision handlers.
|
||||
*/
|
||||
export interface ISupervisionError {
|
||||
/** Child id that failed */
|
||||
childId: string;
|
||||
/** Error message */
|
||||
message: string;
|
||||
/** Stack trace (if available) */
|
||||
stack?: string;
|
||||
/** Original error object - use instanceof or type guards to check type */
|
||||
original?: unknown;
|
||||
/** Timestamp */
|
||||
ts: number;
|
||||
/** Number of times this child has been restarted */
|
||||
restartCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions that can be taken in response to a child error.
|
||||
*/
|
||||
export type SupervisionAction =
|
||||
| 'restart' // Restart the failed child
|
||||
| 'stop' // Stop the failed child (remove it)
|
||||
| 'escalate' // Propagate error to parent
|
||||
| 'ignore'; // Log and continue
|
||||
|
||||
/**
|
||||
* Built-in supervision strategies.
|
||||
*/
|
||||
export type BuiltinStrategy =
|
||||
| 'log' // Just log errors (default)
|
||||
| 'restart' // Always restart failed children
|
||||
| 'restart-all' // Restart all children on any failure
|
||||
| 'stop' // Stop failed children
|
||||
| 'escalate'; // Always escalate to parent
|
||||
|
||||
/**
|
||||
* Custom supervision function.
|
||||
*/
|
||||
export type SupervisionFn = (error: ISupervisionError) => SupervisionAction | Promise<SupervisionAction>;
|
||||
|
||||
/**
|
||||
* Supervision strategy - can be built-in string or custom function.
|
||||
*/
|
||||
export type SupervisionStrategy = BuiltinStrategy | SupervisionFn;
|
||||
|
||||
/**
|
||||
* Restart limits configuration.
|
||||
*/
|
||||
export interface IRestartLimits {
|
||||
/** Maximum restarts within time window */
|
||||
maxRestarts: number;
|
||||
/** Time window in milliseconds */
|
||||
withinMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervision policy for a composite component.
|
||||
*/
|
||||
export interface ISupervisionPolicy {
|
||||
/** Strategy for handling errors */
|
||||
strategy: SupervisionStrategy;
|
||||
/** Restart limits (for 'restart' strategy) */
|
||||
restartLimits?: IRestartLimits;
|
||||
/** Delay before restart in ms */
|
||||
restartDelayMs?: number;
|
||||
/** Whether to bubble errors up via emit */
|
||||
emitErrors?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default supervision policy.
|
||||
*/
|
||||
export const DEFAULT_SUPERVISION_POLICY: ISupervisionPolicy = {
|
||||
strategy: 'log',
|
||||
emitErrors: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Supervisor - manages error handling for child components.
|
||||
*
|
||||
* ## DESIGN NOTE: Exit Signals via Topics (Joe Armstrong's Concern)
|
||||
*
|
||||
* Exit signals are delivered via pub/sub topics, not actor mailboxes:
|
||||
* ```typescript
|
||||
* // In MatrixActor._monitorChild():
|
||||
* const exitTopic = `${childInfo.mount}.$exit`;
|
||||
* transport.subscribe(exitTopic, handleExitSignal);
|
||||
* ```
|
||||
*
|
||||
* **Why topics instead of mailboxes:**
|
||||
*
|
||||
* 1. **Multiple observers** - In Erlang, multiple processes can monitor
|
||||
* the same process. Topics enable parent + siblings + external observers
|
||||
* to all receive the exit signal.
|
||||
*
|
||||
* 2. **System-level vs application-level** - Exit signals are runtime
|
||||
* infrastructure, not application messages. Erlang's monitors are also
|
||||
* delivered by the runtime, not via the actor's message queue.
|
||||
*
|
||||
* 3. **Separation of concerns** - The mailbox is for business logic messages.
|
||||
* Supervision is orthogonal infrastructure.
|
||||
*
|
||||
* **Tradeoff:**
|
||||
* - Topics: Anyone who knows the topic can listen (no exclusivity)
|
||||
* - Mailbox: Exclusive delivery, but only one supervisor per actor
|
||||
*
|
||||
* **If mailbox-based supervision is needed**, the IDownSignal could be
|
||||
* sent to the supervisor's mailbox as a message:
|
||||
* ```typescript
|
||||
* supervisorMailbox.send({
|
||||
* command: '$down',
|
||||
* payload: { target: 'child.$inbox', reason: 'error' },
|
||||
* meta: { ts: Date.now() }
|
||||
* });
|
||||
* ```
|
||||
* This would require supervisors to run processMailbox() loops.
|
||||
*/
|
||||
export class Supervisor {
|
||||
private readonly _policy: ISupervisionPolicy;
|
||||
private readonly _restartCounts = new Map<string, number>();
|
||||
private readonly _restartTimestamps = new Map<string, number[]>();
|
||||
|
||||
constructor(policy: ISupervisionPolicy = DEFAULT_SUPERVISION_POLICY) {
|
||||
this._policy = policy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an error from a child.
|
||||
* Returns the action to take.
|
||||
*/
|
||||
async handleError(error: ISupervisionError): Promise<SupervisionAction> {
|
||||
const { strategy } = this._policy;
|
||||
|
||||
// Update restart count
|
||||
const currentCount = this._restartCounts.get(error.childId) ?? 0;
|
||||
error.restartCount = currentCount;
|
||||
|
||||
// Check restart limits
|
||||
if (this._isOverRestartLimit(error.childId)) {
|
||||
// eslint-disable-next-line no-console -- supervisor restart-limit violations need direct kernel diagnostics
|
||||
console.error(
|
||||
`[Supervisor] Child '${error.childId}' exceeded restart limits, stopping`
|
||||
);
|
||||
return 'stop';
|
||||
}
|
||||
|
||||
// Determine action
|
||||
let action: SupervisionAction;
|
||||
|
||||
if (typeof strategy === 'function') {
|
||||
action = await strategy(error);
|
||||
} else {
|
||||
switch (strategy) {
|
||||
case 'log':
|
||||
// eslint-disable-next-line no-console -- log supervision strategy intentionally writes child failures to console
|
||||
console.error(`[Supervisor] Child '${error.childId}' error:`, error.message);
|
||||
action = 'ignore';
|
||||
break;
|
||||
case 'restart':
|
||||
action = 'restart';
|
||||
break;
|
||||
case 'restart-all':
|
||||
action = 'restart'; // Caller handles 'all' logic
|
||||
break;
|
||||
case 'stop':
|
||||
action = 'stop';
|
||||
break;
|
||||
case 'escalate':
|
||||
action = 'escalate';
|
||||
break;
|
||||
default:
|
||||
action = 'ignore';
|
||||
}
|
||||
}
|
||||
|
||||
// Track restart if that's the action
|
||||
if (action === 'restart') {
|
||||
this._recordRestart(error.childId);
|
||||
}
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if child is over restart limit.
|
||||
*/
|
||||
private _isOverRestartLimit(childId: string): boolean {
|
||||
const limits = this._policy.restartLimits;
|
||||
if (!limits) return false;
|
||||
|
||||
const timestamps = this._restartTimestamps.get(childId) ?? [];
|
||||
const now = Date.now();
|
||||
const windowStart = now - limits.withinMs;
|
||||
|
||||
// Count restarts within window
|
||||
const recentRestarts = timestamps.filter(ts => ts > windowStart).length;
|
||||
return recentRestarts >= limits.maxRestarts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a restart for tracking limits.
|
||||
*/
|
||||
private _recordRestart(childId: string): void {
|
||||
const count = (this._restartCounts.get(childId) ?? 0) + 1;
|
||||
this._restartCounts.set(childId, count);
|
||||
|
||||
const timestamps = this._restartTimestamps.get(childId) ?? [];
|
||||
timestamps.push(Date.now());
|
||||
|
||||
// Clean old timestamps
|
||||
const limits = this._policy.restartLimits;
|
||||
if (limits) {
|
||||
const windowStart = Date.now() - limits.withinMs;
|
||||
const recent = timestamps.filter(ts => ts > windowStart);
|
||||
this._restartTimestamps.set(childId, recent);
|
||||
} else {
|
||||
this._restartTimestamps.set(childId, timestamps);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get restart delay in ms.
|
||||
*/
|
||||
getRestartDelayMs(): number {
|
||||
return this._policy.restartDelayMs ?? 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if errors should be emitted.
|
||||
*/
|
||||
shouldEmitErrors(): boolean {
|
||||
return this._policy.emitErrors ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if strategy is 'restart-all'.
|
||||
*/
|
||||
isRestartAllStrategy(): boolean {
|
||||
return this._policy.strategy === 'restart-all';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset restart tracking for a child.
|
||||
*/
|
||||
resetChild(childId: string): void {
|
||||
this._restartCounts.delete(childId);
|
||||
this._restartTimestamps.delete(childId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all tracking.
|
||||
*/
|
||||
reset(): void {
|
||||
this._restartCounts.clear();
|
||||
this._restartTimestamps.clear();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Actor Exit Signal Integration
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Convert an actor IExitSignal to a SupervisionError.
|
||||
*
|
||||
* This bridges the actor mailbox system (IExitSignal) with the
|
||||
* supervision system (SupervisionError), enabling unified error handling.
|
||||
*
|
||||
* @param signal - Exit signal from actor termination
|
||||
* @returns SupervisionError for processing by handleError()
|
||||
*/
|
||||
exitSignalToError(signal: IExitSignal): ISupervisionError {
|
||||
// Extract child id from mount path (last segment)
|
||||
const childId = signal.from.split('.').pop() ?? signal.from;
|
||||
|
||||
return {
|
||||
childId,
|
||||
message: signal.error?.message ?? `Actor terminated with reason: ${signal.reason}`,
|
||||
stack: signal.error?.stack,
|
||||
original: signal,
|
||||
ts: Date.now(),
|
||||
restartCount: this._restartCounts.get(childId) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an actor exit signal.
|
||||
*
|
||||
* Convenience method that converts the signal and determines action.
|
||||
*
|
||||
* @param signal - Exit signal from actor termination
|
||||
* @returns Action to take
|
||||
*/
|
||||
async handleExitSignal(signal: IExitSignal): Promise<SupervisionAction> {
|
||||
// Normal termination doesn't need supervision action
|
||||
if (signal.reason === 'normal') {
|
||||
return 'ignore';
|
||||
}
|
||||
|
||||
const error = this.exitSignalToError(signal);
|
||||
return this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a IDownSignal from an IExitSignal.
|
||||
*
|
||||
* Used by supervisors to notify monitors about child termination.
|
||||
*
|
||||
* @param exit - Exit signal from terminated actor
|
||||
* @param observerMount - Mount path of the observer
|
||||
* @returns IDownSignal to send to observers
|
||||
*/
|
||||
export function createDownSignal(exit: IExitSignal, observerMount: string): IDownSignal {
|
||||
return {
|
||||
type: 'down',
|
||||
target: exit.from,
|
||||
reason: exit.reason,
|
||||
error: exit.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a IMonitorRef for tracking monitors.
|
||||
*
|
||||
* @param target - Mount path being monitored
|
||||
* @param observer - Mount path of observer
|
||||
* @returns IMonitorRef for tracking
|
||||
*/
|
||||
export function createMonitorRef(target: string, observer: string): IMonitorRef {
|
||||
return {
|
||||
id: `${observer}->${target}-${Date.now()}`,
|
||||
target,
|
||||
observer,
|
||||
};
|
||||
}
|
||||
359
packages/core/src/core/WorkstreamActor.ts
Normal file
359
packages/core/src/core/WorkstreamActor.ts
Normal file
@ -0,0 +1,359 @@
|
||||
/**
|
||||
* WorkstreamActor — base class for workstream-level cognitive actors.
|
||||
*
|
||||
* Extracted from SmithersSupervisor (Phase 3 reference implementation).
|
||||
* Provides: memory persistence, plan lifecycle, context recovery on wake,
|
||||
* and a standard cognitive field pattern for any workstream.
|
||||
*
|
||||
* Typically registers with system.workstreams, but may mount anywhere in the
|
||||
* domain hierarchy. The coordinator is a registry/protocol surface, not the
|
||||
* required parent mount for every workstream instance.
|
||||
*
|
||||
* Subclasses declare:
|
||||
* - static purpose (required)
|
||||
* - static systemPrompt (optional)
|
||||
* - static promptTriggers (optional)
|
||||
* - static skills (optional)
|
||||
* - onBootstrap(): initialize domain-specific state
|
||||
*
|
||||
* Constraints satisfied:
|
||||
* C14: One actor per workstream
|
||||
* C16: Memory survives context window death
|
||||
* C17: Extracted from proven Smithers pattern
|
||||
*/
|
||||
import { MatrixActor } from './MatrixActor';
|
||||
import { RequestReply } from '../engine/remoting/RequestReply';
|
||||
|
||||
/** Plan step structure */
|
||||
export interface PlanStep {
|
||||
name: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed' | 'skipped';
|
||||
}
|
||||
|
||||
/** Active plan recovered from memory */
|
||||
export interface ActivePlan {
|
||||
planId?: string;
|
||||
name: string;
|
||||
steps: PlanStep[];
|
||||
}
|
||||
|
||||
interface WorkstreamRequestRecord {
|
||||
id: string;
|
||||
text: string;
|
||||
from: string | null;
|
||||
priority: string | null;
|
||||
status: 'pending';
|
||||
ts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for workstream actors. Extends MatrixActor with
|
||||
* memory persistence, plan lifecycle, and context recovery.
|
||||
*/
|
||||
export class WorkstreamActor extends MatrixActor {
|
||||
// ─── Cognitive Defaults ──────────────────────────────────────────────
|
||||
// Subclasses MUST override purpose. All other fields have sensible defaults.
|
||||
|
||||
static promptable = true;
|
||||
static regime = 'focused' as const;
|
||||
static memoryScope = 'actor' as const;
|
||||
static planScope = 'actor' as const;
|
||||
static continuationType = 'persistent' as const;
|
||||
static kind = 'workstream';
|
||||
|
||||
static contract = {
|
||||
interfaces: {
|
||||
workstream: {
|
||||
description: 'Generic durable workstream actor surface',
|
||||
ops: {
|
||||
status: {
|
||||
description: 'Get workstream status summary',
|
||||
payload: {},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: true },
|
||||
name: { type: 'string', required: true },
|
||||
purpose: { type: 'string', required: true },
|
||||
regime: { type: 'string', required: true },
|
||||
promptable: { type: 'boolean', required: true },
|
||||
pendingRequests: { type: 'number', required: true },
|
||||
activeAgentCount: { type: 'number', required: true },
|
||||
},
|
||||
},
|
||||
request: {
|
||||
description: 'Submit an external request into the workstream inbox',
|
||||
payload: {
|
||||
text: { type: 'string', required: true, description: 'Request text or instruction' },
|
||||
from: { type: 'string', required: false, description: 'Requesting actor or operator' },
|
||||
priority: { type: 'string', required: false, description: 'Optional priority label' },
|
||||
},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: true },
|
||||
requestId: { type: 'string', required: true },
|
||||
status: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
plan: {
|
||||
description: 'Get active plan',
|
||||
payload: {},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: true },
|
||||
plan: { type: 'object', required: false, description: 'Active plan when present' },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
description: 'List the workstream actor and visible child agents',
|
||||
payload: {},
|
||||
response: {
|
||||
ok: { type: 'boolean', required: true },
|
||||
agents: { type: 'array', required: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
static accepts = {
|
||||
'workstream.status': { description: 'Get workstream status summary' },
|
||||
'workstream.request': { description: 'Submit a request into the workstream inbox' },
|
||||
'workstream.memory': { description: 'Query workstream memory' },
|
||||
'workstream.plan': { description: 'Get active plan' },
|
||||
'workstream.agents': { description: 'List the workstream actor and visible child agents' },
|
||||
};
|
||||
|
||||
static emits = {
|
||||
'workstream.phaseChanged': { description: 'Published when workstream phase changes' },
|
||||
'workstream.memoryWritten': { description: 'Published when a memory record is saved' },
|
||||
'workstream.requested': { description: 'Published when a new workstream request is queued' },
|
||||
};
|
||||
|
||||
// ─── Instance State ──────────────────────────────────────────────────
|
||||
|
||||
/** Active plan recovered from memory on wake */
|
||||
protected _activePlan: ActivePlan | null = null;
|
||||
/** Workstream name (derived from mount path) */
|
||||
protected _workstreamName = '';
|
||||
/** Minimal in-memory request inbox for generic operator tooling */
|
||||
protected _requests: WorkstreamRequestRecord[] = [];
|
||||
/** Best-effort flag to avoid unregister spam when registration never succeeded */
|
||||
protected _registeredWithCoordinator = false;
|
||||
|
||||
// ─── Lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
protected override async onBootstrap(): Promise<void> {
|
||||
// Derive workstream name from the actor mount, regardless of namespace.
|
||||
const mount = this._context?.mount ?? '';
|
||||
const parts = mount.split('.');
|
||||
this._workstreamName = parts[parts.length - 1] || 'unknown';
|
||||
|
||||
// Phase 3/C16: Recover context from memory on wake
|
||||
await this._recoverFromMemory();
|
||||
await this._registerWithCoordinator();
|
||||
}
|
||||
|
||||
protected override onDispose(): void {
|
||||
this._unregisterFromCoordinator();
|
||||
}
|
||||
|
||||
/** Recover active plan and recent memory on actor wake. */
|
||||
private async _recoverFromMemory(): Promise<void> {
|
||||
try {
|
||||
const plan = await this.queryActivePlan();
|
||||
if (plan) {
|
||||
this._activePlan = plan;
|
||||
const currentStep = plan.steps.findIndex(s => s.status === 'in_progress');
|
||||
// eslint-disable-next-line no-console -- bootstrap recovery notice for kernel workstream actor
|
||||
console.log(`[${this._workstreamName}] Recovered active plan: ${plan.name}, step ${currentStep + 1}/${plan.steps.length}`);
|
||||
}
|
||||
} catch { /* memory service may not be ready at bootstrap — non-fatal */ }
|
||||
}
|
||||
|
||||
// ─── Op Handlers ─────────────────────────────────────────────────────
|
||||
|
||||
onWorkstreamStatus(): unknown {
|
||||
const ctor = this.constructor as typeof WorkstreamActor;
|
||||
const childCount = this._children?.getAllChildren()?.length ?? 0;
|
||||
return {
|
||||
ok: true,
|
||||
name: this._workstreamName,
|
||||
purpose: ctor.purpose ?? '(none)',
|
||||
regime: ctor.regime,
|
||||
promptable: ctor.promptable,
|
||||
kind: ctor.kind,
|
||||
pendingRequests: this._requests.length,
|
||||
activeAgentCount: 1 + childCount,
|
||||
activePlan: this._activePlan ? {
|
||||
name: this._activePlan.name,
|
||||
steps: this._activePlan.steps,
|
||||
currentStep: this._activePlan.steps.find(s => s.status === 'in_progress')?.name ?? null,
|
||||
} : null,
|
||||
triggers: ctor.promptTriggers?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
onWorkstreamRequest(payload: Record<string, unknown>): unknown {
|
||||
const text = typeof payload.text === 'string' ? payload.text.trim() : '';
|
||||
if (!text) return { ok: false, error: 'text required' };
|
||||
|
||||
const request: WorkstreamRequestRecord = {
|
||||
id: `req-${Date.now()}-${this._requests.length + 1}`,
|
||||
text,
|
||||
from: typeof payload.from === 'string' ? payload.from : null,
|
||||
priority: typeof payload.priority === 'string' ? payload.priority : null,
|
||||
status: 'pending',
|
||||
ts: Date.now(),
|
||||
};
|
||||
this._requests.push(request);
|
||||
this.emit('workstream.requested', request);
|
||||
return { ok: true, requestId: request.id, status: request.status };
|
||||
}
|
||||
|
||||
private async _registerWithCoordinator(): Promise<void> {
|
||||
if (!this._context) return;
|
||||
const ctor = this.constructor as typeof WorkstreamActor;
|
||||
try {
|
||||
await RequestReply.execute(this._context, 'system.workstreams', 'workstream.register', {
|
||||
name: this._workstreamName,
|
||||
mount: this._context.mount,
|
||||
type: ctor.name,
|
||||
purpose: ctor.purpose ?? '(none)',
|
||||
promptable: ctor.promptable,
|
||||
regime: ctor.regime,
|
||||
triggerCount: ctor.promptTriggers?.length ?? 0,
|
||||
}, { timeoutMs: 2000 });
|
||||
this._registeredWithCoordinator = true;
|
||||
} catch {
|
||||
this._registeredWithCoordinator = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _unregisterFromCoordinator(): void {
|
||||
if (!this._context || !this._registeredWithCoordinator) return;
|
||||
RequestReply.execute(this._context, 'system.workstreams', 'workstream.unregister', {
|
||||
mount: this._context.mount,
|
||||
}, { timeoutMs: 1000 }).catch(() => {});
|
||||
}
|
||||
|
||||
async onWorkstreamMemory(payload: Record<string, unknown>): Promise<unknown> {
|
||||
const limit = typeof payload.limit === 'number' ? payload.limit : 10;
|
||||
return this.queryRecentMemory(limit);
|
||||
}
|
||||
|
||||
async onWorkstreamPlan(): Promise<unknown> {
|
||||
const plan = this._activePlan ?? await this.queryActivePlan();
|
||||
return plan ? { ok: true, plan } : { ok: false, error: 'No active plan' };
|
||||
}
|
||||
|
||||
onWorkstreamAgents(): unknown {
|
||||
const childAgents = (this._children?.getAllChildren() ?? []).map((child) => ({
|
||||
id: child.id,
|
||||
mount: child.mount,
|
||||
componentClass: child.componentClass,
|
||||
role: 'child',
|
||||
}));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
agents: [
|
||||
{
|
||||
id: this._workstreamName,
|
||||
mount: this._context?.mount ?? '',
|
||||
componentClass: (this.constructor as typeof WorkstreamActor).name,
|
||||
role: 'workstream',
|
||||
},
|
||||
...childAgents,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Memory Helpers (protected — available to subclasses) ────────────
|
||||
|
||||
/** Save a memory record at a key decision point. Non-fatal on failure. */
|
||||
protected async saveMemory(content: string, tags: string[] = []): Promise<void> {
|
||||
if (!this._context) return;
|
||||
try {
|
||||
await RequestReply.execute(this._context, 'system.agents.memory', 'memory.save', {
|
||||
mount: this._context.mount,
|
||||
content,
|
||||
tags: [this._workstreamName, ...tags],
|
||||
}, { timeoutMs: 5000 });
|
||||
this.emit('workstream.memoryWritten', { content: content.slice(0, 100), tags });
|
||||
// Stigmergy: publish workstream state to blackboard
|
||||
RequestReply.sendToInbox(this._context, 'system.blackboard', 'fact.assert', {
|
||||
domain: `workstream.${this._workstreamName}`,
|
||||
data: { lastMemory: content.slice(0, 100), tags, updatedAt: Date.now() },
|
||||
});
|
||||
} catch { /* memory service may be unavailable — non-fatal */ }
|
||||
}
|
||||
|
||||
/** Query active plan from system.memory. */
|
||||
protected async queryActivePlan(): Promise<ActivePlan | null> {
|
||||
if (!this._context) return null;
|
||||
try {
|
||||
const result = await RequestReply.execute(this._context, 'system.agents.memory', 'plan.active', {
|
||||
mount: this._context.mount,
|
||||
}, { timeoutMs: 3000 }) as Record<string, unknown>;
|
||||
if (result?.ok && result?.name) {
|
||||
return result as unknown as ActivePlan;
|
||||
}
|
||||
} catch { /* memory service unavailable */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Query recent memory records. */
|
||||
protected async queryRecentMemory(limit = 10): Promise<unknown> {
|
||||
if (!this._context) return { ok: false, error: 'No context' };
|
||||
try {
|
||||
return await RequestReply.execute(this._context, 'system.agents.memory', 'memory.history', {
|
||||
mount: this._context.mount, limit,
|
||||
}, { timeoutMs: 3000 });
|
||||
} catch { return { ok: false, error: 'Memory service unavailable' }; }
|
||||
}
|
||||
|
||||
// ─── Plan Lifecycle (protected — available to subclasses) ────────────
|
||||
|
||||
/** Create a plan with named steps. Returns planId or null. */
|
||||
protected async createPlan(name: string, steps: Array<{ name: string }>): Promise<string | null> {
|
||||
if (!this._context) return null;
|
||||
try {
|
||||
const result = await RequestReply.execute(this._context, 'system.agents.memory', 'plan.create', {
|
||||
mount: this._context.mount,
|
||||
name,
|
||||
steps: steps.map(s => ({ name: s.name, status: 'pending' })),
|
||||
}, { timeoutMs: 5000 }) as Record<string, unknown>;
|
||||
const planId = (result?.planId as string) ?? null;
|
||||
if (planId && result?.name) {
|
||||
this._activePlan = result as unknown as ActivePlan;
|
||||
}
|
||||
return planId;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Update a plan step status. */
|
||||
protected async updatePlanStep(planId: string, stepIndex: number, status: string): Promise<void> {
|
||||
if (!this._context) return;
|
||||
try {
|
||||
await RequestReply.execute(this._context, 'system.agents.memory', 'plan.update', {
|
||||
planId, stepIndex, status,
|
||||
}, { timeoutMs: 3000 });
|
||||
// Update local cache
|
||||
if (this._activePlan?.planId === planId && this._activePlan.steps[stepIndex]) {
|
||||
this._activePlan.steps[stepIndex].status = status as PlanStep['status'];
|
||||
}
|
||||
this.emit('workstream.phaseChanged', { planId, stepIndex, status });
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
/** Mark a plan as complete. */
|
||||
protected async completePlan(planId: string): Promise<void> {
|
||||
if (!this._context) return;
|
||||
try {
|
||||
await RequestReply.execute(this._context, 'system.agents.memory', 'plan.complete', {
|
||||
planId,
|
||||
}, { timeoutMs: 3000 });
|
||||
if (this._activePlan?.planId === planId) {
|
||||
this._activePlan = null; // Clear active plan
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Behavioral tests for PackageRootActor storage seam (HARVEST-001 / HARVEST-002).
|
||||
*
|
||||
* Two layers:
|
||||
*
|
||||
* 1. Fast unit tests of _initStorage against a minimal fake context.
|
||||
* No MatrixRuntime, no transport, no $join, no shutdown wait.
|
||||
* Proves the contract:
|
||||
* - provider present + returns store -> RECORD_STORE_KEY set
|
||||
* - no provider -> deferred (no RECORD_STORE_KEY, no throw)
|
||||
* - provider returns null -> deferred
|
||||
* - provider throws -> deferred (bootstrap unaffected)
|
||||
* - manifest declares no storage -> provider not invoked
|
||||
* - provider invoked with (matrixHome, domain) where domain comes from manifest
|
||||
* Each test runs in <10ms.
|
||||
*
|
||||
* 2. Runtime-wiring test: a single test that proves MatrixRuntime registers
|
||||
* the provider on its rootContext under RECORD_STORE_PROVIDER_KEY when
|
||||
* `recordStoreProvider` is supplied via IRuntimeConfig. Uses runtime.shutdown()
|
||||
* to exit cleanly. This is the only test that touches a real runtime.
|
||||
*
|
||||
* Why this split:
|
||||
* - The seam is a context-service contract. The unit tests test the contract.
|
||||
* - The runtime-wiring test proves IRuntimeConfig.recordStoreProvider lands
|
||||
* where PackageRootActor will look for it, without dragging the actor
|
||||
* bootstrap path (and its $join wait) into the assertion loop.
|
||||
*/
|
||||
|
||||
import '../../_node-compat.js';
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MatrixRuntime } from '../../runtime/MatrixRuntime.js';
|
||||
import { PackageRootActor, type PackageManifestProps } from '../PackageRootActor.js';
|
||||
import { RECORD_STORE_PROVIDER_KEY, type IRecordStoreProvider } from '../IRecordStoreProvider.js';
|
||||
import { RECORD_STORE_KEY } from '../../framework/InteractionRecord.js';
|
||||
|
||||
// ── Fake context (cascading service registry, no runtime) ───────────────────
|
||||
|
||||
interface IServiceLookup {
|
||||
getService(key: string | symbol): unknown;
|
||||
setService(key: string | symbol, value: unknown): void;
|
||||
}
|
||||
|
||||
class FakeContext implements IServiceLookup {
|
||||
private readonly services = new Map<string | symbol, unknown>();
|
||||
constructor(private readonly parent?: IServiceLookup) {}
|
||||
getService(key: string | symbol): unknown {
|
||||
if (this.services.has(key)) return this.services.get(key);
|
||||
return this.parent?.getService(key);
|
||||
}
|
||||
setService(key: string | symbol, value: unknown): void {
|
||||
this.services.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test PackageRootActor that bypasses MatrixActor mounting ────────────────
|
||||
|
||||
class TestPackageRoot extends PackageRootActor {
|
||||
static override package = '@open-matrix/test-package';
|
||||
static override namespace = 'test.pkg';
|
||||
|
||||
/**
|
||||
* Set both _props (what onBootstrap reads) and _manifestProps (what
|
||||
* _initStorage reads). onBootstrap normally copies _props -> _manifestProps;
|
||||
* the test bypasses bootstrap so we copy here.
|
||||
*/
|
||||
setManifestProps(props: PackageManifestProps): void {
|
||||
(this as unknown as { _props: PackageManifestProps })._props = props;
|
||||
(this as unknown as { _manifestProps: PackageManifestProps })._manifestProps = props;
|
||||
}
|
||||
attachContext(ctx: IServiceLookup): void {
|
||||
(this as unknown as { _context: IServiceLookup })._context = ctx;
|
||||
}
|
||||
async runInitStorage(): Promise<void> {
|
||||
await (this as unknown as { _initStorage(): Promise<void> })._initStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function makeContextWithProvider(provider: IRecordStoreProvider): FakeContext {
|
||||
const root = new FakeContext();
|
||||
root.setService(RECORD_STORE_PROVIDER_KEY, provider);
|
||||
return new FakeContext(root); // child cascades to root
|
||||
}
|
||||
|
||||
function makeContextNoProvider(): FakeContext {
|
||||
const root = new FakeContext();
|
||||
return new FakeContext(root);
|
||||
}
|
||||
|
||||
// ── Unit tests (fast: no runtime) ────────────────────────────────────────────
|
||||
|
||||
describe('PackageRootActor storage seam — unit', () => {
|
||||
|
||||
it('sets RECORD_STORE_KEY when provider returns a store; passes (matrixHome, domain)', async () => {
|
||||
const fakeStore = { __probe: 'fake-store-handle' };
|
||||
const calls: Array<{ matrixHome: string; domain: string }> = [];
|
||||
const provider: IRecordStoreProvider = {
|
||||
async provide(matrixHome, domain) {
|
||||
calls.push({ matrixHome, domain });
|
||||
return fakeStore;
|
||||
},
|
||||
};
|
||||
const ctx = makeContextWithProvider(provider);
|
||||
|
||||
const actor = new TestPackageRoot();
|
||||
actor.setManifestProps({
|
||||
storage: { sqlite: { domain: 'test-domain' } },
|
||||
packageName: '@open-matrix/test-package',
|
||||
});
|
||||
actor.attachContext(ctx);
|
||||
|
||||
await actor.runInitStorage();
|
||||
|
||||
assert.equal(calls.length, 1, 'provider invoked exactly once');
|
||||
assert.equal(calls[0]!.domain, 'test-domain', 'domain matches manifest');
|
||||
assert.ok(calls[0]!.matrixHome.length > 0, 'matrixHome resolved (non-empty)');
|
||||
assert.deepEqual(ctx.getService(RECORD_STORE_KEY), fakeStore, 'store registered on context');
|
||||
});
|
||||
|
||||
it('does not set RECORD_STORE_KEY when no provider is registered (deferred)', async () => {
|
||||
const ctx = makeContextNoProvider();
|
||||
|
||||
const actor = new TestPackageRoot();
|
||||
actor.setManifestProps({
|
||||
storage: { sqlite: { domain: 'test-domain-2' } },
|
||||
packageName: '@open-matrix/test-package',
|
||||
});
|
||||
actor.attachContext(ctx);
|
||||
|
||||
await actor.runInitStorage();
|
||||
|
||||
assert.equal(ctx.getService(RECORD_STORE_KEY), undefined, 'no store; deferred');
|
||||
});
|
||||
|
||||
it('does not set RECORD_STORE_KEY when provider returns null (declined)', async () => {
|
||||
const provider: IRecordStoreProvider = { async provide() { return null; } };
|
||||
const ctx = makeContextWithProvider(provider);
|
||||
|
||||
const actor = new TestPackageRoot();
|
||||
actor.setManifestProps({
|
||||
storage: { sqlite: { domain: 'test-domain-3' } },
|
||||
packageName: '@open-matrix/test-package',
|
||||
});
|
||||
actor.attachContext(ctx);
|
||||
|
||||
await actor.runInitStorage();
|
||||
|
||||
assert.equal(ctx.getService(RECORD_STORE_KEY), undefined, 'declined provider; deferred');
|
||||
});
|
||||
|
||||
it('swallows provider errors; bootstrap not affected (matches pre-seam swallow)', async () => {
|
||||
const provider: IRecordStoreProvider = {
|
||||
async provide() { throw new Error('synthetic provider failure'); },
|
||||
};
|
||||
const ctx = makeContextWithProvider(provider);
|
||||
|
||||
const actor = new TestPackageRoot();
|
||||
actor.setManifestProps({
|
||||
storage: { sqlite: { domain: 'test-domain-4' } },
|
||||
packageName: '@open-matrix/test-package',
|
||||
});
|
||||
actor.attachContext(ctx);
|
||||
|
||||
await actor.runInitStorage(); // must not throw
|
||||
|
||||
assert.equal(ctx.getService(RECORD_STORE_KEY), undefined, 'failed provider; deferred');
|
||||
});
|
||||
|
||||
it('does not invoke provider when manifest has no storage declaration', async () => {
|
||||
let providerCalled = false;
|
||||
const provider: IRecordStoreProvider = {
|
||||
async provide() { providerCalled = true; return { dummy: true }; },
|
||||
};
|
||||
const ctx = makeContextWithProvider(provider);
|
||||
|
||||
const actor = new TestPackageRoot();
|
||||
actor.setManifestProps({
|
||||
packageName: '@open-matrix/test-package',
|
||||
});
|
||||
actor.attachContext(ctx);
|
||||
|
||||
await actor.runInitStorage();
|
||||
|
||||
assert.equal(providerCalled, false, 'no storage declared -> provider not invoked');
|
||||
assert.equal(ctx.getService(RECORD_STORE_KEY), undefined);
|
||||
});
|
||||
|
||||
it('passes the exact domain string from manifest (per-package data isolation)', async () => {
|
||||
// Defensive: HARVEST-SQLITE-ARCH says each package/domain owns its own
|
||||
// <matrixHome>/data/<domain>/store.db — provider must receive the domain
|
||||
// verbatim so two packages with different domains never collide.
|
||||
const observedDomains: string[] = [];
|
||||
const provider: IRecordStoreProvider = {
|
||||
async provide(_matrixHome, domain) {
|
||||
observedDomains.push(domain);
|
||||
return { handle: domain };
|
||||
},
|
||||
};
|
||||
const root = new FakeContext();
|
||||
root.setService(RECORD_STORE_PROVIDER_KEY, provider);
|
||||
|
||||
for (const domain of ['pkg-a', 'pkg-b', 'system.observability']) {
|
||||
const ctx = new FakeContext(root);
|
||||
const actor = new TestPackageRoot();
|
||||
actor.setManifestProps({ storage: { sqlite: { domain } }, packageName: domain });
|
||||
actor.attachContext(ctx);
|
||||
await actor.runInitStorage();
|
||||
}
|
||||
|
||||
assert.deepEqual(observedDomains, ['pkg-a', 'pkg-b', 'system.observability']);
|
||||
});
|
||||
|
||||
it('seam key matches the symbol the runtime config registers under', () => {
|
||||
// Symbol.for is global so this is identity-equal across modules; the
|
||||
// assertion guards against accidental divergence in future edits.
|
||||
assert.equal(
|
||||
RECORD_STORE_PROVIDER_KEY,
|
||||
Symbol.for('matrix.record-store.provider'),
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── Runtime wiring (single integration assertion) ────────────────────────────
|
||||
|
||||
describe('MatrixRuntime registers IRecordStoreProvider on rootContext', () => {
|
||||
|
||||
it('places config.recordStoreProvider at RECORD_STORE_PROVIDER_KEY on rootContext', async () => {
|
||||
const provider: IRecordStoreProvider = { async provide() { return null; } };
|
||||
|
||||
const runtime = new MatrixRuntime({
|
||||
logging: false,
|
||||
recordStoreProvider: provider,
|
||||
});
|
||||
|
||||
try {
|
||||
const ctx = (runtime as unknown as { rootContext: { getService(k: symbol): unknown } }).rootContext;
|
||||
const found = ctx.getService(RECORD_STORE_PROVIDER_KEY);
|
||||
assert.equal(found, provider, 'runtime registered provider under expected key');
|
||||
} finally {
|
||||
await runtime.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not register a provider when config.recordStoreProvider is omitted', async () => {
|
||||
const runtime = new MatrixRuntime({ logging: false });
|
||||
|
||||
try {
|
||||
const ctx = (runtime as unknown as { rootContext: { getService(k: symbol): unknown } }).rootContext;
|
||||
const found = ctx.getService(RECORD_STORE_PROVIDER_KEY);
|
||||
assert.equal(found, undefined, 'no provider registered; consumers see undefined');
|
||||
} finally {
|
||||
await runtime.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
6
packages/core/src/core/actor/AcceptsDefinition.d.ts
vendored
Normal file
6
packages/core/src/core/actor/AcceptsDefinition.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Typed accepts definition for compile-time safety.
|
||||
* Maps command names to their expected payload types.
|
||||
*/
|
||||
export type AcceptsDefinition = Record<string, unknown>;
|
||||
//# sourceMappingURL=AcceptsDefinition.d.ts.map
|
||||
1
packages/core/src/core/actor/AcceptsDefinition.d.ts.map
Normal file
1
packages/core/src/core/actor/AcceptsDefinition.d.ts.map
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"AcceptsDefinition.d.ts","sourceRoot":"","sources":["AcceptsDefinition.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC"}
|
||||
7
packages/core/src/core/actor/AcceptsDefinition.ts
Normal file
7
packages/core/src/core/actor/AcceptsDefinition.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// src/core/actor/AcceptsDefinition.ts
|
||||
|
||||
/**
|
||||
* Typed accepts definition for compile-time safety.
|
||||
* Maps command names to their expected payload types.
|
||||
*/
|
||||
export type AcceptsDefinition = Record<string, unknown>;
|
||||
134
packages/core/src/core/actor/ActorBehavior.d.ts
vendored
Normal file
134
packages/core/src/core/actor/ActorBehavior.d.ts
vendored
Normal file
@ -0,0 +1,134 @@
|
||||
import type { IMailbox } from '../../engine/mailbox/IMailbox';
|
||||
import type { IMatrixContext } from '../../engine/core/IMatrixContext';
|
||||
import type { AcceptsDefinition } from './AcceptsDefinition';
|
||||
import type { IActorBehavior } from './IActorBehavior';
|
||||
import type { IDispatchStrategy } from './IDispatchStrategy';
|
||||
import type { IMessageMeta } from './IMessageMeta';
|
||||
import type { IDispatchPriorities } from './IDispatchPriorities';
|
||||
/** @deprecated Use IMessageMeta instead */
|
||||
type MessageMeta = IMessageMeta;
|
||||
/** @deprecated Use IDispatchPriorities instead */
|
||||
type DispatchPriorities = IDispatchPriorities;
|
||||
/**
|
||||
* Options for ActorBehavior construction.
|
||||
*/
|
||||
export interface IActorBehaviorOptions {
|
||||
/**
|
||||
* Whether to yield between messages (cooperative scheduling).
|
||||
* Default: true
|
||||
*/
|
||||
cooperativeYield?: boolean;
|
||||
}
|
||||
/** @deprecated Use IActorBehaviorOptions instead */
|
||||
export type ActorBehaviorOptions = IActorBehaviorOptions;
|
||||
/**
|
||||
* ActorBehavior - Encapsulates mailbox and handler dispatch.
|
||||
*
|
||||
* This class implements the composed actor behavior pattern:
|
||||
* - MatrixActor composes ActorBehavior (not extends)
|
||||
* - Mailbox ownership is exclusive to this behavior
|
||||
* - Handlers are registered and dispatched through strategies
|
||||
* - Erlang-style become() enables state machine patterns
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class MyActor extends MatrixActor {
|
||||
* protected onBootstrap() {
|
||||
* this.enableActorMode({
|
||||
* increment: this.handleIncrement.bind(this),
|
||||
* query: this.handleQuery.bind(this),
|
||||
* });
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare class ActorBehavior<TAccepts extends AcceptsDefinition = AcceptsDefinition> implements IActorBehavior<TAccepts> {
|
||||
private readonly mailbox;
|
||||
private readonly context;
|
||||
private readonly handlers;
|
||||
private readonly strategies;
|
||||
private readonly cooperativeYield;
|
||||
private priorities;
|
||||
private running;
|
||||
constructor(context: IMatrixContext, options?: IActorBehaviorOptions);
|
||||
get isRunning(): boolean;
|
||||
get address(): string;
|
||||
/**
|
||||
* Get the underlying mailbox for message queuing.
|
||||
*
|
||||
* This allows external code (like MatrixActor._bindAccepts) to queue
|
||||
* messages to the actor's mailbox. The ActorBehavior maintains ownership
|
||||
* and processes messages through its dispatch loop.
|
||||
*/
|
||||
getMailbox(): IMailbox;
|
||||
registerHandler<K extends keyof TAccepts & string>(command: K, handler: (payload: TAccepts[K], meta: MessageMeta) => Promise<unknown>): void;
|
||||
unregisterHandler(command: string): void;
|
||||
become(handlers: Partial<{
|
||||
[K in keyof TAccepts]: (payload: TAccepts[K], meta: MessageMeta) => Promise<unknown>;
|
||||
}>): void;
|
||||
addDispatchStrategy(strategy: IDispatchStrategy): void;
|
||||
removeDispatchStrategy(name: string): void;
|
||||
/**
|
||||
* Configure dispatch priorities.
|
||||
* Re-sorts strategies after update.
|
||||
*/
|
||||
configurePriorities(priorities: Partial<DispatchPriorities>): void;
|
||||
/**
|
||||
* Check if a handler exists for a command.
|
||||
*/
|
||||
hasHandler(command: string): boolean;
|
||||
/**
|
||||
* Get list of registered handler commands.
|
||||
*/
|
||||
listHandlers(): string[];
|
||||
start(): void;
|
||||
stop(): void;
|
||||
/**
|
||||
* Main message processing loop.
|
||||
*
|
||||
* Dispatch order:
|
||||
* 1. System commands ($* prefix) - built-in handling
|
||||
* 2. Dispatch strategies (in priority order)
|
||||
* 3. TypeScript handlers (fallback)
|
||||
*/
|
||||
private runLoop;
|
||||
/**
|
||||
* Dispatch a single message.
|
||||
*
|
||||
* Vision 27 Security: Messages are verified before dispatch.
|
||||
* Joe Armstrong: "Every message must be verified at the boundary."
|
||||
*/
|
||||
private dispatch;
|
||||
/**
|
||||
* Type guard for BecomePayload.
|
||||
*/
|
||||
private isBecomePayload;
|
||||
/**
|
||||
* Handle system commands ($* prefix).
|
||||
*
|
||||
* System commands:
|
||||
* - $close: Stop the actor
|
||||
* - $ping: Health check
|
||||
* - $introspect: Return handler info
|
||||
* - $become: Change behavior at runtime
|
||||
*/
|
||||
private handleSystemCommand;
|
||||
/**
|
||||
* Determine message direction for security verification.
|
||||
*
|
||||
* Vision 27: Direction determines trust level:
|
||||
* - parent-to-child: TRUSTED (supervisor relationship)
|
||||
* - child-to-parent: VERIFIED (requires capability)
|
||||
* - sibling: VERIFIED (requires capability)
|
||||
* - external: UNTRUSTED (requires capability + signature)
|
||||
*
|
||||
* Joe Armstrong (Erlang, "Programming Erlang"):
|
||||
* "In Erlang, every message knows who sent it."
|
||||
*
|
||||
* @param from - Sender's mount path
|
||||
* @returns Message direction for policy lookup
|
||||
*/
|
||||
private _determineDirection;
|
||||
}
|
||||
export {};
|
||||
//# sourceMappingURL=ActorBehavior.d.ts.map
|
||||
1
packages/core/src/core/actor/ActorBehavior.d.ts.map
Normal file
1
packages/core/src/core/actor/ActorBehavior.d.ts.map
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"ActorBehavior.d.ts","sourceRoot":"","sources":["ActorBehavior.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAiB,MAAM,+BAA+B,CAAC;AAC7E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kCAAkC,CAAC;AAEvE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAKjE,2CAA2C;AAC3C,KAAK,WAAW,GAAG,YAAY,CAAC;AAChC,kDAAkD;AAClD,KAAK,kBAAkB,GAAG,mBAAmB,CAAC;AAI9C;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,oDAAoD;AACpD,MAAM,MAAM,oBAAoB,GAAG,qBAAqB,CAAC;AAOzD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,aAAa,CAAC,QAAQ,SAAS,iBAAiB,GAAG,iBAAiB,CAC/E,YAAW,cAAc,CAAC,QAAQ,CAAC;IAEnC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAW;IACnC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAiB;IACzC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA8B;IACvD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA2B;IACtD,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAU;IAC3C,OAAO,CAAC,UAAU,CAAiD;IACnE,OAAO,CAAC,OAAO,CAAS;gBAEZ,OAAO,EAAE,cAAc,EAAE,OAAO,GAAE,qBAA0B;IAMxE,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED;;;;;;OAMG;IACH,UAAU,IAAI,QAAQ;IAItB,eAAe,CAAC,CAAC,SAAS,MAAM,QAAQ,GAAG,MAAM,EAC/C,OAAO,EAAE,CAAC,EACV,OAAO,EAAE,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,OAAO,CAAC,GACrE,IAAI;IAIP,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAIxC,MAAM,CAAC,QAAQ,EAAE,OAAO,CAAC;SACtB,CAAC,IAAI,MAAM,QAAQ,GAAG,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,OAAO,CAAC;KACrF,CAAC,GAAG,IAAI;IAST,mBAAmB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,IAAI;IAKtD,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAO1C;;;OAGG;IACH,mBAAmB,CAAC,UAAU,EAAE,OAAO,CAAC,kBAAkB,CAAC,GAAG,IAAI;IAKlE;;OAEG;IACH,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAKpC;;OAEG;IACH,YAAY,IAAI,MAAM,EAAE;IAIxB,KAAK,IAAI,IAAI;IAOb,IAAI,IAAI,IAAI;IAKZ;;;;;;;OAOG;YACW,OAAO;IAmBrB;;;;;OAKG;YACW,QAAQ;IAoGtB;;OAEG;IACH,OAAO,CAAC,eAAe;IASvB;;;;;;;;OAQG;YACW,mBAAmB;IA2CjC;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,mBAAmB;CA6B5B"}
|
||||
412
packages/core/src/core/actor/ActorBehavior.ts
Normal file
412
packages/core/src/core/actor/ActorBehavior.ts
Normal file
@ -0,0 +1,412 @@
|
||||
// src/core/actor/ActorBehavior.ts
|
||||
|
||||
import type { IMailbox, IActorMessage } from '../../engine/mailbox/IMailbox';
|
||||
import type { IMatrixContext } from '../../engine/core/IMatrixContext';
|
||||
import { RequestReplyHelper } from '../messaging/RequestReplyHelper';
|
||||
import type { AcceptsDefinition } from './AcceptsDefinition';
|
||||
import type { IActorBehavior } from './IActorBehavior';
|
||||
import type { IDispatchStrategy } from './IDispatchStrategy';
|
||||
import type { IMessageMeta } from './IMessageMeta';
|
||||
import type { IDispatchPriorities } from './IDispatchPriorities';
|
||||
import { DEFAULT_PRIORITIES } from './IDispatchPriorities';
|
||||
import type { IBecomePayload } from './IBecomePayload';
|
||||
import type { MessageDirection } from '../security/ISecurityRealm';
|
||||
|
||||
/** @deprecated Use IMessageMeta instead */
|
||||
type MessageMeta = IMessageMeta;
|
||||
/** @deprecated Use IDispatchPriorities instead */
|
||||
type DispatchPriorities = IDispatchPriorities;
|
||||
/** @deprecated Use IBecomePayload instead */
|
||||
type BecomePayload<T extends AcceptsDefinition = AcceptsDefinition> = IBecomePayload<T>;
|
||||
|
||||
/**
|
||||
* Options for ActorBehavior construction.
|
||||
*/
|
||||
export interface IActorBehaviorOptions {
|
||||
/**
|
||||
* Whether to yield between messages (cooperative scheduling).
|
||||
* Default: true
|
||||
*/
|
||||
cooperativeYield?: boolean;
|
||||
}
|
||||
|
||||
/** @deprecated Use IActorBehaviorOptions instead */
|
||||
export type ActorBehaviorOptions = IActorBehaviorOptions;
|
||||
|
||||
/**
|
||||
* Handler function type.
|
||||
*/
|
||||
type Handler = (payload: unknown, meta: MessageMeta) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* ActorBehavior - Encapsulates mailbox and handler dispatch.
|
||||
*
|
||||
* This class implements the composed actor behavior pattern:
|
||||
* - MatrixActor composes ActorBehavior (not extends)
|
||||
* - Mailbox ownership is exclusive to this behavior
|
||||
* - Handlers are registered and dispatched through strategies
|
||||
* - Erlang-style become() enables state machine patterns
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class MyActor extends MatrixActor {
|
||||
* protected onBootstrap() {
|
||||
* this.enableActorMode({
|
||||
* increment: this.handleIncrement.bind(this),
|
||||
* query: this.handleQuery.bind(this),
|
||||
* });
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class ActorBehavior<TAccepts extends AcceptsDefinition = AcceptsDefinition>
|
||||
implements IActorBehavior<TAccepts>
|
||||
{
|
||||
private readonly mailbox: IMailbox;
|
||||
private readonly context: IMatrixContext;
|
||||
private readonly handlers = new Map<string, Handler>();
|
||||
private readonly strategies: IDispatchStrategy[] = [];
|
||||
private readonly cooperativeYield: boolean;
|
||||
private priorities: DispatchPriorities = { ...DEFAULT_PRIORITIES };
|
||||
private running = false;
|
||||
|
||||
constructor(context: IMatrixContext, options: IActorBehaviorOptions = {}) {
|
||||
this.context = context;
|
||||
this.mailbox = context.runtime.createMailbox(context.mount);
|
||||
this.cooperativeYield = options.cooperativeYield ?? true;
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
get address(): string {
|
||||
return this.mailbox.address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying mailbox for message queuing.
|
||||
*
|
||||
* This allows external code (like MatrixActor._bindAccepts) to queue
|
||||
* messages to the actor's mailbox. The ActorBehavior maintains ownership
|
||||
* and processes messages through its dispatch loop.
|
||||
*/
|
||||
getMailbox(): IMailbox {
|
||||
return this.mailbox;
|
||||
}
|
||||
|
||||
registerHandler<K extends keyof TAccepts & string>(
|
||||
command: K,
|
||||
handler: (payload: TAccepts[K], meta: MessageMeta) => Promise<unknown>
|
||||
): void {
|
||||
this.handlers.set(command, handler as Handler);
|
||||
}
|
||||
|
||||
unregisterHandler(command: string): void {
|
||||
this.handlers.delete(command);
|
||||
}
|
||||
|
||||
become(handlers: Partial<{
|
||||
[K in keyof TAccepts]: (payload: TAccepts[K], meta: MessageMeta) => Promise<unknown>
|
||||
}>): void {
|
||||
this.handlers.clear();
|
||||
for (const [cmd, handler] of Object.entries(handlers)) {
|
||||
if (handler) {
|
||||
this.handlers.set(cmd, handler as Handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addDispatchStrategy(strategy: IDispatchStrategy): void {
|
||||
this.strategies.push(strategy);
|
||||
this.strategies.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
removeDispatchStrategy(name: string): void {
|
||||
const idx = this.strategies.findIndex(s => s.name === name);
|
||||
if (idx >= 0) {
|
||||
this.strategies.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure dispatch priorities.
|
||||
* Re-sorts strategies after update.
|
||||
*/
|
||||
configurePriorities(priorities: Partial<DispatchPriorities>): void {
|
||||
this.priorities = { ...this.priorities, ...priorities };
|
||||
this.strategies.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a handler exists for a command.
|
||||
*/
|
||||
hasHandler(command: string): boolean {
|
||||
if (this.handlers.has(command)) return true;
|
||||
return this.strategies.some(s => s.canHandle(command));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of registered handler commands.
|
||||
*/
|
||||
listHandlers(): string[] {
|
||||
return Array.from(this.handlers.keys());
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.mailbox.claim();
|
||||
this.runLoop();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
this.mailbox.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main message processing loop.
|
||||
*
|
||||
* Dispatch order:
|
||||
* 1. System commands ($* prefix) - built-in handling
|
||||
* 2. Dispatch strategies (in priority order)
|
||||
* 3. TypeScript handlers (fallback)
|
||||
*/
|
||||
private async runLoop(): Promise<void> {
|
||||
try {
|
||||
for await (const message of this.mailbox) {
|
||||
if (!this.running) break;
|
||||
await this.dispatch(message);
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.running) {
|
||||
// Publish exit signal
|
||||
this.context.transport.publish(`${this.context.mount}.$exit`, {
|
||||
type: 'exit',
|
||||
from: this.context.mount,
|
||||
reason: 'error',
|
||||
error: { message: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a single message.
|
||||
*
|
||||
* Vision 27 Security: Messages are verified before dispatch.
|
||||
* Joe Armstrong: "Every message must be verified at the boundary."
|
||||
*/
|
||||
private async dispatch(message: IActorMessage): Promise<void> {
|
||||
// Cooperative yield - allow other tasks to run
|
||||
if (this.cooperativeYield) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
const { command, payload } = message;
|
||||
const meta: MessageMeta = {
|
||||
ts: message.meta.ts,
|
||||
from: message.meta.from,
|
||||
correlationId: message.meta.correlationId,
|
||||
topic: `${this.context.mount}.accepts.${command}`,
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PRIORITY 1: System commands ($* prefix) - bypass normal dispatch
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
if (command.startsWith('$')) {
|
||||
await this.handleSystemCommand(command, payload, meta);
|
||||
return;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PRIORITY 1.5: SECURITY VERIFICATION (Vision 27)
|
||||
// Mark Miller: "The receiver decides whether to trust the sender."
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
if (this.context.securityRealm) {
|
||||
const direction = this._determineDirection(message.meta.from);
|
||||
const capabilityId = message.meta.capabilityId;
|
||||
|
||||
// Look up capability token if ID provided
|
||||
// Note: For cross-root verification, capability lookup would need
|
||||
// to consult a shared registry. For now, we verify against our security realm.
|
||||
const verificationResult = this.context.securityRealm.verifyMessage(
|
||||
direction,
|
||||
undefined, // TODO: Look up capability by ID from shared registry
|
||||
{
|
||||
principal: message.meta.from ?? 'unknown',
|
||||
target: this.context.mount,
|
||||
command,
|
||||
}
|
||||
);
|
||||
|
||||
if (!verificationResult.allowed) {
|
||||
// eslint-disable-next-line no-console -- security rejection diagnostics in kernel behavior layer
|
||||
console.warn(
|
||||
`[ActorBehavior] Security: Message rejected at ${this.context.mount}`,
|
||||
{
|
||||
command,
|
||||
from: message.meta.from,
|
||||
direction,
|
||||
reason: verificationResult.reason,
|
||||
}
|
||||
);
|
||||
// Send error reply if this was a request-reply
|
||||
if (RequestReplyHelper.isEnvelope(payload)) {
|
||||
RequestReplyHelper.sendErrorReply(
|
||||
this.context.transport,
|
||||
payload,
|
||||
new Error(`Security: ${verificationResult.reason}`)
|
||||
);
|
||||
}
|
||||
return; // BLOCK the message
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PRIORITY 2-N: Dispatch strategies (in priority order)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
for (const strategy of this.strategies) {
|
||||
if (strategy.canHandle(command)) {
|
||||
// Use executeWithReply (throws on error) instead of executeWithReplySafe
|
||||
// This allows errors to propagate to runLoop() for $exit signal
|
||||
await RequestReplyHelper.executeWithReply(
|
||||
this.context.transport,
|
||||
payload,
|
||||
(params) => strategy.handle(command, params ?? payload, meta)
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// FALLBACK: TypeScript handlers
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
const handler = this.handlers.get(command);
|
||||
if (handler) {
|
||||
// Use executeWithReply (throws on error) instead of executeWithReplySafe
|
||||
// This allows errors to propagate to runLoop() for $exit signal
|
||||
await RequestReplyHelper.executeWithReply(
|
||||
this.context.transport,
|
||||
payload,
|
||||
(params) => handler(params ?? payload, meta)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// No handler found - log warning
|
||||
// eslint-disable-next-line no-console -- missing command handlers should remain visible in kernel logs
|
||||
console.warn(`[ActorBehavior] No handler for command: ${command} at ${this.context.mount}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for BecomePayload.
|
||||
*/
|
||||
private isBecomePayload(payload: unknown): payload is BecomePayload<TAccepts> {
|
||||
return (
|
||||
payload !== null &&
|
||||
typeof payload === 'object' &&
|
||||
'handlers' in payload &&
|
||||
typeof (payload as BecomePayload).handlers === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle system commands ($* prefix).
|
||||
*
|
||||
* System commands:
|
||||
* - $close: Stop the actor
|
||||
* - $ping: Health check
|
||||
* - $introspect: Return handler info
|
||||
* - $become: Change behavior at runtime
|
||||
*/
|
||||
private async handleSystemCommand(
|
||||
command: string,
|
||||
payload: unknown,
|
||||
meta: MessageMeta
|
||||
): Promise<void> {
|
||||
switch (command) {
|
||||
case '$close':
|
||||
this.stop();
|
||||
break;
|
||||
|
||||
case '$ping':
|
||||
if (RequestReplyHelper.isEnvelope(payload)) {
|
||||
RequestReplyHelper.sendSuccessReply(this.context.transport, payload, {
|
||||
pong: true,
|
||||
mount: this.context.mount,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case '$introspect':
|
||||
if (RequestReplyHelper.isEnvelope(payload)) {
|
||||
RequestReplyHelper.sendSuccessReply(this.context.transport, payload, {
|
||||
mount: this.context.mount,
|
||||
handlers: this.listHandlers(),
|
||||
strategies: this.strategies.map(s => ({ name: s.name, priority: s.priority })),
|
||||
running: this.running,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case '$become':
|
||||
// Allow runtime behavior change via message
|
||||
if (this.isBecomePayload(payload)) {
|
||||
this.become(payload.handlers);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// eslint-disable-next-line no-console -- unknown system commands should remain visible in kernel logs
|
||||
console.warn(`[ActorBehavior] Unknown system command: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine message direction for security verification.
|
||||
*
|
||||
* Vision 27: Direction determines trust level:
|
||||
* - parent-to-child: TRUSTED (supervisor relationship)
|
||||
* - child-to-parent: VERIFIED (requires capability)
|
||||
* - sibling: VERIFIED (requires capability)
|
||||
* - external: UNTRUSTED (requires capability + signature)
|
||||
*
|
||||
* Joe Armstrong (Erlang, "Programming Erlang"):
|
||||
* "In Erlang, every message knows who sent it."
|
||||
*
|
||||
* @param from - Sender's mount path
|
||||
* @returns Message direction for policy lookup
|
||||
*/
|
||||
private _determineDirection(from: string | undefined): MessageDirection {
|
||||
if (!from) {
|
||||
// Unknown sender = external (most restrictive)
|
||||
return 'external';
|
||||
}
|
||||
|
||||
const myMount = this.context.mount;
|
||||
const parentMount = this.context.parent?.mount;
|
||||
|
||||
// Parent-to-child: Sender is our parent
|
||||
if (parentMount && from === parentMount) {
|
||||
return 'parent-to-child';
|
||||
}
|
||||
|
||||
// Child-to-parent: Sender's mount starts with our mount
|
||||
// e.g., myMount = "app.parent", from = "app.parent.child"
|
||||
if (from.startsWith(myMount + '.')) {
|
||||
return 'child-to-parent';
|
||||
}
|
||||
|
||||
// Sibling: Same parent, different child
|
||||
// e.g., parentMount = "app", myMount = "app.a", from = "app.b"
|
||||
if (parentMount && from.startsWith(parentMount + '.') && from !== myMount) {
|
||||
return 'sibling';
|
||||
}
|
||||
|
||||
// Everything else is external
|
||||
return 'external';
|
||||
}
|
||||
}
|
||||
52
packages/core/src/core/actor/IActorBehavior.d.ts
vendored
Normal file
52
packages/core/src/core/actor/IActorBehavior.d.ts
vendored
Normal file
@ -0,0 +1,52 @@
|
||||
import type { AcceptsDefinition } from './AcceptsDefinition';
|
||||
import type { IMessageMeta } from './IMessageMeta';
|
||||
import type { IDispatchStrategy } from './IDispatchStrategy';
|
||||
/** @deprecated Use IMessageMeta instead */
|
||||
type MessageMeta = IMessageMeta;
|
||||
/**
|
||||
* Actor behavior interface.
|
||||
*
|
||||
* This is the composed behavior pattern - components don't extend ActorBehavior,
|
||||
* they compose it via enableActorMode().
|
||||
*/
|
||||
export interface IActorBehavior<TAccepts extends AcceptsDefinition = AcceptsDefinition> {
|
||||
/**
|
||||
* Register a typed handler for a command.
|
||||
*/
|
||||
registerHandler<K extends keyof TAccepts & string>(command: K, handler: (payload: TAccepts[K], meta: MessageMeta) => Promise<unknown>): void;
|
||||
/**
|
||||
* Change all handlers atomically (Erlang's become).
|
||||
*
|
||||
* This replaces ALL handlers at once, enabling state machine patterns
|
||||
* where the actor's entire behavior changes based on state.
|
||||
*/
|
||||
become(handlers: Partial<{
|
||||
[K in keyof TAccepts]: (payload: TAccepts[K], meta: MessageMeta) => Promise<unknown>;
|
||||
}>): void;
|
||||
/**
|
||||
* Start processing messages from the mailbox.
|
||||
*/
|
||||
start(): void;
|
||||
/**
|
||||
* Stop processing and close the mailbox.
|
||||
*/
|
||||
stop(): void;
|
||||
/**
|
||||
* Add a dispatch strategy for pluggable message routing.
|
||||
*/
|
||||
addDispatchStrategy(strategy: IDispatchStrategy): void;
|
||||
/**
|
||||
* Remove a dispatch strategy by name.
|
||||
*/
|
||||
removeDispatchStrategy(name: string): void;
|
||||
/**
|
||||
* Whether the actor is currently running.
|
||||
*/
|
||||
readonly isRunning: boolean;
|
||||
/**
|
||||
* The actor's mailbox address.
|
||||
*/
|
||||
readonly address: string;
|
||||
}
|
||||
export {};
|
||||
//# sourceMappingURL=IActorBehavior.d.ts.map
|
||||
1
packages/core/src/core/actor/IActorBehavior.d.ts.map
Normal file
1
packages/core/src/core/actor/IActorBehavior.d.ts.map
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"IActorBehavior.d.ts","sourceRoot":"","sources":["IActorBehavior.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,2CAA2C;AAC3C,KAAK,WAAW,GAAG,YAAY,CAAC;AAEhC;;;;;GAKG;AACH,MAAM,WAAW,cAAc,CAAC,QAAQ,SAAS,iBAAiB,GAAG,iBAAiB;IACpF;;OAEG;IACH,eAAe,CAAC,CAAC,SAAS,MAAM,QAAQ,GAAG,MAAM,EAC/C,OAAO,EAAE,CAAC,EACV,OAAO,EAAE,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,OAAO,CAAC,GACrE,IAAI,CAAC;IAER;;;;;OAKG;IACH,MAAM,CAAC,QAAQ,EAAE,OAAO,CAAC;SACtB,CAAC,IAAI,MAAM,QAAQ,GAAG,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,OAAO,CAAC;KACrF,CAAC,GAAG,IAAI,CAAC;IAEV;;OAEG;IACH,KAAK,IAAI,IAAI,CAAC;IAEd;;OAEG;IACH,IAAI,IAAI,IAAI,CAAC;IAEb;;OAEG;IACH,mBAAmB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAEvD;;OAEG;IACH,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAE3C;;OAEG;IACH,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAE5B;;OAEG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B"}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user