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