chore: consolidate new foundation and archive v1 (#1495)

This commit is contained in:
2026-09-07 12:32:57 -05:00
3511 changed files with 727899 additions and 10 deletions
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@mosaicstack/agent",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/agent"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@mosaicstack/types": "workspace:*"
},
"devDependencies": {
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
"access": "public"
},
"files": [
"dist"
]
}
@@ -0,0 +1,261 @@
import { describe, expect, it, vi } from 'vitest';
import type {
ConnectorExecutionContext,
ConnectorExecutionGrant,
ConnectorLease,
ConnectorLeaseAuditEvent,
ConnectorLeaseStore,
FencedConnectorAdapter,
LogicalAgentBinding,
} from '@mosaicstack/types';
import {
ConnectorLeaseCoordinator,
MAX_CONNECTOR_GRANT_TTL_MS,
MAX_CONNECTOR_LEASE_TTL_MS,
} from './connector-lease.js';
import type { ConnectorLeaseError } from './connector-lease.js';
const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const;
const binding: LogicalAgentBinding = { identity, bindingId: 'operator-chat' };
const activeLease: ConnectorLease = {
...binding,
leaseId: '00000000-0000-4000-8000-000000000001',
connectorId: 'connector-a',
scopes: ['runtime.send', 'tool.execute'],
leaseEpoch: '3',
acquiredAt: '2026-07-14T17:00:00.000Z',
heartbeatAt: '2026-07-14T17:00:00.000Z',
expiresAt: '2026-07-14T17:10:00.000Z',
};
class FakeLeaseStore implements ConnectorLeaseStore {
lease: ConnectorLease | null = activeLease;
readonly audits: ConnectorLeaseAuditEvent[] = [];
async acquire(): Promise<ConnectorLease> {
if (!this.lease) throw new Error('fixture has no lease');
return this.lease;
}
async takeover(): Promise<ConnectorLease> {
if (!this.lease) throw new Error('fixture has no lease');
return this.lease;
}
async heartbeat(): Promise<ConnectorLease> {
if (!this.lease) throw new Error('fixture has no lease');
return this.lease;
}
async release(): Promise<void> {}
async findCurrent(): Promise<ConnectorLease | null> {
return this.lease;
}
async recordAudit(event: ConnectorLeaseAuditEvent): Promise<void> {
this.audits.push(event);
}
}
describe('ConnectorLeaseCoordinator fencing', (): void => {
it('validates a server-minted grant immediately before invoking an adapter side effect', async (): Promise<void> => {
const store = new FakeLeaseStore();
const coordinator = new ConnectorLeaseCoordinator(store, {
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
});
const grant = await coordinator.issueGrant({
lease: activeLease,
scopes: ['runtime.send'],
ttlMs: 30_000,
correlationId: 'correlation-1',
});
const execute = vi.fn(async (_input: string, context: ConnectorExecutionContext) => context);
const adapter: FencedConnectorAdapter<string, ConnectorExecutionContext> = { execute };
const context = await coordinator.executeGrant(grant, 'runtime.send', 'hello', adapter);
expect(execute).toHaveBeenCalledOnce();
expect(context).toMatchObject({
identity,
bindingId: 'operator-chat',
connectorId: 'connector-a',
leaseEpoch: '3',
scopes: ['runtime.send'],
});
});
it('caps grant expiry to the durable current lease instead of submitted metadata', async (): Promise<void> => {
const store = new FakeLeaseStore();
store.lease = { ...activeLease, expiresAt: '2026-07-14T17:01:05.000Z' };
const coordinator = new ConnectorLeaseCoordinator(store, {
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
});
const grant = await coordinator.issueGrant({
lease: { ...activeLease, expiresAt: '2026-07-14T18:00:00.000Z' },
scopes: ['runtime.send'],
ttlMs: 30_000,
correlationId: 'correlation-durable-expiry',
});
expect(grant.expiresAt).toBe('2026-07-14T17:01:05.000Z');
});
it.each([
['forged clone', (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ ...grant })],
[
'cross-tenant clone',
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
...grant,
identity: { ...grant.identity, tenantId: 'tenant-b' },
}),
],
[
'cross-agent clone',
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
...grant,
identity: { ...grant.identity, logicalAgentId: 'other-agent' },
}),
],
[
'cross-binding clone',
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
...grant,
bindingId: 'other-binding',
}),
],
[
'cross-connector clone',
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
...grant,
connectorId: 'connector-b',
}),
],
])('denies and audits a %s before adapter invocation', async (_label, forge): Promise<void> => {
const store = new FakeLeaseStore();
const coordinator = new ConnectorLeaseCoordinator(store, {
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
});
const grant = await coordinator.issueGrant({
lease: activeLease,
scopes: ['runtime.send'],
ttlMs: 30_000,
correlationId: 'correlation-forged',
});
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
await expect(
coordinator.executeGrant(forge(grant), 'runtime.send', undefined, adapter),
).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial<ConnectorLeaseError>);
expect(adapter.execute).not.toHaveBeenCalled();
expect(store.audits.at(-1)).toMatchObject({
event: 'reject',
outcome: 'denied',
reason: 'forged_grant',
correlationId: 'correlation-forged',
});
});
it('denies malformed forged grants with sanitized audit metadata', async (): Promise<void> => {
const store = new FakeLeaseStore();
const coordinator = new ConnectorLeaseCoordinator(store, {
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
});
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
await expect(
coordinator.executeGrant(
// @ts-expect-error Deliberately exercise malformed runtime input at the trust boundary.
{},
'runtime.send',
undefined,
adapter,
),
).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial<ConnectorLeaseError>);
expect(adapter.execute).not.toHaveBeenCalled();
expect(store.audits).toContainEqual(
expect.objectContaining({
identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' },
bindingId: 'untrusted',
connectorId: 'untrusted',
correlationId: 'untrusted',
reason: 'forged_grant',
}),
);
});
it('rejects lease and grant TTLs above server-side safety caps', async (): Promise<void> => {
const store = new FakeLeaseStore();
const coordinator = new ConnectorLeaseCoordinator(store, {
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
});
expect(
() =>
new ConnectorLeaseCoordinator(store, {
maxLeaseTtlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1,
}),
).toThrow(/no greater than/);
await expect(
coordinator.acquire({
identity,
bindingId: 'operator-chat',
connectorId: 'connector-a',
scopes: ['runtime.send'],
ttlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1,
correlationId: 'correlation-ttl',
}),
).rejects.toThrow(/no greater than/);
await expect(
coordinator.issueGrant({
lease: activeLease,
scopes: ['runtime.send'],
ttlMs: MAX_CONNECTOR_GRANT_TTL_MS + 1,
correlationId: 'correlation-ttl',
}),
).rejects.toThrow(/no greater than/);
});
it('denies stale epoch, expired grant, and unauthorized scope before side effects', async (): Promise<void> => {
let now = new Date('2026-07-14T17:01:00.000Z');
const store = new FakeLeaseStore();
const coordinator = new ConnectorLeaseCoordinator(store, { now: (): Date => now });
const stale = await coordinator.issueGrant({
lease: activeLease,
scopes: ['runtime.send'],
ttlMs: 30_000,
correlationId: 'correlation-stale',
});
store.lease = { ...activeLease, leaseEpoch: '4', connectorId: 'connector-b' };
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
await expect(
coordinator.executeGrant(stale, 'runtime.send', undefined, adapter),
).rejects.toMatchObject({ code: 'stale_epoch' } satisfies Partial<ConnectorLeaseError>);
store.lease = activeLease;
const expiring = await coordinator.issueGrant({
lease: activeLease,
scopes: ['runtime.send'],
ttlMs: 1_000,
correlationId: 'correlation-expired',
});
now = new Date('2026-07-14T17:01:02.000Z');
await expect(
coordinator.executeGrant(expiring, 'runtime.send', undefined, adapter),
).rejects.toMatchObject({ code: 'grant_expired' } satisfies Partial<ConnectorLeaseError>);
now = new Date('2026-07-14T17:01:00.000Z');
const scoped = await coordinator.issueGrant({
lease: activeLease,
scopes: ['runtime.send'],
ttlMs: 30_000,
correlationId: 'correlation-scope',
});
await expect(
coordinator.executeGrant(scoped, 'tool.execute', undefined, adapter),
).rejects.toMatchObject({ code: 'scope_denied' } satisfies Partial<ConnectorLeaseError>);
expect(adapter.execute).not.toHaveBeenCalled();
});
});
+381
View File
@@ -0,0 +1,381 @@
import { randomUUID } from 'node:crypto';
import {
normalizeConnectorId,
normalizeConnectorScope,
normalizeConnectorScopes,
normalizeCorrelationId,
normalizeLeaseEpoch,
normalizeLogicalAgentIdentity,
normalizeLogicalBindingId,
type AcquireConnectorLeaseInput,
type ConnectorExecutionContext,
type ConnectorExecutionGrant,
type ConnectorLease,
type ConnectorLeaseAuditEvent,
type ConnectorLeaseRejectReason,
type ConnectorLeaseStore,
type FencedConnectorAdapter,
type HeartbeatConnectorLeaseInput,
type IssueConnectorExecutionGrantInput,
type LogicalAgentBinding,
type ReleaseConnectorLeaseInput,
type TakeoverConnectorLeaseInput,
} from '@mosaicstack/types';
export const MAX_CONNECTOR_LEASE_TTL_MS = 5 * 60 * 1000;
export const MAX_CONNECTOR_GRANT_TTL_MS = 30 * 1000;
export interface ConnectorLeaseCoordinatorOptions {
readonly now?: () => Date;
readonly maxLeaseTtlMs?: number;
readonly maxGrantTtlMs?: number;
}
export class ConnectorLeaseError extends Error {
constructor(
readonly code: ConnectorLeaseRejectReason,
message: string,
) {
super(message);
this.name = ConnectorLeaseError.name;
}
}
/**
* Runtime-neutral lease coordinator. Durable CAS lives in the store adapter;
* grant provenance remains process-local so a restart fails closed and mints
* fresh grants from the durable current lease.
*/
export class ConnectorLeaseCoordinator {
private readonly issuedGrants = new WeakSet<object>();
private readonly now: () => Date;
private readonly maxLeaseTtlMs: number;
private readonly maxGrantTtlMs: number;
constructor(
private readonly store: ConnectorLeaseStore,
options: ConnectorLeaseCoordinatorOptions = {},
) {
this.now = options.now ?? (() => new Date());
this.maxLeaseTtlMs = normalizeTtlLimit(
options.maxLeaseTtlMs ?? MAX_CONNECTOR_LEASE_TTL_MS,
MAX_CONNECTOR_LEASE_TTL_MS,
'lease',
);
this.maxGrantTtlMs = normalizeTtlLimit(
options.maxGrantTtlMs ?? MAX_CONNECTOR_GRANT_TTL_MS,
MAX_CONNECTOR_GRANT_TTL_MS,
'grant',
);
}
async acquire(input: AcquireConnectorLeaseInput): Promise<ConnectorLease> {
const command = normalizeAcquireInput(input, this.maxLeaseTtlMs);
const now = this.now();
return this.store.acquire({
...command,
leaseId: randomUUID(),
now: now.toISOString(),
expiresAt: expiresAt(now, command.ttlMs),
});
}
async takeover(input: TakeoverConnectorLeaseInput): Promise<ConnectorLease> {
const command = normalizeAcquireInput(input, this.maxLeaseTtlMs);
const now = this.now();
return this.store.takeover({
...command,
expectedEpoch: normalizeLeaseEpoch(input.expectedEpoch),
leaseId: randomUUID(),
now: now.toISOString(),
expiresAt: expiresAt(now, command.ttlMs),
});
}
async heartbeat(input: HeartbeatConnectorLeaseInput): Promise<ConnectorLease> {
const now = this.now();
const lease = normalizeConnectorLease(input.lease);
const ttlMs = normalizeTtl(input.ttlMs, this.maxLeaseTtlMs, 'lease');
return this.store.heartbeat({
lease,
ttlMs,
correlationId: normalizeCorrelationId(input.correlationId),
now: now.toISOString(),
expiresAt: expiresAt(now, ttlMs),
});
}
async release(input: ReleaseConnectorLeaseInput): Promise<void> {
await this.store.release({
lease: normalizeConnectorLease(input.lease),
correlationId: normalizeCorrelationId(input.correlationId),
now: this.now().toISOString(),
});
}
async current(binding: LogicalAgentBinding): Promise<ConnectorLease | null> {
return this.store.findCurrent(normalizeBinding(binding));
}
async issueGrant(input: IssueConnectorExecutionGrantInput): Promise<ConnectorExecutionGrant> {
const now = this.now();
const lease = normalizeConnectorLease(input.lease);
const scopes = normalizeConnectorScopes(input.scopes);
const correlationId = normalizeCorrelationId(input.correlationId);
const ttlMs = normalizeTtl(input.ttlMs, this.maxGrantTtlMs, 'grant');
const current = await this.store.findCurrent(lease);
await this.assertCurrentLease(current, lease, now, correlationId);
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
if (!isScopeSubset(scopes, lease.scopes) || !isScopeSubset(scopes, current.scopes)) {
await this.reject(lease, correlationId, now, 'scope_denied');
}
const requestedExpiry = new Date(now.getTime() + ttlMs);
const leaseExpiry = new Date(current.expiresAt);
const grantExpiry = requestedExpiry < leaseExpiry ? requestedExpiry : leaseExpiry;
const grant: ConnectorExecutionGrant = Object.freeze({
identity: current.identity,
bindingId: current.bindingId,
leaseId: current.leaseId,
connectorId: current.connectorId,
scopes,
leaseEpoch: current.leaseEpoch,
issuedAt: now.toISOString(),
expiresAt: grantExpiry.toISOString(),
correlationId,
});
this.issuedGrants.add(grant);
return grant;
}
async executeGrant<TInput, TOutput>(
grant: ConnectorExecutionGrant,
requiredScope: string,
input: TInput,
adapter: FencedConnectorAdapter<TInput, TOutput>,
): Promise<TOutput> {
const now = this.now();
const normalizedScope = normalizeConnectorScope(requiredScope);
if (!this.issuedGrants.has(grant)) {
await this.rejectForgedGrant(grant, now);
}
if (new Date(grant.expiresAt) <= now) {
await this.rejectGrant(grant, now, 'grant_expired');
}
const current = await this.store.findCurrent(normalizeBinding(grant));
await this.assertCurrentLease(current, grant, now, grant.correlationId);
if (!grant.scopes.includes(normalizedScope) || !current?.scopes.includes(normalizedScope)) {
await this.rejectGrant(grant, now, 'scope_denied');
}
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
const context: ConnectorExecutionContext = Object.freeze({
identity: current.identity,
bindingId: current.bindingId,
leaseId: current.leaseId,
connectorId: current.connectorId,
scopes: Object.freeze([...grant.scopes]),
leaseEpoch: current.leaseEpoch,
correlationId: grant.correlationId,
grantExpiresAt: grant.expiresAt,
});
return adapter.execute(input, context);
}
private async assertCurrentLease(
current: ConnectorLease | null,
authority: ConnectorLease | ConnectorExecutionGrant,
now: Date,
correlationId: string,
): Promise<void> {
if (!current) await this.reject(authority, correlationId, now, 'lease_missing');
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
if (current.releasedAt) await this.reject(authority, correlationId, now, 'lease_released');
if (new Date(current.expiresAt) <= now) {
await this.store.recordAudit(auditEvent(current, correlationId, now, 'expiry', 'succeeded'));
await this.reject(authority, correlationId, now, 'lease_expired');
}
if (current.leaseEpoch !== authority.leaseEpoch) {
await this.reject(authority, correlationId, now, 'stale_epoch');
}
if (current.leaseId !== authority.leaseId || current.connectorId !== authority.connectorId) {
await this.reject(authority, correlationId, now, 'connector_mismatch');
}
}
private async rejectGrant(
grant: ConnectorExecutionGrant,
now: Date,
reason: ConnectorLeaseRejectReason,
): Promise<never> {
return this.reject(grant, grant.correlationId, now, reason);
}
private async rejectForgedGrant(grant: unknown, now: Date): Promise<never> {
const event = safeForgedGrantAudit(grant, now);
await this.store.recordAudit(event);
throw new ConnectorLeaseError('forged_grant', safeReasonMessage('forged_grant'));
}
private async reject(
authority: LogicalAgentBinding & {
readonly connectorId: string;
readonly leaseId?: string;
readonly leaseEpoch?: string;
},
correlationId: string,
now: Date,
reason: ConnectorLeaseRejectReason,
): Promise<never> {
await this.store.recordAudit(
auditEvent(authority, correlationId, now, 'reject', 'denied', reason),
);
throw new ConnectorLeaseError(reason, safeReasonMessage(reason));
}
}
function normalizeAcquireInput(
input: AcquireConnectorLeaseInput,
maxLeaseTtlMs: number,
): AcquireConnectorLeaseInput {
return {
identity: normalizeLogicalAgentIdentity(input.identity),
bindingId: normalizeLogicalBindingId(input.bindingId),
connectorId: normalizeConnectorId(input.connectorId),
scopes: normalizeConnectorScopes(input.scopes),
ttlMs: normalizeTtl(input.ttlMs, maxLeaseTtlMs, 'lease'),
correlationId: normalizeCorrelationId(input.correlationId),
};
}
function normalizeBinding(input: LogicalAgentBinding): LogicalAgentBinding {
return {
identity: normalizeLogicalAgentIdentity(input.identity),
bindingId: normalizeLogicalBindingId(input.bindingId),
};
}
export function normalizeConnectorLease(lease: ConnectorLease): ConnectorLease {
const binding = normalizeBinding(lease);
return Object.freeze({
...binding,
leaseId: lease.leaseId,
connectorId: normalizeConnectorId(lease.connectorId),
scopes: normalizeConnectorScopes(lease.scopes),
leaseEpoch: normalizeLeaseEpoch(lease.leaseEpoch),
acquiredAt: normalizeTimestamp(lease.acquiredAt, 'lease acquisition'),
heartbeatAt: normalizeTimestamp(lease.heartbeatAt, 'lease heartbeat'),
expiresAt: normalizeTimestamp(lease.expiresAt, 'lease expiry'),
...(lease.releasedAt
? { releasedAt: normalizeTimestamp(lease.releasedAt, 'lease release') }
: {}),
});
}
function normalizeTtl(ttlMs: number, maximum: number, kind: 'lease' | 'grant'): number {
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > maximum) {
throw new Error(
`Connector ${kind} TTL must be a positive safe integer no greater than ${maximum}ms`,
);
}
return ttlMs;
}
function normalizeTtlLimit(ttlMs: number, hardMaximum: number, kind: 'lease' | 'grant'): number {
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > hardMaximum) {
throw new Error(
`Maximum connector ${kind} TTL must be a positive safe integer no greater than ${hardMaximum}ms`,
);
}
return ttlMs;
}
function normalizeTimestamp(value: string, label: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) throw new Error(`${label} timestamp is invalid`);
return date.toISOString();
}
function expiresAt(now: Date, ttlMs: number): string {
const expiry = new Date(now.getTime() + ttlMs);
if (Number.isNaN(expiry.getTime())) throw new Error('Connector lease TTL exceeds date range');
return expiry.toISOString();
}
function isScopeSubset(requested: readonly string[], allowed: readonly string[]): boolean {
return requested.every((scope) => allowed.includes(scope));
}
function auditEvent(
authority: LogicalAgentBinding & {
readonly connectorId: string;
readonly leaseId?: string;
readonly leaseEpoch?: string;
},
correlationId: string,
now: Date,
event: ConnectorLeaseAuditEvent['event'],
outcome: ConnectorLeaseAuditEvent['outcome'],
reason?: ConnectorLeaseRejectReason,
): ConnectorLeaseAuditEvent {
return {
identity: authority.identity,
bindingId: authority.bindingId,
connectorId: authority.connectorId,
correlationId,
occurredAt: now.toISOString(),
event,
outcome,
...(authority.leaseId ? { leaseId: authority.leaseId } : {}),
...(authority.leaseEpoch ? { leaseEpoch: authority.leaseEpoch } : {}),
...(reason ? { reason } : {}),
};
}
function safeForgedGrantAudit(grant: unknown, now: Date): ConnectorLeaseAuditEvent {
const fallback: ConnectorLeaseAuditEvent = {
identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' },
bindingId: 'untrusted',
connectorId: 'untrusted',
correlationId: 'untrusted',
occurredAt: now.toISOString(),
event: 'reject',
outcome: 'denied',
reason: 'forged_grant',
};
if (typeof grant !== 'object' || grant === null || !('identity' in grant)) return fallback;
const identity = grant.identity;
if (typeof identity !== 'object' || identity === null) return fallback;
if (!('tenantId' in identity) || !('logicalAgentId' in identity)) return fallback;
if (!('bindingId' in grant) || !('connectorId' in grant) || !('correlationId' in grant)) {
return fallback;
}
if (
typeof identity.tenantId !== 'string' ||
typeof identity.logicalAgentId !== 'string' ||
typeof grant.bindingId !== 'string' ||
typeof grant.connectorId !== 'string' ||
typeof grant.correlationId !== 'string'
) {
return fallback;
}
try {
return {
identity: normalizeLogicalAgentIdentity({
tenantId: identity.tenantId,
logicalAgentId: identity.logicalAgentId,
}),
bindingId: normalizeLogicalBindingId(grant.bindingId),
connectorId: normalizeConnectorId(grant.connectorId),
correlationId: normalizeCorrelationId(grant.correlationId),
occurredAt: now.toISOString(),
event: 'reject',
outcome: 'denied',
reason: 'forged_grant',
};
} catch {
return fallback;
}
}
function safeReasonMessage(reason: ConnectorLeaseRejectReason): string {
return `Connector authority denied: ${reason}`;
}
@@ -0,0 +1,305 @@
import { describe, expect, it } from 'vitest';
import {
DurableSessionCoordinator,
InMemoryDurableSessionStore,
type DurableSessionIdentity,
} from './durable-session.js';
const IDENTITY: DurableSessionIdentity = {
agentName: 'Nova',
sessionId: 'tess-session-1',
tenantId: 'tenant-1',
ownerId: 'owner-1',
providerId: 'fleet',
runtimeSessionId: 'nova',
};
describe('DurableSessionCoordinator', () => {
it('reconstructs an exact session identity, pending inbox/outbox, checkpoint, and handoff after a simulated process restart', async () => {
const store = new InMemoryDurableSessionStore();
const beforeRestart = new DurableSessionCoordinator(store);
await beforeRestart.create(IDENTITY);
await beforeRestart.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'ingress-1',
correlationId: 'correlation-1',
content: 'continue the session',
});
await beforeRestart.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-1',
correlationId: 'correlation-1',
channelId: 'cli',
kind: 'provider.send',
content: 'resumable response',
});
await beforeRestart.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-1',
cursor: 'cursor-42',
summary: 'operator asked for recovery proof',
compactionEpoch: 0,
});
await beforeRestart.handoff({
sessionId: IDENTITY.sessionId,
handoffId: 'handoff-1',
destination: 'mos',
correlationId: 'correlation-1',
checkpointId: 'checkpoint-1',
status: 'pending',
});
// Simulate an ungraceful process death: no in-memory coordinator state survives.
const afterRestart = new DurableSessionCoordinator(store);
const recovered = await afterRestart.recover(IDENTITY.sessionId);
expect(recovered.identity).toEqual(IDENTITY);
expect(recovered.inbox).toMatchObject([{ idempotencyKey: 'ingress-1', status: 'pending' }]);
expect(recovered.outbox).toMatchObject([{ idempotencyKey: 'outbox-1', status: 'pending' }]);
expect(recovered.checkpoint).toMatchObject({
checkpointId: 'checkpoint-1',
cursor: 'cursor-42',
});
expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-1', status: 'pending' }]);
});
it('rebinds a recovered runtime while preserving the immutable conversation owner scope', async () => {
const coordinator = new DurableSessionCoordinator(new InMemoryDurableSessionStore());
await coordinator.create(IDENTITY);
await coordinator.create({
...IDENTITY,
providerId: 'fleet-next',
runtimeSessionId: 'nova-next',
});
await expect(coordinator.snapshot(IDENTITY.sessionId)).resolves.toMatchObject({
identity: { ...IDENTITY, providerId: 'fleet-next', runtimeSessionId: 'nova-next' },
});
await expect(coordinator.create({ ...IDENTITY, ownerId: 'other-owner' })).rejects.toThrow(
/identity conflict/,
);
});
it('deduplicates duplicate ingress and never reprocesses an inbox record after restart or compaction', async () => {
const store = new InMemoryDurableSessionStore();
const firstProcess = new DurableSessionCoordinator(store);
const handled: string[] = [];
await firstProcess.create(IDENTITY);
await expect(
firstProcess.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'ingress-duplicate',
correlationId: 'correlation-2',
content: 'only process me once',
}),
).resolves.toMatchObject({ accepted: true });
await expect(
firstProcess.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'ingress-duplicate',
correlationId: 'correlation-2',
content: 'only process me once',
}),
).resolves.toMatchObject({ accepted: false, status: 'pending' });
await expect(
firstProcess.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'ingress-duplicate',
correlationId: 'forged-correlation',
content: 'only process me once',
}),
).rejects.toThrow(/idempotency conflict/);
await firstProcess.drainInbox(IDENTITY.sessionId, async (entry) => {
handled.push(entry.idempotencyKey);
});
await firstProcess.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-after-inbox',
cursor: 'cursor-43',
summary: 'safe to compact',
compactionEpoch: 1,
});
const afterRestartAndCompaction = new DurableSessionCoordinator(store);
await afterRestartAndCompaction.recover(IDENTITY.sessionId);
await afterRestartAndCompaction.drainInbox(IDENTITY.sessionId, async (entry) => {
handled.push(entry.idempotencyKey);
});
expect(handled).toEqual(['ingress-duplicate']);
});
it('does not redispatch an already applied outbox side effect after replay, restart, or compaction', async () => {
const store = new InMemoryDurableSessionStore();
const beforeRestart = new DurableSessionCoordinator(store);
const appliedEffects: string[] = [];
await beforeRestart.create(IDENTITY);
await beforeRestart.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'effect-1',
correlationId: 'correlation-3',
channelId: 'cli',
kind: 'provider.send',
content: 'send exactly once',
});
await expect(
beforeRestart.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'effect-1',
correlationId: 'correlation-3',
channelId: 'cli',
kind: 'provider.send',
content: 'send exactly once',
}),
).resolves.toMatchObject({ accepted: false, status: 'pending' });
await beforeRestart.dispatchOutbox(IDENTITY.sessionId, async (entry) => {
appliedEffects.push(entry.idempotencyKey);
});
await beforeRestart.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-after-effect',
cursor: 'cursor-44',
summary: 'effect persisted before compaction',
compactionEpoch: 1,
});
const afterRestartAndCompaction = new DurableSessionCoordinator(store);
await afterRestartAndCompaction.recover(IDENTITY.sessionId);
await afterRestartAndCompaction.dispatchOutbox(IDENTITY.sessionId, async (entry) => {
appliedEffects.push(entry.idempotencyKey);
});
expect(appliedEffects).toEqual(['effect-1']);
});
it('rejects outbox idempotency-key reuse when immutable effect data differs', async () => {
const store = new InMemoryDurableSessionStore();
const coordinator = new DurableSessionCoordinator(store);
await coordinator.create(IDENTITY);
await coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-conflict',
correlationId: 'correlation-outbox',
channelId: 'cli',
kind: 'provider.send',
content: 'original effect',
});
await expect(
coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-conflict',
correlationId: 'correlation-outbox',
channelId: 'forged-channel',
kind: 'provider.send',
content: 'original effect',
}),
).rejects.toThrow(/idempotency conflict/);
});
it('retains an ambiguous failed provider effect as processing until explicit recovery', async () => {
const store = new InMemoryDurableSessionStore();
const beforeRestart = new DurableSessionCoordinator(store);
await beforeRestart.create(IDENTITY);
await beforeRestart.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'ambiguous-effect',
correlationId: 'correlation-ambiguous',
channelId: 'cli',
kind: 'provider.send',
content: 'preserve this effect claim',
});
await expect(
beforeRestart.dispatchOutbox(IDENTITY.sessionId, async (): Promise<void> => {
throw new Error('provider connection dropped after submit');
}),
).rejects.toThrow(/connection dropped/);
const afterRestart = new DurableSessionCoordinator(store);
await afterRestart.recover(IDENTITY.sessionId);
const calls: string[] = [];
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
calls.push(entry.idempotencyKey);
});
expect(calls).toEqual([]);
expect(await afterRestart.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [{ idempotencyKey: 'ambiguous-effect', status: 'processing' }],
});
});
it('rejects a handoff-id replay with different immutable state', async () => {
const store = new InMemoryDurableSessionStore();
const coordinator = new DurableSessionCoordinator(store);
await coordinator.create(IDENTITY);
await coordinator.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-conflict',
cursor: 'cursor-conflict',
summary: 'handoff conflict proof',
compactionEpoch: 0,
});
await coordinator.handoff({
sessionId: IDENTITY.sessionId,
handoffId: 'handoff-conflict',
destination: 'mos',
correlationId: 'correlation-conflict',
checkpointId: 'checkpoint-conflict',
status: 'pending',
});
await expect(
coordinator.handoff({
sessionId: IDENTITY.sessionId,
handoffId: 'handoff-conflict',
destination: 'forged-destination',
correlationId: 'correlation-conflict',
checkpointId: 'checkpoint-conflict',
status: 'pending',
}),
).rejects.toThrow(/identity conflict/);
});
it('keeps a handoff portable and resumes it from its durable checkpoint without process-local references', async () => {
const store = new InMemoryDurableSessionStore();
const source = new DurableSessionCoordinator(store);
await source.create(IDENTITY);
await source.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-handoff',
cursor: 'cursor-45',
summary: 'portable state',
compactionEpoch: 2,
});
await source.handoff({
sessionId: IDENTITY.sessionId,
handoffId: 'handoff-portable',
destination: 'mos',
correlationId: 'correlation-4',
checkpointId: 'checkpoint-handoff',
status: 'pending',
});
await source.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-later',
cursor: 'cursor-46',
summary: 'newer compacted state must not strand the handoff',
compactionEpoch: 3,
});
const receivingProcess = new DurableSessionCoordinator(store);
const handoff = await receivingProcess.resumeHandoff('handoff-portable');
expect(handoff.identity).toEqual(IDENTITY);
expect(handoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-handoff' });
expect(handoff.handoff).toMatchObject({ handoffId: 'handoff-portable', destination: 'mos' });
});
});
+498
View File
@@ -0,0 +1,498 @@
export interface DurableSessionIdentity {
/** Provisioned roster identity; isolates durable state between named agents. */
agentName: string;
sessionId: string;
tenantId: string;
ownerId: string;
providerId: string;
runtimeSessionId: string;
}
export type DurableInboxStatus = 'pending' | 'processing' | 'processed';
export type DurableOutboxStatus = 'pending' | 'processing' | 'delivered';
export interface DurableInboxInput {
sessionId: string;
idempotencyKey: string;
correlationId: string;
content: string;
}
export interface DurableInboxEntry extends DurableInboxInput {
status: DurableInboxStatus;
}
export interface DurableOutboxInput {
sessionId: string;
idempotencyKey: string;
correlationId: string;
channelId: string;
kind: string;
content: string;
}
export interface DurableOutboxEntry extends DurableOutboxInput {
status: DurableOutboxStatus;
}
export interface DurableCheckpointInput {
sessionId: string;
checkpointId: string;
cursor: string;
summary: string;
compactionEpoch: number;
}
export interface DurableCheckpoint extends DurableCheckpointInput {}
export interface DurableHandoffInput {
sessionId: string;
handoffId: string;
destination: string;
correlationId: string;
checkpointId: string;
status: 'pending' | 'accepted';
}
export interface DurableHandoff extends DurableHandoffInput {}
export interface DurableSessionSnapshot {
identity: DurableSessionIdentity;
inbox: DurableInboxEntry[];
outbox: DurableOutboxEntry[];
checkpoint?: DurableCheckpoint;
handoffs: DurableHandoff[];
}
export interface DurableHandoffRecovery {
identity: DurableSessionIdentity;
checkpoint: DurableCheckpoint;
handoff: DurableHandoff;
}
export interface DurableEnqueueResult<TStatus extends string> {
accepted: boolean;
status: TStatus;
}
/**
* A durable-state port. Implementations must atomically claim and complete work
* records. Recovery may requeue interrupted inbox work, but never an
* externally visible outbox effect: an ambiguous provider result remains
* claimed until a separately authorized reconciliation proves it safe.
*/
export interface DurableSessionStore {
create(identity: DurableSessionIdentity): Promise<void>;
snapshot(sessionId: string): Promise<DurableSessionSnapshot | null>;
enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>>;
claimInbox(sessionId: string): Promise<DurableInboxEntry | null>;
completeInbox(sessionId: string, idempotencyKey: string): Promise<void>;
releaseInbox(sessionId: string, idempotencyKey: string): Promise<void>;
enqueueOutbox(input: DurableOutboxInput): Promise<DurableEnqueueResult<DurableOutboxStatus>>;
claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null>;
claimOutboxByKey(sessionId: string, idempotencyKey: string): Promise<DurableOutboxEntry | null>;
completeOutbox(sessionId: string, idempotencyKey: string): Promise<void>;
releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void>;
checkpoint(input: DurableCheckpointInput): Promise<void>;
findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null>;
handoff(input: DurableHandoffInput): Promise<void>;
findHandoff(handoffId: string): Promise<DurableHandoff | null>;
requeueInFlight(sessionId: string): Promise<void>;
}
export class DurableSessionNotFoundError extends Error {
constructor(sessionId: string) {
super(`Durable session not found: ${sessionId}`);
this.name = 'DurableSessionNotFoundError';
}
}
export class DurableSessionCoordinator {
constructor(private readonly store: DurableSessionStore) {}
async create(identity: DurableSessionIdentity): Promise<void> {
this.assertIdentity(identity);
await this.store.create(identity);
}
async receive(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
this.assertRecord(input.sessionId, input.idempotencyKey, input.correlationId, input.content);
return this.store.enqueueInbox(input);
}
async enqueueOutbox(
input: DurableOutboxInput,
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
this.assertRecord(
input.sessionId,
input.idempotencyKey,
input.correlationId,
input.channelId,
input.content,
);
if (input.kind.trim().length === 0) throw new Error('Durable outbox kind is required');
return this.store.enqueueOutbox(input);
}
async checkpoint(input: DurableCheckpointInput): Promise<void> {
this.assertRecord(input.sessionId, input.checkpointId, input.cursor, input.summary);
if (!Number.isInteger(input.compactionEpoch) || input.compactionEpoch < 0) {
throw new Error('Durable checkpoint compaction epoch must be a non-negative integer');
}
await this.store.checkpoint(input);
}
async handoff(input: DurableHandoffInput): Promise<void> {
this.assertRecord(input.sessionId, input.handoffId, input.destination, input.correlationId);
if (input.checkpointId.trim().length === 0)
throw new Error('Durable handoff checkpoint is required');
await this.store.handoff(input);
}
/** Read durable state without changing claim status; safe during normal operation. */
async snapshot(sessionId: string): Promise<DurableSessionSnapshot> {
const snapshot = await this.store.snapshot(sessionId);
if (!snapshot) throw new DurableSessionNotFoundError(sessionId);
return snapshot;
}
/** Requeue interrupted inbox work during recovery; preserve ambiguous outbox claims. */
async recover(sessionId: string): Promise<DurableSessionSnapshot> {
await this.store.requeueInFlight(sessionId);
return this.snapshot(sessionId);
}
async drainInbox(
sessionId: string,
handler: (entry: DurableInboxEntry) => Promise<void>,
): Promise<void> {
for (;;) {
const entry = await this.store.claimInbox(sessionId);
if (!entry) return;
try {
await handler(entry);
} catch (error: unknown) {
await this.store.releaseInbox(sessionId, entry.idempotencyKey);
throw error;
}
// If this write fails after the handler succeeded, leave the record
// processing. A recovery path can retry it with its stable idempotency key.
await this.store.completeInbox(sessionId, entry.idempotencyKey);
}
}
async dispatchOutbox(
sessionId: string,
dispatcher: (entry: DurableOutboxEntry) => Promise<void>,
): Promise<void> {
for (;;) {
const entry = await this.store.claimOutbox(sessionId);
if (!entry) return;
// A provider failure can be ambiguous: it may occur after the receiver
// accepted the idempotency key. Preserve the claim for reconciliation.
await dispatcher(entry);
// Do not requeue an effect after it has been applied but before its
// terminal state could be persisted; recovery preserves the claim.
await this.store.completeOutbox(sessionId, entry.idempotencyKey);
}
}
async dispatchOutboxEntry(
sessionId: string,
idempotencyKey: string,
dispatcher: (entry: DurableOutboxEntry) => Promise<void>,
): Promise<void> {
const entry = await this.store.claimOutboxByKey(sessionId, idempotencyKey);
if (!entry) return;
// A provider failure can be ambiguous, so this stays processing until
// separately authorized reconciliation proves it safe to resolve.
await dispatcher(entry);
await this.store.completeOutbox(sessionId, entry.idempotencyKey);
}
async resumeHandoff(handoffId: string): Promise<DurableHandoffRecovery> {
const handoff = await this.store.findHandoff(handoffId);
if (!handoff) throw new Error(`Durable handoff not found: ${handoffId}`);
const snapshot = await this.snapshot(handoff.sessionId);
const checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId);
if (!checkpoint) {
throw new Error(`Durable handoff checkpoint is unavailable: ${handoff.checkpointId}`);
}
return { identity: snapshot.identity, checkpoint, handoff };
}
private assertIdentity(identity: DurableSessionIdentity): void {
this.assertRecord(
identity.agentName,
identity.sessionId,
identity.tenantId,
identity.ownerId,
identity.providerId,
);
if (identity.runtimeSessionId.trim().length === 0) {
throw new Error('Durable runtime session identity is required');
}
}
private assertRecord(...values: string[]): void {
if (values.some((value: string): boolean => value.trim().length === 0)) {
throw new Error('Durable session records require non-empty fields');
}
}
}
interface InMemorySessionState {
identity: DurableSessionIdentity;
inbox: Map<string, DurableInboxEntry>;
outbox: Map<string, DurableOutboxEntry>;
checkpoints: Map<string, DurableCheckpoint>;
handoffs: Map<string, DurableHandoff>;
}
/** Reference store for deterministic domain tests; production uses the gateway DB adapter. */
export class InMemoryDurableSessionStore implements DurableSessionStore {
private readonly sessions = new Map<string, InMemorySessionState>();
async create(identity: DurableSessionIdentity): Promise<void> {
const existing = this.sessions.get(identity.sessionId);
if (existing) {
if (!sameEnrollmentScope(existing.identity, identity)) {
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
}
existing.identity.providerId = identity.providerId;
existing.identity.runtimeSessionId = identity.runtimeSessionId;
return;
}
this.sessions.set(identity.sessionId, {
identity: copyIdentity(identity),
inbox: new Map(),
outbox: new Map(),
checkpoints: new Map(),
handoffs: new Map(),
});
}
async snapshot(sessionId: string): Promise<DurableSessionSnapshot | null> {
const state = this.sessions.get(sessionId);
if (!state) return null;
const checkpoint = latestCheckpoint(state.checkpoints);
return {
identity: copyIdentity(state.identity),
inbox: [...state.inbox.values()].map(copyInbox),
outbox: [...state.outbox.values()].map(copyOutbox),
...(checkpoint ? { checkpoint: copyCheckpoint(checkpoint) } : {}),
handoffs: [...state.handoffs.values()].map(copyHandoff),
};
}
async enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
const state = this.require(input.sessionId);
const existing = state.inbox.get(input.idempotencyKey);
if (existing) {
if (!sameInbox(existing, input)) {
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: existing.status };
}
state.inbox.set(input.idempotencyKey, { ...input, status: 'pending' });
return { accepted: true, status: 'pending' };
}
async claimInbox(sessionId: string): Promise<DurableInboxEntry | null> {
const state = this.require(sessionId);
const entry = [...state.inbox.values()].find(
(candidate: DurableInboxEntry): boolean => candidate.status === 'pending',
);
if (!entry) return null;
entry.status = 'processing';
return copyInbox(entry);
}
async completeInbox(sessionId: string, idempotencyKey: string): Promise<void> {
this.requireEntry(this.require(sessionId).inbox, idempotencyKey, 'inbox').status = 'processed';
}
async releaseInbox(sessionId: string, idempotencyKey: string): Promise<void> {
this.requireEntry(this.require(sessionId).inbox, idempotencyKey, 'inbox').status = 'pending';
}
async enqueueOutbox(
input: DurableOutboxInput,
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
const state = this.require(input.sessionId);
const existing = state.outbox.get(input.idempotencyKey);
if (existing) {
if (!sameOutbox(existing, input)) {
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: existing.status };
}
state.outbox.set(input.idempotencyKey, { ...input, status: 'pending' });
return { accepted: true, status: 'pending' };
}
async claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null> {
const state = this.require(sessionId);
const entry = [...state.outbox.values()].find(
(candidate: DurableOutboxEntry): boolean => candidate.status === 'pending',
);
if (!entry) return null;
entry.status = 'processing';
return copyOutbox(entry);
}
async claimOutboxByKey(
sessionId: string,
idempotencyKey: string,
): Promise<DurableOutboxEntry | null> {
const entry = this.require(sessionId).outbox.get(idempotencyKey);
if (!entry || entry.status !== 'pending') return null;
entry.status = 'processing';
return copyOutbox(entry);
}
async completeOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
this.requireEntry(this.require(sessionId).outbox, idempotencyKey, 'outbox').status =
'delivered';
}
async releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
this.requireEntry(this.require(sessionId).outbox, idempotencyKey, 'outbox').status = 'pending';
}
async checkpoint(input: DurableCheckpointInput): Promise<void> {
const checkpoints = this.require(input.sessionId).checkpoints;
const existing = checkpoints.get(input.checkpointId);
if (existing && !sameCheckpoint(existing, input)) {
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
}
if (!existing) checkpoints.set(input.checkpointId, { ...input });
}
async findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null> {
const checkpoint = this.require(sessionId).checkpoints.get(checkpointId);
return checkpoint ? copyCheckpoint(checkpoint) : null;
}
async handoff(input: DurableHandoffInput): Promise<void> {
const state = this.require(input.sessionId);
if (!state.checkpoints.has(input.checkpointId)) {
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
}
const existing = state.handoffs.get(input.handoffId);
if (existing && !sameHandoff(existing, input)) {
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
}
if (!existing) state.handoffs.set(input.handoffId, { ...input });
}
async findHandoff(handoffId: string): Promise<DurableHandoff | null> {
for (const state of this.sessions.values()) {
const handoff = state.handoffs.get(handoffId);
if (handoff) return copyHandoff(handoff);
}
return null;
}
async requeueInFlight(sessionId: string): Promise<void> {
const state = this.require(sessionId);
for (const entry of state.inbox.values()) {
if (entry.status === 'processing') entry.status = 'pending';
}
}
private require(sessionId: string): InMemorySessionState {
const state = this.sessions.get(sessionId);
if (!state) throw new DurableSessionNotFoundError(sessionId);
return state;
}
private requireEntry<T extends { status: string }>(
records: Map<string, T>,
idempotencyKey: string,
kind: string,
): T {
const entry = records.get(idempotencyKey);
if (!entry) throw new Error(`Durable ${kind} entry not found: ${idempotencyKey}`);
return entry;
}
}
function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean {
return (
left.agentName === right.agentName &&
left.sessionId === right.sessionId &&
left.tenantId === right.tenantId &&
left.ownerId === right.ownerId
);
}
function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean {
return (
left.sessionId === right.sessionId &&
left.idempotencyKey === right.idempotencyKey &&
left.correlationId === right.correlationId &&
left.content === right.content
);
}
function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean {
return (
left.sessionId === right.sessionId &&
left.idempotencyKey === right.idempotencyKey &&
left.correlationId === right.correlationId &&
left.channelId === right.channelId &&
left.kind === right.kind &&
left.content === right.content
);
}
function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean {
return (
left.sessionId === right.sessionId &&
left.checkpointId === right.checkpointId &&
left.cursor === right.cursor &&
left.summary === right.summary &&
left.compactionEpoch === right.compactionEpoch
);
}
function latestCheckpoint(
checkpoints: Map<string, DurableCheckpoint>,
): DurableCheckpoint | undefined {
return [...checkpoints.values()].sort(
(left: DurableCheckpoint, right: DurableCheckpoint): number =>
right.compactionEpoch - left.compactionEpoch,
)[0];
}
function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean {
return (
left.sessionId === right.sessionId &&
left.handoffId === right.handoffId &&
left.destination === right.destination &&
left.correlationId === right.correlationId &&
left.checkpointId === right.checkpointId &&
left.status === right.status
);
}
function copyIdentity(identity: DurableSessionIdentity): DurableSessionIdentity {
return { ...identity };
}
function copyInbox(entry: DurableInboxEntry): DurableInboxEntry {
return { ...entry };
}
function copyOutbox(entry: DurableOutboxEntry): DurableOutboxEntry {
return { ...entry };
}
function copyCheckpoint(checkpoint: DurableCheckpoint): DurableCheckpoint {
return { ...checkpoint };
}
function copyHandoff(handoff: DurableHandoff): DurableHandoff {
return { ...handoff };
}
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';
import type { RuntimeScope } from '@mosaicstack/types';
import { HermesRuntimeProvider, type HermesRuntimeTransport } from './hermes-runtime-provider.js';
const scope: RuntimeScope = { actorId: 'a', tenantId: 't', channelId: 'c', correlationId: 'r' };
const transport = (capabilities = ['session.list', 'session.tree']): HermesRuntimeTransport => ({
capabilities: vi.fn(async () => capabilities),
health: vi.fn(async () => ({ status: 'healthy' })),
sessions: vi.fn(async () => [
{
conversation_id: 'child',
agent_id: 'hermes-a',
parent_conversation_id: 'parent',
status: 'running',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
{
conversation_id: 'parent',
agent_id: 'hermes-a',
status: 'unknown',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
]),
stream: async function* () {},
send: vi.fn(),
attach: vi.fn(),
detach: vi.fn(),
terminate: vi.fn(),
});
describe('HermesRuntimeProvider normalization boundary', () => {
it('returns an exhaustive fail-closed transitional capability matrix', async () => {
const provider = new HermesRuntimeProvider(transport());
await expect(provider.transitionalCapabilityMatrix(scope)).resolves.toEqual([
{ capability: 'kanban', status: 'unsupported' },
{ capability: 'skills', status: 'unsupported' },
{ capability: 'memory', status: 'unsupported' },
{ capability: 'tools', status: 'unsupported' },
{ capability: 'cron', status: 'unsupported' },
]);
});
it('denies unsupported transitional capabilities without calling Hermes', async () => {
const hermes = transport();
const provider = new HermesRuntimeProvider(hermes);
await expect(provider.assertTransitionalCapability('memory', scope)).rejects.toMatchObject({
code: 'capability_unsupported',
});
expect(hermes.capabilities).not.toHaveBeenCalled();
});
it('normalizes legacy sessions without exposing legacy fields', async () => {
const provider = new HermesRuntimeProvider(transport());
await expect(provider.listSessions(scope)).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'child', runtimeId: 'hermes-a', state: 'active' }),
]),
);
const result = await provider.listSessions(scope);
expect(result[0]).not.toHaveProperty('conversation_id');
});
it('forms normalized hierarchy and fails closed for unbridged operations', async () => {
const provider = new HermesRuntimeProvider(transport());
await expect(provider.getSessionTree(scope)).resolves.toEqual([
expect.objectContaining({
session: expect.objectContaining({ id: 'parent', state: 'failed' }),
children: [expect.objectContaining({ session: expect.objectContaining({ id: 'child' }) })],
}),
]);
await expect(
provider.sendMessage('parent', { content: 'x', idempotencyKey: 'i' }, scope),
).rejects.toMatchObject({ code: 'capability_unsupported' });
});
});
@@ -0,0 +1,217 @@
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapability,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionState,
RuntimeSessionTree,
RuntimeStreamEvent,
TransitionalCapabilityInventoryEntry,
TransitionalCapabilityInventoryProvider,
TransitionalRuntimeCapability,
} from '@mosaicstack/types';
const HERMES_PROVIDER_ID = 'runtime.hermes';
const TRANSITIONAL_CAPABILITIES: readonly TransitionalRuntimeCapability[] = [
'kanban',
'skills',
'memory',
'tools',
'cron',
];
const RUNTIME_CAPABILITIES: readonly RuntimeCapability[] = [
'session.list',
'session.tree',
'session.stream',
'session.send',
'session.attach',
'session.terminate',
];
/** Legacy transport boundary. These shapes are intentionally adapter-local. */
export interface HermesLegacySession {
conversation_id: string;
agent_id: string;
parent_conversation_id?: string;
status: string;
created_at: string;
updated_at: string;
}
export interface HermesRuntimeTransport {
capabilities(scope: RuntimeScope): Promise<string[]>;
health(scope: RuntimeScope): Promise<{ status: string; detail?: string }>;
sessions(scope: RuntimeScope): Promise<HermesLegacySession[]>;
stream(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent>;
send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void>;
attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle>;
detach(attachmentId: string, scope: RuntimeScope): Promise<void>;
terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void>;
}
export class HermesRuntimeProviderError extends Error {
constructor(
readonly code: 'capability_unsupported' | 'invalid_request',
message: string,
) {
super(message);
this.name = HermesRuntimeProviderError.name;
}
}
/**
* Transitional Hermes adapter. Legacy identifiers and schemas do not cross this
* boundary: callers only observe Mosaic AgentRuntimeProvider contracts.
*/
export class HermesRuntimeProvider
implements AgentRuntimeProvider, TransitionalCapabilityInventoryProvider
{
readonly id = HERMES_PROVIDER_ID;
constructor(private readonly transport: HermesRuntimeTransport) {}
/**
* Full AC-TESS-05 migration inventory. These operations are deliberately
* unsupported until their Mosaic-owned plugin contracts exist.
*/
async transitionalCapabilityMatrix(
_scope: RuntimeScope,
): Promise<TransitionalCapabilityInventoryEntry[]> {
return TRANSITIONAL_CAPABILITIES.map((capability) => ({ capability, status: 'unsupported' }));
}
async assertTransitionalCapability(
capability: TransitionalRuntimeCapability,
scope: RuntimeScope,
): Promise<void> {
const entry = (await this.transitionalCapabilityMatrix(scope)).find(
(candidate) => candidate.capability === capability,
);
if (!entry || entry.status !== 'supported') {
throw new HermesRuntimeProviderError(
'capability_unsupported',
`Hermes transitional capability is unsupported: ${capability}`,
);
}
}
async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
const legacyCapabilities = await this.transport.capabilities(scope);
return {
supported: RUNTIME_CAPABILITIES.filter((capability) =>
legacyCapabilities.includes(capability),
),
};
}
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
const health = await this.transport.health(scope);
return {
status: health.status === 'healthy' || health.status === 'degraded' ? health.status : 'down',
checkedAt: new Date().toISOString(),
...(health.detail ? { detail: health.detail } : {}),
};
}
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
await this.requireCapability('session.list', scope);
return (await this.transport.sessions(scope)).map((session) => this.session(session));
}
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
await this.requireCapability('session.tree', scope);
const sessions = (await this.transport.sessions(scope)).map((session) => this.session(session));
const nodes = new Map<string, RuntimeSessionTree>(
sessions.map((session) => [session.id, { session, children: [] }]),
);
const roots: RuntimeSessionTree[] = [];
for (const session of sessions) {
const node = nodes.get(session.id)!;
const parent = session.parentSessionId ? nodes.get(session.parentSessionId) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
return roots;
}
async *streamSession(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
await this.requireCapability('session.stream', scope);
yield* this.transport.stream(sessionId, cursor, scope);
}
async sendMessage(
sessionId: string,
message: RuntimeMessage,
scope: RuntimeScope,
): Promise<void> {
await this.requireCapability('session.send', scope);
if (!message.content.trim())
throw new HermesRuntimeProviderError('invalid_request', 'Message content is required');
await this.transport.send(sessionId, message, scope);
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
await this.requireCapability('session.attach', scope);
return this.transport.attach(sessionId, mode, scope);
}
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
await this.transport.detach(attachmentId, scope);
}
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
await this.requireCapability('session.terminate', scope);
if (!approvalRef.trim())
throw new HermesRuntimeProviderError('invalid_request', 'Termination approval is required');
await this.transport.terminate(sessionId, approvalRef, scope);
}
private async requireCapability(
capability: RuntimeCapability,
scope: RuntimeScope,
): Promise<void> {
if (!(await this.capabilities(scope)).supported.includes(capability)) {
throw new HermesRuntimeProviderError(
'capability_unsupported',
`Hermes does not bridge ${capability}`,
);
}
}
private session(value: HermesLegacySession): RuntimeSession {
return {
id: value.conversation_id,
providerId: this.id,
runtimeId: value.agent_id,
...(value.parent_conversation_id ? { parentSessionId: value.parent_conversation_id } : {}),
state: state(value.status),
createdAt: value.created_at,
updatedAt: value.updated_at,
};
}
}
function state(value: string): RuntimeSessionState {
return (
(
{ running: 'active', waiting: 'idle', starting: 'starting', stopped: 'stopped' } as Record<
string,
RuntimeSessionState
>
)[value] ?? 'failed'
);
}
+8
View File
@@ -0,0 +1,8 @@
export const VERSION = '0.0.0';
export * from './runtime-provider-registry.js';
export * from './tmux-fleet-runtime-provider.js';
export * from './hermes-runtime-provider.js';
export * from './matrix-native-runtime-provider.js';
export * from './durable-session.js';
export * from './connector-lease.js';
@@ -0,0 +1,153 @@
import { describe, expect, it, vi } from 'vitest';
import type { RuntimeScope, RuntimeStreamEvent } from '@mosaicstack/types';
import {
type MatrixRuntimeSession,
type MatrixRuntimeTransport,
MatrixNativeRuntimeProvider,
type MatrixReadAuthority,
type MatrixWriteAuthority,
} from './matrix-native-runtime-provider.js';
const scope: RuntimeScope = {
actorId: 'operator-1',
tenantId: 'tenant-a',
channelId: 'matrix-control',
correlationId: 'corr-1',
};
const session: MatrixRuntimeSession = {
id: 'native-1',
runtimeId: '@worker:example.test',
state: 'active',
createdAt: '2026-07-13T00:00:00.000Z',
updatedAt: '2026-07-13T00:00:00.000Z',
};
function transport(): MatrixRuntimeTransport {
return {
health: vi.fn(async () => ({
status: 'healthy' as const,
checkedAt: '2026-07-13T00:00:00.000Z',
})),
listSessions: vi.fn(async () => [session]),
verifySession: vi.fn(async (sessionId) => {
if (sessionId !== session.id) throw new Error('unexpected session');
return session;
}),
stream: vi.fn(async function* (): AsyncIterable<RuntimeStreamEvent> {
yield {
type: 'message.delta',
sessionId: 'native-1',
cursor: 'cursor-1',
occurredAt: '2026-07-13T00:00:00.000Z',
content: 'hello',
};
}),
send: vi.fn(async () => undefined),
terminate: vi.fn(async () => undefined),
};
}
function readAuthority(): MatrixReadAuthority {
return { canRead: vi.fn(async () => true) };
}
function writeAuthority(): MatrixWriteAuthority {
return {
canWrite: vi.fn(async () => true),
assertAuthorized: vi.fn(async () => undefined),
};
}
describe('MatrixNativeRuntimeProvider contract boundary', (): void => {
it('advertises the concrete Matrix operations and returns only normalized sessions', async (): Promise<void> => {
const provider = new MatrixNativeRuntimeProvider({
transport: transport(),
readAuthority: readAuthority(),
});
await expect(provider.capabilities(scope)).resolves.toEqual({
supported: [
'session.list',
'session.tree',
'session.stream',
'session.send',
'session.attach',
'session.terminate',
],
});
await expect(provider.listSessions(scope)).resolves.toEqual([
expect.objectContaining({ id: 'native-1', providerId: 'runtime.matrix' }),
]);
});
it('binds read attachments to immutable scope and rejects control mode before transport access', async (): Promise<void> => {
const matrix = transport();
const provider = new MatrixNativeRuntimeProvider({
transport: matrix,
readAuthority: readAuthority(),
attachmentIdFactory: () => 'attachment-1',
now: () => new Date('2026-07-13T00:00:00.000Z'),
});
await expect(provider.attach('native-1', 'control', scope)).rejects.toMatchObject({
code: 'forbidden',
});
expect(matrix.verifySession).not.toHaveBeenCalled();
await provider.attach('native-1', 'read', scope);
await expect(
provider.detach('attachment-1', { ...scope, tenantId: 'other' }),
).rejects.toMatchObject({
code: 'forbidden',
});
});
it('validates messages and applies exact Matrix authority after bound-session verification', async (): Promise<void> => {
const matrix = transport();
const writes = writeAuthority();
const provider = new MatrixNativeRuntimeProvider({
transport: matrix,
readAuthority: readAuthority(),
writeAuthority: writes,
});
await expect(
provider.sendMessage('native-1', { content: '', idempotencyKey: 'msg-1' }, scope),
).rejects.toMatchObject({
code: 'invalid_request',
});
expect(matrix.verifySession).not.toHaveBeenCalled();
await provider.sendMessage('native-1', { content: 'hello', idempotencyKey: 'msg-1' }, scope);
expect(writes.assertAuthorized).toHaveBeenCalledWith({
operation: 'session.send',
sessionId: 'native-1',
scope,
});
expect(matrix.send).toHaveBeenCalledWith(
'native-1',
{ content: 'hello', idempotencyKey: 'msg-1' },
scope,
);
});
it('streams only after exact read authorization', async (): Promise<void> => {
const matrix = transport();
const provider = new MatrixNativeRuntimeProvider({
transport: matrix,
readAuthority: readAuthority(),
});
await expect(collect(provider.streamSession('native-1', 'cursor-0', scope))).resolves.toEqual([
expect.objectContaining({ type: 'message.delta', sessionId: 'native-1' }),
]);
expect(matrix.stream).toHaveBeenCalledWith('native-1', 'cursor-0', scope);
});
});
async function collect(stream: AsyncIterable<RuntimeStreamEvent>): Promise<RuntimeStreamEvent[]> {
const values: RuntimeStreamEvent[] = [];
for await (const value of stream) values.push(value);
return values;
}
@@ -0,0 +1,351 @@
import { randomUUID } from 'node:crypto';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
} from '@mosaicstack/types';
const MATRIX_PROVIDER_ID = 'runtime.matrix';
const ATTACHMENT_TTL_MS = 5 * 60 * 1_000;
/** A verified native runtime session. Matrix room and event details remain transport-local. */
export interface MatrixRuntimeSession {
id: string;
runtimeId: string;
parentSessionId?: string;
state: RuntimeSession['state'];
createdAt: string;
updatedAt: string;
}
/**
* Narrow native Matrix boundary. The concrete Mosaic transport owns homeserver
* authentication, exact room mapping, Matrix identity checks, and replay cursors.
*/
export interface MatrixRuntimeTransport {
health(scope: RuntimeScope): Promise<RuntimeHealth>;
listSessions(scope: RuntimeScope): Promise<MatrixRuntimeSession[]>;
verifySession(sessionId: string, scope: RuntimeScope): Promise<MatrixRuntimeSession>;
stream(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent>;
send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void>;
terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void>;
}
export type MatrixRuntimeProviderErrorCode =
| 'capability_unsupported'
| 'forbidden'
| 'invalid_request'
| 'not_found';
export class MatrixRuntimeProviderError extends Error {
constructor(
readonly code: MatrixRuntimeProviderErrorCode,
message: string,
) {
super(message);
this.name = MatrixRuntimeProviderError.name;
}
}
export type MatrixReadOperation =
| 'runtime.health'
| 'session.list'
| 'session.tree'
| 'session.stream'
| 'session.attach';
export interface MatrixReadAuthority {
canRead(input: {
operation: MatrixReadOperation;
scope: RuntimeScope;
sessionId?: string;
}): Promise<boolean>;
}
export interface MatrixWriteAuthority {
canWrite(input: {
operation: 'session.send' | 'session.terminate';
sessionId: string;
scope: RuntimeScope;
approvalRef?: string;
}): Promise<boolean>;
assertAuthorized(input: {
operation: 'session.send' | 'session.terminate';
sessionId: string;
scope: RuntimeScope;
approvalRef?: string;
}): Promise<void>;
}
export interface MatrixNativeRuntimeProviderOptions {
transport: MatrixRuntimeTransport;
readAuthority?: MatrixReadAuthority;
writeAuthority?: MatrixWriteAuthority;
attachmentIdFactory?: () => string;
now?: () => Date;
attachmentTtlMs?: number;
}
interface Attachment {
sessionId: string;
scope: RuntimeScope;
expiresAtMs: number;
}
class DenyMatrixReadAuthority implements MatrixReadAuthority {
async canRead(): Promise<boolean> {
return false;
}
}
class DenyMatrixWriteAuthority implements MatrixWriteAuthority {
async canWrite(): Promise<boolean> {
return false;
}
async assertAuthorized(): Promise<void> {
throw new MatrixRuntimeProviderError(
'forbidden',
'Matrix runtime writes require orchestrator authority',
);
}
}
/**
* Native Matrix adapter behind the Mosaic runtime contract. It accepts only
* stable session IDs; room identifiers, Matrix event schemas, and credentials
* are deliberately confined to the concrete transport implementation.
*/
export class MatrixNativeRuntimeProvider implements AgentRuntimeProvider {
readonly id = MATRIX_PROVIDER_ID;
private readonly readAuthority: MatrixReadAuthority;
private readonly writeAuthority: MatrixWriteAuthority;
private readonly attachmentIdFactory: () => string;
private readonly now: () => Date;
private readonly attachmentTtlMs: number;
private readonly attachments = new Map<string, Attachment>();
constructor(private readonly options: MatrixNativeRuntimeProviderOptions) {
this.readAuthority = options.readAuthority ?? new DenyMatrixReadAuthority();
this.writeAuthority = options.writeAuthority ?? new DenyMatrixWriteAuthority();
this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID;
this.now = options.now ?? (() => new Date());
this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS;
}
async capabilities(_scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
return {
supported: [
'session.list',
'session.tree',
'session.stream',
'session.send',
'session.attach',
'session.terminate',
],
};
}
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
await this.assertRead('runtime.health', undefined, scope);
return this.options.transport.health(scope);
}
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
await this.assertRead('session.list', undefined, scope);
const sessions = await this.options.transport.listSessions(scope);
const visible = await Promise.all(
sessions.map((session) =>
this.readAuthority.canRead({ operation: 'session.list', sessionId: session.id, scope }),
),
);
return sessions
.filter((_session, index) => visible[index] === true)
.map((session) => this.runtimeSession(session));
}
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
await this.assertRead('session.tree', undefined, scope);
const sessions = await this.options.transport.listSessions(scope);
const visible = await Promise.all(
sessions.map((session) =>
this.readAuthority.canRead({ operation: 'session.tree', sessionId: session.id, scope }),
),
);
const runtimeSessions = sessions
.filter((_session, index) => visible[index] === true)
.map((session) => this.runtimeSession(session));
const nodes = new Map<string, RuntimeSessionTree>(
runtimeSessions.map((session) => [session.id, { session, children: [] }]),
);
const roots: RuntimeSessionTree[] = [];
for (const session of runtimeSessions) {
const node = nodes.get(session.id)!;
const parent = session.parentSessionId ? nodes.get(session.parentSessionId) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
return roots;
}
async *streamSession(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
await this.assertRead('session.stream', sessionId, scope);
const session = await this.options.transport.verifySession(sessionId, scope);
await this.assertRead('session.stream', session.id, scope);
yield* this.options.transport.stream(session.id, cursor, scope);
}
async sendMessage(
sessionId: string,
message: RuntimeMessage,
scope: RuntimeScope,
): Promise<void> {
if (!message.content.trim()) {
throw new MatrixRuntimeProviderError(
'invalid_request',
'Matrix runtime message content is required',
);
}
await this.assertWritePermitted('session.send', sessionId, scope);
const session = await this.options.transport.verifySession(sessionId, scope);
await this.assertWriteAuthorized('session.send', session.id, scope);
await this.options.transport.send(session.id, message, scope);
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
if (mode !== 'read') {
throw new MatrixRuntimeProviderError('forbidden', 'Matrix control attach is not permitted');
}
await this.assertRead('session.attach', sessionId, scope);
const session = await this.options.transport.verifySession(sessionId, scope);
await this.assertRead('session.attach', session.id, scope);
const nowMs = this.now().getTime();
this.pruneExpired(nowMs);
const attachmentId = this.attachmentIdFactory();
const expiresAtMs = nowMs + this.attachmentTtlMs;
this.attachments.set(attachmentId, {
sessionId: session.id,
scope: snapshotScope(scope),
expiresAtMs,
});
return {
attachmentId,
sessionId: session.id,
mode,
expiresAt: new Date(expiresAtMs).toISOString(),
};
}
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
const attachment = this.attachments.get(attachmentId);
if (!attachment)
throw new MatrixRuntimeProviderError('not_found', 'Matrix attachment is not active');
if (this.now().getTime() >= attachment.expiresAtMs) {
this.attachments.delete(attachmentId);
throw new MatrixRuntimeProviderError('forbidden', 'Matrix attachment has expired');
}
if (!sameScope(attachment.scope, scope)) {
throw new MatrixRuntimeProviderError('forbidden', 'Matrix attachment scope does not match');
}
this.attachments.delete(attachmentId);
}
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
if (!approvalRef.trim()) {
throw new MatrixRuntimeProviderError(
'invalid_request',
'Matrix termination approval is required',
);
}
await this.assertWritePermitted('session.terminate', sessionId, scope, approvalRef);
const session = await this.options.transport.verifySession(sessionId, scope);
await this.assertWriteAuthorized('session.terminate', session.id, scope, approvalRef);
await this.options.transport.terminate(session.id, approvalRef, scope);
}
private runtimeSession(session: MatrixRuntimeSession): RuntimeSession {
return { ...session, providerId: this.id };
}
private async assertRead(
operation: MatrixReadOperation,
sessionId: string | undefined,
scope: RuntimeScope,
): Promise<void> {
const allowed = await this.readAuthority.canRead({
operation,
scope,
...(sessionId ? { sessionId } : {}),
});
if (!allowed)
throw new MatrixRuntimeProviderError('forbidden', 'Matrix runtime read is not authorized');
}
private async assertWritePermitted(
operation: 'session.send' | 'session.terminate',
sessionId: string,
scope: RuntimeScope,
approvalRef?: string,
): Promise<void> {
const allowed = await this.writeAuthority.canWrite({
operation,
sessionId,
scope,
...(approvalRef ? { approvalRef } : {}),
});
if (!allowed)
throw new MatrixRuntimeProviderError('forbidden', 'Matrix runtime write is not authorized');
}
private async assertWriteAuthorized(
operation: 'session.send' | 'session.terminate',
sessionId: string,
scope: RuntimeScope,
approvalRef?: string,
): Promise<void> {
await this.writeAuthority.assertAuthorized({
operation,
sessionId,
scope,
...(approvalRef ? { approvalRef } : {}),
});
}
private pruneExpired(nowMs: number): void {
for (const [id, attachment] of this.attachments) {
if (attachment.expiresAtMs <= nowMs) this.attachments.delete(id);
}
}
}
function snapshotScope(scope: RuntimeScope): RuntimeScope {
return Object.freeze({ ...scope });
}
function sameScope(left: RuntimeScope, right: RuntimeScope): boolean {
return (
left.actorId === right.actorId &&
left.tenantId === right.tenantId &&
left.channelId === right.channelId &&
left.correlationId === right.correlationId
);
}
@@ -0,0 +1,176 @@
import { describe, expect, it, vi } from 'vitest';
import type { AgentRuntimeProvider, RuntimeScope } from '@mosaicstack/types';
import {
MatrixNativeRuntimeProvider,
type MatrixRuntimeTransport,
} from './matrix-native-runtime-provider.js';
import {
TmuxFleetRuntimeProvider,
type FleetRuntimeTransport,
} from './tmux-fleet-runtime-provider.js';
const scope: RuntimeScope = {
actorId: 'operator-1',
tenantId: 'tenant-a',
channelId: 'cli',
correlationId: 'corr-1',
};
interface ProviderFixture {
name: string;
provider: AgentRuntimeProvider;
providerId: string;
verifySession: unknown;
send: unknown;
}
function fixtures(): ProviderFixture[] {
const fleetTransport: FleetRuntimeTransport = {
verifySession: vi.fn(async () => ({
id: 'session-1',
runtimeId: 'native-1',
socketName: 'fleet',
})),
listSessions: vi.fn(async () => [
{ id: 'session-1', runtimeId: 'native-1', socketName: 'fleet' },
]),
sendMessage: vi.fn(async () => undefined),
terminate: vi.fn(async () => undefined),
};
const fleet = new TmuxFleetRuntimeProvider({
transport: fleetTransport,
readAuthority: { canRead: vi.fn(async () => true) },
writeAuthority: {
canWrite: vi.fn(async () => true),
assertAuthorized: vi.fn(async () => undefined),
},
attachmentIdFactory: () => 'attachment-1',
now: () => new Date('2026-07-13T00:00:00.000Z'),
});
const matrixTransport: MatrixRuntimeTransport = {
health: vi.fn(async () => ({
status: 'healthy' as const,
checkedAt: '2026-07-13T00:00:00.000Z',
})),
verifySession: vi.fn(async () => ({
id: 'session-1',
runtimeId: 'native-1',
state: 'active' as const,
createdAt: '2026-07-13T00:00:00.000Z',
updatedAt: '2026-07-13T00:00:00.000Z',
})),
listSessions: vi.fn(async () => [
{
id: 'session-1',
runtimeId: 'native-1',
state: 'active' as const,
createdAt: '2026-07-13T00:00:00.000Z',
updatedAt: '2026-07-13T00:00:00.000Z',
},
]),
stream: async function* () {},
send: vi.fn(async () => undefined),
terminate: vi.fn(async () => undefined),
};
const matrix = new MatrixNativeRuntimeProvider({
transport: matrixTransport,
readAuthority: { canRead: vi.fn(async () => true) },
writeAuthority: {
canWrite: vi.fn(async () => true),
assertAuthorized: vi.fn(async () => undefined),
},
attachmentIdFactory: () => 'attachment-1',
now: () => new Date('2026-07-13T00:00:00.000Z'),
});
return [
{
name: 'tmux/fleet',
provider: fleet,
providerId: 'fleet.tmux',
verifySession: fleetTransport.verifySession,
send: fleetTransport.sendMessage,
},
{
name: 'Matrix/native',
provider: matrix,
providerId: 'runtime.matrix',
verifySession: matrixTransport.verifySession,
send: matrixTransport.send,
},
];
}
/** Shared contract tests for the migration-safe provider intersection. */
describe('tmux/fleet and Matrix/native provider parity', (): void => {
it.each(fixtures())(
'%s exposes the shared runtime operations',
async (fixture): Promise<void> => {
await expect(fixture.provider.capabilities(scope)).resolves.toEqual(
expect.objectContaining({
supported: expect.arrayContaining([
'session.list',
'session.tree',
'session.send',
'session.attach',
'session.terminate',
]),
}),
);
await expect(fixture.provider.listSessions(scope)).resolves.toEqual([
expect.objectContaining({
id: 'session-1',
providerId: fixture.providerId,
runtimeId: 'native-1',
}),
]);
},
);
it.each(fixtures())(
'%s rejects empty messages before touching its transport',
async (fixture): Promise<void> => {
await expect(
fixture.provider.sendMessage(
'session-1',
{ content: '', idempotencyKey: 'message-1' },
scope,
),
).rejects.toMatchObject({ code: 'invalid_request' });
expect(fixture.verifySession).not.toHaveBeenCalled();
expect(fixture.send).not.toHaveBeenCalled();
},
);
it.each(fixtures())(
'%s rejects an empty termination approval before touching its transport',
async (fixture): Promise<void> => {
await expect(fixture.provider.terminate('session-1', '', scope)).rejects.toMatchObject({
code: 'invalid_request',
});
expect(fixture.verifySession).not.toHaveBeenCalled();
},
);
it.each(fixtures())(
'%s creates a read-only handle bound to immutable scope',
async (fixture): Promise<void> => {
await expect(fixture.provider.attach('session-1', 'control', scope)).rejects.toMatchObject({
code: 'forbidden',
});
expect(fixture.verifySession).not.toHaveBeenCalled();
await expect(fixture.provider.attach('session-1', 'read', scope)).resolves.toEqual(
expect.objectContaining({
attachmentId: 'attachment-1',
sessionId: 'session-1',
mode: 'read',
}),
);
await expect(
fixture.provider.detach('attachment-1', { ...scope, actorId: 'operator-2' }),
).rejects.toMatchObject({ code: 'forbidden' });
},
);
});
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
} from '@mosaicstack/types';
import { AgentRuntimeProviderRegistry } from './runtime-provider-registry.js';
class TestRuntimeProvider implements AgentRuntimeProvider {
readonly id = 'test-runtime';
async capabilities(_scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
return { supported: ['session.list'] };
}
async health(_scope: RuntimeScope): Promise<RuntimeHealth> {
return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' };
}
async listSessions(_scope: RuntimeScope): Promise<RuntimeSession[]> {
return [];
}
async getSessionTree(_scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
return [];
}
async *streamSession(
_sessionId: string,
_cursor: string | undefined,
_scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
return;
}
async sendMessage(
_sessionId: string,
_message: RuntimeMessage,
_scope: RuntimeScope,
): Promise<void> {}
async attach(
_sessionId: string,
_mode: RuntimeAttachMode,
_scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
return {
attachmentId: 'attachment-1',
sessionId: 'session-1',
mode: 'read',
expiresAt: '2026-07-12T00:00:00.000Z',
};
}
async detach(_attachmentId: string, _scope: RuntimeScope): Promise<void> {}
async terminate(_sessionId: string, _approvalRef: string, _scope: RuntimeScope): Promise<void> {}
}
describe('AgentRuntimeProviderRegistry', (): void => {
it('resolves only registered runtime providers', (): void => {
const registry = new AgentRuntimeProviderRegistry();
const provider = new TestRuntimeProvider();
registry.register(provider);
expect(registry.get(provider.id)).toBe(provider);
expect(registry.get('unknown-runtime')).toBeUndefined();
expect(registry.list()).toEqual([provider]);
});
it('rejects duplicate and blank provider identities rather than silently replacing a runtime', (): void => {
const registry = new AgentRuntimeProviderRegistry();
const provider = new TestRuntimeProvider();
registry.register(provider);
expect((): void => {
registry.register(provider);
}).toThrow(/already registered/);
expect((): void => {
registry.require('');
}).toThrow(/provider ID is required/);
expect((): void => {
registry.require('unknown-runtime');
}).toThrow(/not registered/);
});
});
@@ -0,0 +1,40 @@
import type { AgentRuntimeProvider } from '@mosaicstack/types';
/**
* Registry for runtime providers. Registration is explicit and replacement is
* forbidden so a provider identity cannot be silently hijacked at runtime.
*/
export class AgentRuntimeProviderRegistry {
private readonly providers = new Map<string, AgentRuntimeProvider>();
register(provider: AgentRuntimeProvider): void {
const providerId = provider.id.trim();
if (providerId.length === 0) {
throw new Error('Runtime provider ID is required');
}
if (this.providers.has(providerId)) {
throw new Error(`Runtime provider is already registered: ${providerId}`);
}
this.providers.set(providerId, provider);
}
get(providerId: string): AgentRuntimeProvider | undefined {
return this.providers.get(providerId);
}
require(providerId: string): AgentRuntimeProvider {
const normalizedProviderId = providerId.trim();
if (normalizedProviderId.length === 0) {
throw new Error('Runtime provider ID is required');
}
const provider = this.providers.get(normalizedProviderId);
if (!provider) {
throw new Error(`Runtime provider is not registered: ${normalizedProviderId}`);
}
return provider;
}
list(): AgentRuntimeProvider[] {
return Array.from(this.providers.values());
}
}
@@ -0,0 +1,274 @@
import { describe, expect, it, vi } from 'vitest';
import type { RuntimeScope } from '@mosaicstack/types';
import {
type FleetReadAuthority,
type FleetWriteAuthority,
TmuxFleetRuntimeProvider,
} from './tmux-fleet-runtime-provider.js';
import type {
FleetRuntimeProviderError,
FleetRuntimeTarget,
FleetRuntimeTransport,
} from './tmux-fleet-runtime-provider.js';
const scope: RuntimeScope = {
actorId: 'operator-1',
tenantId: 'tenant-a',
channelId: 'discord-1',
correlationId: 'corr-1',
};
const target: FleetRuntimeTarget = {
id: 'coder0',
runtimeId: 'codex',
socketName: 'tess-fleet',
};
function transport(): FleetRuntimeTransport {
return {
verifySession: vi.fn(async (): Promise<FleetRuntimeTarget> => target),
listSessions: vi.fn(async (): Promise<FleetRuntimeTarget[]> => [target]),
sendMessage: vi.fn(async (): Promise<void> => undefined),
terminate: vi.fn(async (): Promise<void> => undefined),
};
}
function readAuthority(): FleetReadAuthority {
return { canRead: vi.fn(async (): Promise<boolean> => true) };
}
describe('TmuxFleetRuntimeProvider security policy', (): void => {
it('advertises only fleet operations it can safely implement', async (): Promise<void> => {
const provider = new TmuxFleetRuntimeProvider({ transport: transport() });
await expect(provider.capabilities(scope)).resolves.toEqual({
supported: [
'session.list',
'session.tree',
'session.send',
'session.attach',
'session.terminate',
],
});
});
it('rejects control attach without consulting the tmux transport', async (): Promise<void> => {
const fleet = transport();
const provider = new TmuxFleetRuntimeProvider({ transport: fleet });
await expect(provider.attach('coder0', 'control', scope)).rejects.toMatchObject({
code: 'forbidden',
} satisfies Partial<FleetRuntimeProviderError>);
expect(fleet.verifySession).not.toHaveBeenCalled();
});
it('denies fleet listing without an exact-scope read authority decision', async (): Promise<void> => {
const fleet = transport();
const provider = new TmuxFleetRuntimeProvider({ transport: fleet });
await expect(provider.listSessions(scope)).rejects.toMatchObject({
code: 'forbidden',
} satisfies Partial<FleetRuntimeProviderError>);
expect(fleet.listSessions).not.toHaveBeenCalled();
});
it('denies read attachment without an exact-scope read authority decision', async (): Promise<void> => {
const fleet = transport();
const provider = new TmuxFleetRuntimeProvider({ transport: fleet });
await expect(provider.attach('coder0', 'read', scope)).rejects.toMatchObject({
code: 'forbidden',
} satisfies Partial<FleetRuntimeProviderError>);
expect(fleet.verifySession).not.toHaveBeenCalled();
});
it('creates a read-only attachment only after exact target verification', async (): Promise<void> => {
const fleet = transport();
const provider = new TmuxFleetRuntimeProvider({
transport: fleet,
readAuthority: readAuthority(),
attachmentIdFactory: (): string => 'attachment-1',
now: (): Date => new Date('2026-07-12T00:00:00.000Z'),
});
await expect(provider.attach('coder0', 'read', scope)).resolves.toEqual({
attachmentId: 'attachment-1',
sessionId: 'coder0',
mode: 'read',
expiresAt: '2026-07-12T00:05:00.000Z',
});
expect(fleet.verifySession).toHaveBeenCalledWith('coder0');
});
it('denies attachment-handle replay from another immutable actor scope', async (): Promise<void> => {
const fleet = transport();
const provider = new TmuxFleetRuntimeProvider({
transport: fleet,
readAuthority: readAuthority(),
attachmentIdFactory: (): string => 'attachment-1',
now: (): Date => new Date('2026-07-12T00:00:00.000Z'),
});
await provider.attach('coder0', 'read', scope);
await expect(
provider.detach('attachment-1', { ...scope, actorId: 'operator-2' }),
).rejects.toMatchObject({
code: 'forbidden',
} satisfies Partial<FleetRuntimeProviderError>);
});
it('keeps attachment scope immutable after caller-side scope mutation', async (): Promise<void> => {
const mutableScope = { ...scope };
const provider = new TmuxFleetRuntimeProvider({
transport: transport(),
readAuthority: readAuthority(),
attachmentIdFactory: (): string => 'attachment-1',
now: (): Date => new Date('2026-07-12T00:00:00.000Z'),
});
await provider.attach('coder0', 'read', mutableScope);
mutableScope.actorId = 'operator-2';
await expect(provider.detach('attachment-1', scope)).resolves.toBeUndefined();
});
it('denies expired attachment handles and removes them', async (): Promise<void> => {
let now = new Date('2026-07-12T00:00:00.000Z');
const provider = new TmuxFleetRuntimeProvider({
transport: transport(),
readAuthority: readAuthority(),
attachmentIdFactory: (): string => 'attachment-1',
now: (): Date => now,
});
await provider.attach('coder0', 'read', scope);
now = new Date('2026-07-12T00:05:00.001Z');
await expect(provider.detach('attachment-1', scope)).rejects.toMatchObject({
code: 'forbidden',
} satisfies Partial<FleetRuntimeProviderError>);
await expect(provider.detach('attachment-1', scope)).rejects.toMatchObject({
code: 'not_found',
} satisfies Partial<FleetRuntimeProviderError>);
});
it('prunes expired attachment handles before creating a new handle', async (): Promise<void> => {
let now = new Date('2026-07-12T00:00:00.000Z');
let attachmentSequence = 0;
const provider = new TmuxFleetRuntimeProvider({
transport: transport(),
readAuthority: readAuthority(),
attachmentIdFactory: (): string => `attachment-${++attachmentSequence}`,
now: (): Date => now,
});
await provider.attach('coder0', 'read', scope);
now = new Date('2026-07-12T00:05:00.001Z');
await provider.attach('coder0', 'read', scope);
await expect(provider.detach('attachment-1', scope)).rejects.toMatchObject({
code: 'not_found',
} satisfies Partial<FleetRuntimeProviderError>);
});
it('rejects an empty message before consulting write authority or tmux', async (): Promise<void> => {
const fleet = transport();
const writeAuthority: FleetWriteAuthority = {
canWrite: vi.fn(async (): Promise<boolean> => true),
assertAuthorized: vi.fn(async (): Promise<void> => undefined),
};
const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority });
await expect(
provider.sendMessage('coder0', { content: '', idempotencyKey: 'message-1' }, scope),
).rejects.toMatchObject({
code: 'invalid_request',
} satisfies Partial<FleetRuntimeProviderError>);
expect(writeAuthority.canWrite).not.toHaveBeenCalled();
expect(writeAuthority.assertAuthorized).not.toHaveBeenCalled();
expect(fleet.verifySession).not.toHaveBeenCalled();
expect(fleet.sendMessage).not.toHaveBeenCalled();
});
it('denies fleet writes by default before probing the tmux transport', async (): Promise<void> => {
const fleet = transport();
const provider = new TmuxFleetRuntimeProvider({ transport: fleet });
await expect(
provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope),
).rejects.toMatchObject({ code: 'forbidden' } satisfies Partial<FleetRuntimeProviderError>);
await expect(provider.terminate('coder0', 'approval-1', scope)).rejects.toMatchObject({
code: 'forbidden',
} satisfies Partial<FleetRuntimeProviderError>);
expect(fleet.verifySession).not.toHaveBeenCalled();
expect(fleet.sendMessage).not.toHaveBeenCalled();
expect(fleet.terminate).not.toHaveBeenCalled();
});
it('rejects an unverified target before consulting orchestrator write authority', async (): Promise<void> => {
const fleet = transport();
fleet.verifySession = vi.fn(async (): Promise<FleetRuntimeTarget> => {
throw new Error('target identity mismatch');
});
const writeAuthority: FleetWriteAuthority = {
canWrite: vi.fn(async (): Promise<boolean> => true),
assertAuthorized: vi.fn(async (): Promise<void> => undefined),
};
const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority });
await expect(
provider.sendMessage('coder', { content: 'hello', idempotencyKey: 'message-1' }, scope),
).rejects.toThrow('target identity mismatch');
expect(writeAuthority.assertAuthorized).not.toHaveBeenCalled();
expect(fleet.sendMessage).not.toHaveBeenCalled();
});
it('uses the role-neutral interaction source label by default', async (): Promise<void> => {
const fleet = transport();
const writeAuthority: FleetWriteAuthority = {
canWrite: vi.fn(async (): Promise<boolean> => true),
assertAuthorized: vi.fn(async (): Promise<void> => undefined),
};
const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority });
await provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope);
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'interaction');
});
it('passes an exact session ID to the fleet transport only through orchestrator-authorized writes', async (): Promise<void> => {
const fleet = transport();
const writeAuthority: FleetWriteAuthority = {
canWrite: vi.fn(async (): Promise<boolean> => true),
assertAuthorized: vi.fn(async (): Promise<void> => undefined),
};
const provider = new TmuxFleetRuntimeProvider({
transport: fleet,
sourceLabel: 'operator',
writeAuthority,
});
await provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope);
await provider.terminate('coder0', 'approval-1', scope);
expect(writeAuthority.assertAuthorized).toHaveBeenCalledWith({
operation: 'session.send',
sessionId: 'coder0',
scope,
});
expect(writeAuthority.assertAuthorized).toHaveBeenCalledWith({
operation: 'session.terminate',
sessionId: 'coder0',
scope,
approvalRef: 'approval-1',
});
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'operator');
expect(fleet.terminate).toHaveBeenCalledWith('coder0');
});
it('fails closed when consumers ask for session streaming', async (): Promise<void> => {
const provider = new TmuxFleetRuntimeProvider({ transport: transport() });
const stream = provider.streamSession('coder0', undefined, scope)[Symbol.asyncIterator]();
await expect(stream.next()).rejects.toMatchObject({
code: 'capability_unsupported',
} satisfies Partial<FleetRuntimeProviderError>);
});
});
@@ -0,0 +1,368 @@
import { randomUUID } from 'node:crypto';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
} from '@mosaicstack/types';
const FLEET_PROVIDER_ID = 'fleet.tmux';
const ATTACHMENT_TTL_MS = 5 * 60 * 1_000;
export type FleetRuntimeProviderErrorCode =
| 'capability_unsupported'
| 'forbidden'
| 'invalid_request'
| 'not_found';
/** A roster-bound target verified by the concrete fleet transport. */
export interface FleetRuntimeTarget {
id: string;
runtimeId: string;
socketName: string;
}
/**
* Narrow transport boundary implemented by the Mosaic tmux adapter. Keeping it
* here prevents the runtime package from depending on the Mosaic CLI package.
*/
export interface FleetRuntimeTransport {
verifySession(sessionId: string): Promise<FleetRuntimeTarget>;
listSessions(): Promise<FleetRuntimeTarget[]>;
sendMessage(sessionId: string, message: string, sourceLabel: string): Promise<void>;
terminate(sessionId: string): Promise<void>;
}
export type FleetReadOperation =
| 'runtime.health'
| 'session.list'
| 'session.tree'
| 'session.attach';
export interface FleetReadAuthorization {
operation: FleetReadOperation;
scope: RuntimeScope;
sessionId?: string;
}
/** Authorization for fleet inspection and read-only attachments. */
export interface FleetReadAuthority {
canRead(authorization: FleetReadAuthorization): Promise<boolean>;
}
export interface FleetWriteAuthorization {
operation: 'session.send' | 'session.terminate';
sessionId: string;
scope: RuntimeScope;
/** Present only for terminate; authority adapters bind it to the exact action. */
approvalRef?: string;
}
/**
* The orchestrator is the only authority that may permit interaction-plane write/control requests to a
* fleet peer. Gateway records the request and denial/success around provider
* invocation; the default authority prevents direct interaction-plane writes by design.
*/
export interface FleetWriteAuthority {
/** Non-consuming preflight used before probing the fleet transport. */
canWrite(authorization: FleetWriteAuthorization): Promise<boolean>;
/** Final exact-target authorization; may consume an orchestrator grant. */
assertAuthorized(authorization: FleetWriteAuthorization): Promise<void>;
}
export interface TmuxFleetRuntimeProviderOptions {
transport: FleetRuntimeTransport;
readAuthority?: FleetReadAuthority;
writeAuthority?: FleetWriteAuthority;
sourceLabel?: string;
attachmentIdFactory?: () => string;
now?: () => Date;
attachmentTtlMs?: number;
}
interface FleetAttachment {
sessionId: string;
scope: RuntimeScope;
expiresAtMs: number;
}
/** A typed, fail-closed provider error that callers can normalize at the boundary. */
export class FleetRuntimeProviderError extends Error {
constructor(
readonly code: FleetRuntimeProviderErrorCode,
message: string,
) {
super(message);
this.name = FleetRuntimeProviderError.name;
}
}
class DenyFleetReadAuthority implements FleetReadAuthority {
async canRead(_authorization: FleetReadAuthorization): Promise<boolean> {
return false;
}
}
class DenyFleetWriteAuthority implements FleetWriteAuthority {
async canWrite(_authorization: FleetWriteAuthorization): Promise<boolean> {
return false;
}
async assertAuthorized(_authorization: FleetWriteAuthorization): Promise<void> {
throw new FleetRuntimeProviderError(
'forbidden',
'Fleet writes require an explicit orchestrator authority decision',
);
}
}
/**
* A capability-limited provider for rostered local fleet peers. It never
* permits raw tmux socket/target selection, interactive control attach, or
* direct interaction-plane writes; all side effects pass through exact transport checks.
*/
export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider {
readonly id = FLEET_PROVIDER_ID;
private readonly attachments = new Map<string, FleetAttachment>();
private readonly readAuthority: FleetReadAuthority;
private readonly writeAuthority: FleetWriteAuthority;
private readonly sourceLabel: string;
private readonly attachmentIdFactory: () => string;
private readonly now: () => Date;
private readonly attachmentTtlMs: number;
constructor(private readonly options: TmuxFleetRuntimeProviderOptions) {
this.readAuthority = options.readAuthority ?? new DenyFleetReadAuthority();
this.writeAuthority = options.writeAuthority ?? new DenyFleetWriteAuthority();
this.sourceLabel = options.sourceLabel ?? 'interaction';
this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID;
this.now = options.now ?? (() => new Date());
this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS;
}
async capabilities(_scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
return {
supported: [
'session.list',
'session.tree',
'session.send',
'session.attach',
'session.terminate',
],
};
}
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
const targets = await this.readTargets('runtime.health', scope);
return {
status: targets.length > 0 ? 'healthy' : 'down',
checkedAt: this.now().toISOString(),
detail:
targets.length > 0
? 'Authorized rostered fleet peers are reachable'
: 'No authorized rostered fleet peers are reachable',
};
}
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
const targets = await this.readTargets('session.list', scope);
return this.toRuntimeSessions(targets);
}
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
const targets = await this.readTargets('session.tree', scope);
return this.toRuntimeSessions(targets).map(
(session): RuntimeSessionTree => ({
session,
children: [],
}),
);
}
async *streamSession(
_sessionId: string,
_cursor: string | undefined,
_scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
throw new FleetRuntimeProviderError(
'capability_unsupported',
'Fleet session streaming is not supported by the tmux provider',
);
}
async sendMessage(
sessionId: string,
message: RuntimeMessage,
scope: RuntimeScope,
): Promise<void> {
if (message.content.length === 0) {
throw new FleetRuntimeProviderError('invalid_request', 'Fleet message content is required');
}
await this.assertWritePermitted('session.send', sessionId, scope);
const target = await this.options.transport.verifySession(sessionId);
await this.assertWriteAuthorized('session.send', target.id, scope);
await this.options.transport.sendMessage(target.id, message.content, this.sourceLabel);
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
if (mode !== 'read') {
throw new FleetRuntimeProviderError('forbidden', 'Fleet control attach is not permitted');
}
await this.assertReadAuthorized('session.attach', sessionId, scope);
const target = await this.options.transport.verifySession(sessionId);
await this.assertReadAuthorized('session.attach', target.id, scope);
const attachmentId = this.attachmentIdFactory();
const nowMs = this.now().getTime();
this.pruneExpiredAttachments(nowMs);
const expiresAtMs = nowMs + this.attachmentTtlMs;
this.attachments.set(attachmentId, {
sessionId: target.id,
scope: snapshotScope(scope),
expiresAtMs,
});
return {
attachmentId,
sessionId: target.id,
mode,
expiresAt: new Date(expiresAtMs).toISOString(),
};
}
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
const attachment = this.attachments.get(attachmentId);
if (!attachment) {
throw new FleetRuntimeProviderError('not_found', 'Fleet attachment is not active');
}
if (this.now().getTime() >= attachment.expiresAtMs) {
this.attachments.delete(attachmentId);
throw new FleetRuntimeProviderError('forbidden', 'Fleet attachment has expired');
}
if (!sameScope(attachment.scope, scope)) {
throw new FleetRuntimeProviderError('forbidden', 'Fleet attachment scope does not match');
}
this.attachments.delete(attachmentId);
}
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
if (approvalRef.trim().length === 0) {
throw new FleetRuntimeProviderError(
'invalid_request',
'Fleet termination approval is required',
);
}
await this.assertWritePermitted('session.terminate', sessionId, scope, approvalRef);
const target = await this.options.transport.verifySession(sessionId);
await this.assertWriteAuthorized('session.terminate', target.id, scope, approvalRef);
await this.options.transport.terminate(target.id);
}
private async readTargets(
operation: FleetReadOperation,
scope: RuntimeScope,
): Promise<FleetRuntimeTarget[]> {
await this.assertReadAuthorized(operation, undefined, scope);
const targets = await this.options.transport.listSessions();
const authorization = await Promise.all(
targets.map(
async (target): Promise<boolean> =>
this.readAuthority.canRead({ operation, sessionId: target.id, scope }),
),
);
return targets.filter((_target, index): boolean => authorization[index] === true);
}
private toRuntimeSessions(targets: FleetRuntimeTarget[]): RuntimeSession[] {
const timestamp = this.now().toISOString();
return targets.map(
(target): RuntimeSession => ({
id: target.id,
providerId: this.id,
runtimeId: target.runtimeId,
state: 'active',
createdAt: timestamp,
updatedAt: timestamp,
}),
);
}
private async assertReadAuthorized(
operation: FleetReadOperation,
sessionId: string | undefined,
scope: RuntimeScope,
): Promise<void> {
const allowed = await this.readAuthority.canRead({
operation,
scope,
...(sessionId ? { sessionId } : {}),
});
if (!allowed) {
throw new FleetRuntimeProviderError('forbidden', 'Fleet read is not authorized');
}
}
private pruneExpiredAttachments(nowMs: number): void {
for (const [attachmentId, attachment] of this.attachments) {
if (attachment.expiresAtMs <= nowMs) {
this.attachments.delete(attachmentId);
}
}
}
private async assertWritePermitted(
operation: FleetWriteAuthorization['operation'],
sessionId: string,
scope: RuntimeScope,
approvalRef?: string,
): Promise<void> {
const permitted = await this.writeAuthority.canWrite({
operation,
sessionId,
scope,
...(approvalRef ? { approvalRef } : {}),
});
if (!permitted) {
throw new FleetRuntimeProviderError('forbidden', 'Fleet write is not authorized');
}
}
private async assertWriteAuthorized(
operation: FleetWriteAuthorization['operation'],
sessionId: string,
scope: RuntimeScope,
approvalRef?: string,
): Promise<void> {
await this.writeAuthority.assertAuthorized({
operation,
sessionId,
scope,
...(approvalRef ? { approvalRef } : {}),
});
}
}
function snapshotScope(scope: RuntimeScope): RuntimeScope {
return Object.freeze({
actorId: scope.actorId,
tenantId: scope.tenantId,
channelId: scope.channelId,
correlationId: scope.correlationId,
});
}
function sameScope(left: RuntimeScope, right: RuntimeScope): boolean {
return (
left.actorId === right.actorId &&
left.tenantId === right.tenantId &&
left.channelId === right.channelId &&
left.correlationId === right.correlationId
);
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@mosaicstack/appservice",
"version": "0.0.1",
"type": "module",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/appservice"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
"access": "public"
},
"files": [
"dist"
]
}
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import { AGENTS_ACCOUNT_DATA_TYPE, AgentTokenStore } from '../agent-store.js';
import type { AppserviceIntent } from '../intent.js';
/** Fake intent: in-memory account_data, no-op user provisioning. Only the
* surface AgentTokenStore touches is implemented. */
const makeFakeIntent = () => {
const store: Record<string, Record<string, unknown>> = {};
const fake = {
domain: 'hs.example',
getSenderAccountData: async (type: string): Promise<Record<string, unknown> | null> =>
store[type] ?? null,
setSenderAccountData: async (type: string, content: Record<string, unknown>): Promise<void> => {
store[type] = structuredClone(content);
},
ensureRegistered: async (agent: string): Promise<string> => `@agent-${agent}:hs.example`,
setDisplayName: async (): Promise<void> => {},
};
return { intent: fake as unknown as AppserviceIntent, store };
};
describe('AgentTokenStore', () => {
it('mints a magt_ token and stores only its sha256 (never plaintext)', async () => {
const { intent, store } = makeFakeIntent();
const s = new AgentTokenStore(intent);
const { agentUserId, token } = await s.register({ alias: 'pi0', host: 'web1' });
expect(agentUserId).toBe('@agent-pi0-web1:hs.example');
expect(token.startsWith('magt_')).toBe(true);
const raw = JSON.stringify(store[AGENTS_ACCOUNT_DATA_TYPE]);
expect(raw).not.toContain(token);
// The stored hash is sha256hex(token), 64 hex chars.
const { createHash } = await import('node:crypto');
const hash = createHash('sha256').update(token).digest('hex');
expect(raw).toContain(hash);
});
it('verifyToken returns the agentUserId for a fresh token, null otherwise', async () => {
const { intent } = makeFakeIntent();
const s = new AgentTokenStore(intent);
const { agentUserId, token } = await s.register({ alias: 'pi0', host: 'web1' });
expect(await s.verifyToken(token)).toBe(agentUserId);
expect(await s.verifyToken('magt_garbage')).toBeNull();
expect(await s.verifyToken('not-a-token')).toBeNull();
expect(await s.verifyToken('')).toBeNull();
});
it('revoke invalidates tokens, returns count, and hides agent from list', async () => {
const { intent } = makeFakeIntent();
const s = new AgentTokenStore(intent);
const { agentUserId, token } = await s.register({ alias: 'pi0', host: 'web1' });
expect((await s.list()).map((a) => a.agent_user_id)).toContain(agentUserId);
const count = await s.revoke(agentUserId);
expect(count).toBe(1);
expect(await s.verifyToken(token)).toBeNull();
expect((await s.list()).map((a) => a.agent_user_id)).not.toContain(agentUserId);
// Idempotent on unknown / already-revoked.
expect(await s.revoke(agentUserId)).toBe(0);
expect(await s.revoke('@agent-nope:hs.example')).toBe(0);
});
it('re-register after revoke yields a working token and the agent reappears', async () => {
const { intent } = makeFakeIntent();
const s = new AgentTokenStore(intent);
const { agentUserId, token: t1 } = await s.register({ alias: 'pi0', host: 'web1' });
await s.revoke(agentUserId);
const { token: t2 } = await s.register({ alias: 'pi0', host: 'web1' });
expect(await s.verifyToken(t1)).toBeNull();
expect(await s.verifyToken(t2)).toBe(agentUserId);
expect((await s.list()).map((a) => a.agent_user_id)).toContain(agentUserId);
});
it('agent A token never verifies as agent B', async () => {
const { intent } = makeFakeIntent();
const s = new AgentTokenStore(intent);
const a = await s.register({ alias: 'pi0', host: 'web1' });
const b = await s.register({ alias: 'pi1', host: 'web2' });
expect(await s.verifyToken(a.token)).toBe(a.agentUserId);
expect(await s.verifyToken(b.token)).toBe(b.agentUserId);
expect(a.agentUserId).not.toBe(b.agentUserId);
});
it('rejects an ambiguous re-registration that collides on one Matrix id', async () => {
const { intent } = makeFakeIntent();
const s = new AgentTokenStore(intent);
// alias="a-b",host="c" and alias="a",host="b-c" both -> @agent-a-b-c.
const first = await s.register({ alias: 'a-b', host: 'c' });
expect(first.agentUserId).toBe('@agent-a-b-c:hs.example');
await expect(s.register({ alias: 'a', host: 'b-c' })).rejects.toThrow(/collision/);
// The original registration is untouched: still one active token, correct pair.
expect(await s.verifyToken(first.token)).toBe(first.agentUserId);
const summary = (await s.list()).find((x) => x.agent_user_id === first.agentUserId);
expect(summary?.alias).toBe('a-b');
expect(summary?.host).toBe('c');
expect(summary?.active_token_count).toBe(1);
});
it('display_name is stored and surfaced in list', async () => {
const { intent } = makeFakeIntent();
const s = new AgentTokenStore(intent);
await s.register({ alias: 'pi0', host: 'web1', displayName: 'Pi Zero' });
const summary = (await s.list())[0];
expect(summary?.display_name).toBe('Pi Zero');
expect(summary?.active_token_count).toBe(1);
});
});
@@ -0,0 +1,230 @@
import { describe, expect, it, vi } from 'vitest';
import { validateBridgeMessage, validateBridgeTyping } from '../bridge.dto.js';
import { AppserviceIntent, MatrixApiError } from '../intent.js';
import { buildRegistration, registrationToYaml } from '../registration.js';
import { TransactionHandler } from '../transactions.js';
import type { AppserviceConfig, MatrixEvent } from '../types.js';
const cfg: AppserviceConfig = {
homeserverUrl: 'https://hs.example',
domain: 'hs.example',
asToken: 'as-secret',
hsToken: 'hs-secret',
};
const jsonResponse = (status: number, body: unknown): Response =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
describe('TransactionHandler', () => {
const makeHandler = (onEvent = vi.fn()) => ({
onEvent,
handler: new TransactionHandler({ hsToken: 'hs-secret', onEvent }),
});
it('rejects a bad hs_token with M_FORBIDDEN', async () => {
const { handler, onEvent } = makeHandler();
const res = await handler.handle(
't1',
{ events: [{ type: 'm.room.message' }] },
{ authorizationHeader: 'Bearer wrong' },
);
expect(res.status).toBe(403);
expect(res.body.errcode).toBe('M_FORBIDDEN');
expect(onEvent).not.toHaveBeenCalled();
});
it('accepts Bearer auth and legacy access_token param', async () => {
const { handler } = makeHandler();
expect(
(await handler.handle('t1', { events: [] }, { authorizationHeader: 'Bearer hs-secret' }))
.status,
).toBe(200);
expect(
(await handler.handle('t2', { events: [] }, { accessTokenParam: 'hs-secret' })).status,
).toBe(200);
});
it('processes events once per txnId (idempotent retries)', async () => {
const { handler, onEvent } = makeHandler();
const body = { events: [{ type: 'm.room.message', event_id: '$e1' }] };
await handler.handle('t1', body, { authorizationHeader: 'Bearer hs-secret' });
const retry = await handler.handle('t1', body, { authorizationHeader: 'Bearer hs-secret' });
expect(retry.status).toBe(200);
expect(onEvent).toHaveBeenCalledTimes(1);
});
it('a throwing event handler does not fail the transaction', async () => {
const onError = vi.fn();
const handler = new TransactionHandler({
hsToken: 'hs-secret',
onEvent: () => {
throw new Error('boom');
},
onError,
});
const res = await handler.handle(
't1',
{ events: [{ type: 'x' }, { type: 'y' }] },
{ authorizationHeader: 'Bearer hs-secret' },
);
expect(res.status).toBe(200);
expect(onError).toHaveBeenCalledTimes(2);
});
});
describe('AppserviceIntent', () => {
it('derives namespaced user ids and rejects bad slugs', () => {
const intent = new AppserviceIntent(cfg);
expect(intent.agentUserId('pi0-web1')).toBe('@agent-pi0-web1:hs.example');
expect(intent.agentUserId('Pi0-Web1')).toBe('@agent-pi0-web1:hs.example');
expect(() => intent.agentUserId('../evil')).toThrow();
expect(() => intent.agentUserId('')).toThrow();
});
it('uses uuid transaction ids', async () => {
const calls: string[] = [];
const fetchMock = vi.fn(async (input: URL | string) => {
calls.push(new URL(String(input)).pathname);
return jsonResponse(200, {});
});
const intent = new AppserviceIntent(cfg, fetchMock as unknown as typeof fetch);
await intent.sendAsAgent({ roomId: '!r:hs.example', agent: 'pi0', body: 'x' });
const send = calls.find((p) => p.includes('/send/m.room.message/'));
expect(send).toMatch(/mosaic-as-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
});
it('registers once, impersonates via user_id, threads replies', async () => {
const calls: Array<{ url: URL; init: RequestInit }> = [];
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
calls.push({ url: new URL(String(input)), init: init ?? {} });
return jsonResponse(200, { event_id: '$sent' });
});
const intent = new AppserviceIntent(cfg, fetchMock as unknown as typeof fetch);
const eventId = await intent.sendAsAgent({
roomId: '!room:hs.example',
agent: 'pi0-web1',
body: 'hello',
threadRoot: '$req',
});
await intent.sendAsAgent({ roomId: '!room:hs.example', agent: 'pi0-web1', body: 'again' });
expect(eventId).toBe('$sent');
const paths = calls.map((c) => c.url.pathname);
expect(paths.filter((p) => p.endsWith('/register'))).toHaveLength(1); // cached
expect(paths.filter((p) => p.includes('/join'))).toHaveLength(1); // cached
const send = calls.find((c) => c.url.pathname.includes('/send/m.room.message/'));
expect(send).toBeDefined();
expect(send!.url.searchParams.get('user_id')).toBe('@agent-pi0-web1:hs.example');
const content = JSON.parse(String(send!.init.body)) as Record<string, unknown>;
const rel = content['m.relates_to'] as Record<string, unknown>;
expect(rel.rel_type).toBe('m.thread');
expect(rel.event_id).toBe('$req');
expect(rel.is_falling_back).toBe(true);
expect(
calls.every(
(c) => (c.init.headers as Record<string, string>).Authorization === 'Bearer as-secret',
),
).toBe(true);
});
it('tolerates M_USER_IN_USE and surfaces other register errors', async () => {
const inUse = vi.fn(async () =>
jsonResponse(400, { errcode: 'M_USER_IN_USE', error: 'taken' }),
);
const intent = new AppserviceIntent(cfg, inUse as unknown as typeof fetch);
await expect(intent.ensureRegistered('pi0-web1')).resolves.toBe('@agent-pi0-web1:hs.example');
const denied = vi.fn(async () =>
jsonResponse(401, { errcode: 'M_UNKNOWN_TOKEN', error: 'nope' }),
);
const intent2 = new AppserviceIntent(cfg, denied as unknown as typeof fetch);
await expect(intent2.ensureRegistered('pi0-web1')).rejects.toThrow(MatrixApiError);
});
it('invites then joins on M_FORBIDDEN join', async () => {
const paths: string[] = [];
const fetchMock = vi.fn(async (input: URL | string) => {
const url = new URL(String(input));
paths.push(url.pathname);
if (url.pathname.endsWith('/join') && paths.filter((p) => p.endsWith('/join')).length === 1) {
return jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'not invited' });
}
return jsonResponse(200, {});
});
const intent = new AppserviceIntent(cfg, fetchMock as unknown as typeof fetch);
await intent.ensureJoined('!room:hs.example', 'pi0-web1');
expect(paths.filter((p) => p.endsWith('/invite'))).toHaveLength(1);
expect(paths.filter((p) => p.endsWith('/join'))).toHaveLength(2);
});
});
describe('registration', () => {
it('builds an exclusive escaped user namespace', () => {
const reg = buildRegistration(cfg, { url: 'http://mosaic-as:8008' });
expect(reg.namespaces.users[0]).toEqual({
regex: '@agent-.*:hs\\.example',
exclusive: true,
});
expect(reg.rate_limited).toBe(false);
const yaml = registrationToYaml(reg);
expect(yaml).toContain("sender_localpart: 'mosaic-as'");
expect(yaml).toContain("as_token: 'as-secret'");
expect(yaml).toContain('exclusive: true');
});
});
describe('registration hardening', () => {
it('rejects control characters in registration values', () => {
const reg = buildRegistration(
{ ...cfg, asToken: 'abc\nhttp_injected: true' },
{ url: 'http://mosaic-as:8008' },
);
expect(() => registrationToYaml(reg)).toThrow(/control characters/);
});
it('escapes single quotes in token values', () => {
const reg = buildRegistration({ ...cfg, asToken: "it's" }, { url: 'http://mosaic-as:8008' });
expect(registrationToYaml(reg)).toContain("as_token: 'it''s'");
});
});
describe('bridge DTOs', () => {
it('validates message and typing payloads', () => {
expect(() =>
validateBridgeMessage({ room_id: '!r:hs', agent: 'pi0', body: 'x' }),
).not.toThrow();
expect(() => validateBridgeMessage({ room_id: 'bad', agent: 'pi0', body: 'x' })).toThrow();
expect(() => validateBridgeMessage({ room_id: '!r:hs', agent: '', body: 'x' })).toThrow();
expect(() => validateBridgeMessage({ room_id: '!r:hs', agent: '../evil', body: 'x' })).toThrow(
/agent must match/,
);
expect(() =>
validateBridgeTyping({ room_id: '!r:hs', agent: 'pi0', typing: true }),
).not.toThrow();
expect(() => validateBridgeTyping({ room_id: '!r:hs', agent: 'pi0', typing: 'yes' })).toThrow();
});
});
describe('event shape', () => {
it('transaction events flow through to the handler', async () => {
const seen: MatrixEvent[] = [];
const handler = new TransactionHandler({
hsToken: 'hs-secret',
onEvent: (e) => void seen.push(e),
});
await handler.handle(
't1',
{
events: [
{ type: 'm.room.message', room_id: '!r:hs', sender: '@u:hs', content: { body: 'hi' } },
],
},
{ authorizationHeader: 'Bearer hs-secret' },
);
expect(seen).toHaveLength(1);
expect(seen[0]!.content?.body).toBe('hi');
});
});
@@ -0,0 +1,63 @@
/** DTOs for agent registration + scoped/revocable bridge tokens (US-007). */
export interface RegisterAgentDto {
/** Agent alias slug, e.g. "pi0". Combined with host into the agent slug. */
alias: string;
/** Host slug, e.g. "web1". Combined with alias into the agent slug. */
host: string;
display_name?: string;
}
export interface RevokeAgentDto {
agent_user_id: string;
}
export interface RegisterAgentResponse {
agent_user_id: string;
bridge_token: string;
}
export interface AgentSummary {
agent_user_id: string;
alias: string;
host: string;
display_name?: string;
created_at: string;
active_token_count: number;
}
const SLUG_RE = /^[a-z0-9][a-z0-9_.-]*$/;
/** Combined agent slug, e.g. alias="pi0", host="web1" -> "pi0-web1". */
export function agentSlug(alias: string, host: string): string {
return `${alias}-${host}`;
}
const assertSlug = (value: unknown, field: string): void => {
if (typeof value !== 'string' || value.length === 0 || !SLUG_RE.test(value)) {
throw new Error(`${field} must match [a-z0-9][a-z0-9_.-]* (lowercase, non-empty)`);
}
};
export function validateRegisterAgent(input: unknown): asserts input is RegisterAgentDto {
const o = input as Partial<RegisterAgentDto> | null | undefined;
if (!o || typeof o !== 'object') throw new Error('payload must be an object');
assertSlug(o.alias, 'alias');
assertSlug(o.host, 'host');
if (o.display_name !== undefined) {
if (typeof o.display_name !== 'string' || o.display_name.length === 0) {
throw new Error('display_name must be a non-empty string');
}
if (o.display_name.length > 100) {
throw new Error('display_name must be at most 100 chars');
}
}
}
export function validateRevokeAgent(input: unknown): asserts input is RevokeAgentDto {
const o = input as Partial<RevokeAgentDto> | null | undefined;
if (!o || typeof o !== 'object') throw new Error('payload must be an object');
if (typeof o.agent_user_id !== 'string' || !o.agent_user_id.startsWith('@')) {
throw new Error('agent_user_id must be a Matrix user id');
}
}
+160
View File
@@ -0,0 +1,160 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { agentSlug } from './agent-registry.dto.js';
import type { AgentSummary } from './agent-registry.dto.js';
import type { AppserviceIntent } from './intent.js';
/** account_data type holding the agent registry on the AS sender user. */
export const AGENTS_ACCOUNT_DATA_TYPE = 'org.uscllc.mosaic_as.agents';
const TOKEN_PREFIX = 'magt_';
interface StoredAgent {
alias: string;
host: string;
display_name?: string;
created_at: string;
/** sha256hex of each active token. Plaintext tokens are NEVER stored. */
token_hashes: string[];
revoked_at?: string;
}
interface AgentRegistry {
agents: Record<string, StoredAgent>;
}
const sha256hex = (value: string): string => createHash('sha256').update(value).digest('hex');
const mintToken = (): string => `${TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`;
/**
* Persists scoped/revocable bridge tokens for agent virtual users in Matrix
* account_data on the AS sender user (no new infra; survives restart).
*
* Tokens are stored only as sha256 hashes (the high-entropy `magt_` token makes
* plain sha256 safe — no salt/KDF needed since brute force is infeasible).
*
* KNOWN v1 LIMIT: Synapse caps a single account_data object (default
* max_account_data_size, ~100KB). Each agent + hash entry is small, so this
* supports thousands of agents, but a very large fleet would eventually need a
* dedicated store. Revoked agents with no active tokens are pruned of hashes
* (kept as tombstones) to bound growth.
*/
export class AgentTokenStore {
constructor(private readonly intent: AppserviceIntent) {}
/** Read the registry fresh from account_data (low-frequency ops favor
* correctness over caching; verifyToken/list also read fresh). */
private async read(): Promise<AgentRegistry> {
const data = await this.intent.getSenderAccountData(AGENTS_ACCOUNT_DATA_TYPE);
const agents = data?.agents;
if (agents && typeof agents === 'object') {
return { agents: agents as Record<string, StoredAgent> };
}
return { agents: {} };
}
private async write(registry: AgentRegistry): Promise<void> {
await this.intent.setSenderAccountData(AGENTS_ACCOUNT_DATA_TYPE, {
agents: registry.agents,
});
}
/** Ensure the virtual user exists, mint a fresh token, store its hash, and
* return the plaintext token ONCE. Clears any prior revocation. */
async register(opts: {
alias: string;
host: string;
displayName?: string;
}): Promise<{ agentUserId: string; token: string }> {
const slug = agentSlug(opts.alias, opts.host);
const agentUserId = await this.intent.ensureRegistered(slug);
if (opts.displayName !== undefined) {
await this.intent.setDisplayName(slug, opts.displayName);
}
const token = mintToken();
const hash = sha256hex(token);
const registry = await this.read();
const existing = registry.agents[agentUserId];
if (existing) {
// The agent slug `<alias>-<host>` joins with a `-`, which is also a legal
// slug char, so distinct pairs can collide on one Matrix id (e.g.
// a/b-c and a-b/c both -> @agent-a-b-c). They ARE the same Matrix user,
// but silently overwriting the stored alias/host of a different pair
// would conflate two logical agents into one token bucket. Reject the
// ambiguous re-registration instead of overwriting.
if (existing.alias !== opts.alias || existing.host !== opts.host) {
throw new Error(
`agent id collision: ${agentUserId} already registered as ` +
`${existing.alias}/${existing.host}, refusing ${opts.alias}/${opts.host}`,
);
}
if (opts.displayName !== undefined) existing.display_name = opts.displayName;
existing.token_hashes = [...existing.token_hashes, hash];
delete existing.revoked_at;
} else {
registry.agents[agentUserId] = {
alias: opts.alias,
host: opts.host,
...(opts.displayName !== undefined ? { display_name: opts.displayName } : {}),
created_at: new Date().toISOString(),
token_hashes: [hash],
};
}
await this.write(registry);
return { agentUserId, token };
}
/** Return the agentUserId bound to an active (non-revoked) token, else null.
* Constant-time hash comparison; no early-out on match. */
async verifyToken(token: string): Promise<string | null> {
if (!token.startsWith(TOKEN_PREFIX)) return null;
const presented = Buffer.from(sha256hex(token), 'hex');
const registry = await this.read();
let matched: string | null = null;
for (const [agentUserId, agent] of Object.entries(registry.agents)) {
if (agent.revoked_at) continue;
for (const stored of agent.token_hashes) {
const candidate = Buffer.from(stored, 'hex');
if (candidate.length === presented.length && timingSafeEqual(candidate, presented)) {
// No early break: keep scanning so timing does not reveal match position.
matched = agentUserId;
}
}
}
return matched;
}
/** Revoke all active tokens for an agent. Idempotent; returns count revoked. */
async revoke(agentUserId: string): Promise<number> {
const registry = await this.read();
const agent = registry.agents[agentUserId];
if (!agent) return 0;
const count = agent.token_hashes.length;
agent.token_hashes = [];
agent.revoked_at = new Date().toISOString();
await this.write(registry);
return count;
}
/** List agents with at least one active token (never advertise revoked/phantom). */
async list(): Promise<AgentSummary[]> {
const registry = await this.read();
const out: AgentSummary[] = [];
for (const [agentUserId, agent] of Object.entries(registry.agents)) {
if (agent.revoked_at || agent.token_hashes.length === 0) continue;
out.push({
agent_user_id: agentUserId,
alias: agent.alias,
host: agent.host,
...(agent.display_name !== undefined ? { display_name: agent.display_name } : {}),
created_at: agent.created_at,
active_token_count: agent.token_hashes.length,
});
}
return out;
}
}
+83
View File
@@ -0,0 +1,83 @@
/** DTOs for the internal bridge API consumed by agent-comms host daemons. */
export interface BridgeMessageDto {
room_id: string;
/** Agent slug (localpart suffix), e.g. "pi0-web1". */
agent: string;
body: string;
thread_root?: string;
msgtype?: string;
/** Optional protocol payload merged into content (e.g. org.uscllc.agent). */
extra_content?: Record<string, unknown>;
}
export interface BridgeTypingDto {
room_id: string;
agent: string;
typing: boolean;
}
const AGENT_SLUG_RE = /^[a-z0-9][a-z0-9_.-]*$/;
const assertAgentSlug = (agent: unknown): void => {
if (typeof agent !== 'string' || !AGENT_SLUG_RE.test(agent.toLowerCase())) {
throw new Error('agent must match [a-z0-9][a-z0-9_.-]*');
}
};
export function validateBridgeMessage(input: unknown): asserts input is BridgeMessageDto {
const o = input as Partial<BridgeMessageDto> | null | undefined;
if (!o || typeof o !== 'object') throw new Error('payload must be an object');
if (typeof o.room_id !== 'string' || !o.room_id.startsWith('!'))
throw new Error('room_id must be a Matrix room id');
assertAgentSlug(o.agent);
if (typeof o.body !== 'string') throw new Error('body must be a string');
if (o.thread_root !== undefined && typeof o.thread_root !== 'string')
throw new Error('thread_root must be a string');
if (
o.extra_content !== undefined &&
(typeof o.extra_content !== 'object' || o.extra_content === null)
) {
throw new Error('extra_content must be an object');
}
}
export function validateBridgeTyping(input: unknown): asserts input is BridgeTypingDto {
const o = input as Partial<BridgeTypingDto> | null | undefined;
if (!o || typeof o !== 'object') throw new Error('payload must be an object');
if (typeof o.room_id !== 'string' || !o.room_id.startsWith('!'))
throw new Error('room_id must be a Matrix room id');
assertAgentSlug(o.agent);
if (typeof o.typing !== 'boolean') throw new Error('typing must be a boolean');
}
export interface ProvisionRoomDto {
name: string;
alias?: string;
topic?: string;
invite?: string[];
space_id?: string;
}
export function validateProvisionRoom(input: unknown): asserts input is ProvisionRoomDto {
const o = input as Partial<ProvisionRoomDto> | null | undefined;
if (!o || typeof o !== 'object') throw new Error('payload must be an object');
if (typeof o.name !== 'string' || o.name.length === 0) throw new Error('name is required');
if (o.alias !== undefined && (!/^[a-z0-9_.-]+$/.test(o.alias) || o.alias.length > 200)) {
throw new Error('alias must match [a-z0-9_.-]+ (max 200 chars)');
}
if (o.invite !== undefined) {
if (
!Array.isArray(o.invite) ||
o.invite.some((u) => typeof u !== 'string' || !u.startsWith('@'))
) {
throw new Error('invite must be a list of Matrix user ids');
}
if (o.invite.length > 50) {
throw new Error('invite list exceeds maximum of 50');
}
}
if (o.space_id !== undefined && (typeof o.space_id !== 'string' || !o.space_id.startsWith('!'))) {
throw new Error('space_id must be a Matrix room id');
}
}
+27
View File
@@ -0,0 +1,27 @@
export { AppserviceIntent, MatrixApiError } from './intent.js';
export type { SendMessageOptions } from './intent.js';
export { TransactionHandler } from './transactions.js';
export type { TransactionHandlerOptions } from './transactions.js';
export { buildRegistration, registrationToYaml } from './registration.js';
export type { RegistrationOptions } from './registration.js';
export {
validateBridgeMessage,
validateBridgeTyping,
validateProvisionRoom,
} from './bridge.dto.js';
export type { BridgeMessageDto, BridgeTypingDto, ProvisionRoomDto } from './bridge.dto.js';
export { agentSlug, validateRegisterAgent, validateRevokeAgent } from './agent-registry.dto.js';
export type {
RegisterAgentDto,
RevokeAgentDto,
RegisterAgentResponse,
AgentSummary,
} from './agent-registry.dto.js';
export { AgentTokenStore, AGENTS_ACCOUNT_DATA_TYPE } from './agent-store.js';
export type {
AppserviceConfig,
EventHandler,
HandlerResult,
MatrixEvent,
Transaction,
} from './types.js';
+262
View File
@@ -0,0 +1,262 @@
import crypto from 'node:crypto';
import type { AppserviceConfig } from './types.js';
export interface SendMessageOptions {
roomId: string;
/** Agent slug, e.g. "pi0-web1" -> @agent-pi0-web1:domain */
agent: string;
body: string;
/** Request event id to thread off (m.thread, spec v1.4). */
threadRoot?: string;
msgtype?: string;
/** Extra content keys merged into the message content (e.g. org.uscllc.agent). */
extraContent?: Record<string, unknown>;
}
export class MatrixApiError extends Error {
constructor(
readonly status: number,
readonly errcode: string | undefined,
message: string,
) {
super(message);
this.name = 'MatrixApiError';
}
}
type FetchLike = typeof fetch;
/**
* Acts on the homeserver as appservice-namespaced virtual users
* (Application Service API: as_token auth + user_id impersonation).
*/
export class AppserviceIntent {
private readonly registered = new Set<string>();
private readonly joined = new Set<string>();
private readonly fetchImpl: FetchLike;
constructor(
private readonly cfg: AppserviceConfig,
fetchImpl?: FetchLike,
) {
this.fetchImpl = fetchImpl ?? fetch;
}
get userPrefix(): string {
return this.cfg.userPrefix ?? 'agent-';
}
get senderUserId(): string {
return `@${this.cfg.senderLocalpart ?? 'mosaic-as'}:${this.cfg.domain}`;
}
agentLocalpart(agent: string): string {
const slug = agent.toLowerCase();
if (!/^[a-z0-9][a-z0-9_.-]*$/.test(slug)) {
throw new Error(`invalid agent slug: ${agent}`);
}
return `${this.userPrefix}${slug}`;
}
agentUserId(agent: string): string {
return `@${this.agentLocalpart(agent)}:${this.cfg.domain}`;
}
private async request(
method: string,
path: string,
options: { userId?: string; body?: unknown } = {},
): Promise<Record<string, unknown>> {
const url = new URL(this.cfg.homeserverUrl.replace(/\/$/, '') + path);
if (options.userId) {
url.searchParams.set('user_id', options.userId);
}
const res = await this.fetchImpl(url, {
method,
headers: {
Authorization: `Bearer ${this.cfg.asToken}`,
'Content-Type': 'application/json',
},
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
const text = await res.text();
const data = (text ? JSON.parse(text) : {}) as Record<string, unknown>;
if (!res.ok) {
throw new MatrixApiError(
res.status,
typeof data.errcode === 'string' ? data.errcode : undefined,
`${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`,
);
}
return data;
}
/** Register the virtual user if it does not exist yet. Idempotent. */
async ensureRegistered(agent: string): Promise<string> {
const localpart = this.agentLocalpart(agent);
const userId = this.agentUserId(agent);
if (this.registered.has(userId)) return userId;
try {
await this.request('POST', '/_matrix/client/v3/register', {
body: { type: 'm.login.application_service', username: localpart },
});
} catch (err) {
if (!(err instanceof MatrixApiError && err.errcode === 'M_USER_IN_USE')) {
throw err;
}
}
this.registered.add(userId);
return userId;
}
/** Join the agent to a room; on invite-only rooms the AS sender invites first. */
async ensureJoined(roomId: string, agent: string): Promise<void> {
const userId = await this.ensureRegistered(agent);
const key = `${userId} ${roomId}`;
if (this.joined.has(key)) return;
const room = encodeURIComponent(roomId);
try {
await this.request('POST', `/_matrix/client/v3/rooms/${room}/join`, { userId, body: {} });
} catch (err) {
if (!(err instanceof MatrixApiError && err.errcode === 'M_FORBIDDEN')) throw err;
await this.request('POST', `/_matrix/client/v3/rooms/${room}/invite`, {
userId: this.senderUserId,
body: { user_id: userId },
});
await this.request('POST', `/_matrix/client/v3/rooms/${room}/join`, { userId, body: {} });
}
this.joined.add(key);
}
/** Send a message AS the agent's virtual user. */
async sendAsAgent(options: SendMessageOptions): Promise<string | undefined> {
const userId = this.agentUserId(options.agent);
await this.ensureJoined(options.roomId, options.agent);
const content: Record<string, unknown> = {
msgtype: options.msgtype ?? 'm.text',
body: options.body,
...options.extraContent,
};
if (options.threadRoot) {
content['m.relates_to'] = {
rel_type: 'm.thread',
event_id: options.threadRoot,
is_falling_back: true,
'm.in_reply_to': { event_id: options.threadRoot },
};
}
const txn = `mosaic-as-${crypto.randomUUID()}`;
const room = encodeURIComponent(options.roomId);
const res = await this.request(
'PUT',
`/_matrix/client/v3/rooms/${room}/send/m.room.message/${txn}`,
{ userId, body: content },
);
return typeof res.event_id === 'string' ? res.event_id : undefined;
}
/** Set the agent's typing indicator in a room. */
async setTyping(
roomId: string,
agent: string,
typing: boolean,
timeoutMs = 30000,
): Promise<void> {
const userId = await this.ensureRegistered(agent);
const room = encodeURIComponent(roomId);
const user = encodeURIComponent(userId);
await this.request('PUT', `/_matrix/client/v3/rooms/${room}/typing/${user}`, {
userId,
body: typing ? { typing: true, timeout: timeoutMs } : { typing: false },
});
}
/** Create a room as the AS sender: agents get PL 50 by namespace via the
* sender (PL 100); humans invited at default PL. Optionally link into a
* space (m.space.child + m.space.parent). Returns the room id. */
async createRoom(options: {
name: string;
alias?: string;
topic?: string;
invite?: string[];
spaceId?: string;
}): Promise<{ roomId: string; spaceLinked: boolean; spaceError?: string }> {
const body: Record<string, unknown> = {
name: options.name,
preset: 'private_chat',
invite: options.invite ?? [],
power_level_content_override: {
users: { [this.senderUserId]: 100 },
// state_default 50 stays; the AS sender can grant agents as needed.
},
};
if (options.alias) body.room_alias_name = options.alias;
if (options.topic) body.topic = options.topic;
const res = await this.request('POST', '/_matrix/client/v3/createRoom', {
userId: this.senderUserId,
body,
});
const roomId = res.room_id;
if (typeof roomId !== 'string') throw new Error('createRoom returned no room_id');
if (!options.spaceId) {
return { roomId, spaceLinked: false };
}
// Space-link failures must NOT throw: the room already exists, and an
// exception would hide the room_id (orphaned room, no recovery path).
const encodedSpaceId = encodeURIComponent(options.spaceId);
const encodedRoomId = encodeURIComponent(roomId);
try {
await this.request(
'PUT',
`/_matrix/client/v3/rooms/${encodedSpaceId}/state/m.space.child/${encodedRoomId}`,
{ userId: this.senderUserId, body: { via: [this.cfg.domain], suggested: true } },
);
await this.request(
'PUT',
`/_matrix/client/v3/rooms/${encodedRoomId}/state/m.space.parent/${encodedSpaceId}`,
{ userId: this.senderUserId, body: { via: [this.cfg.domain], canonical: true } },
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { roomId, spaceLinked: false, spaceError: message };
}
return { roomId, spaceLinked: true };
}
/** Set display name for an agent's virtual user. */
async setDisplayName(agent: string, displayName: string): Promise<void> {
const userId = await this.ensureRegistered(agent);
const user = encodeURIComponent(userId);
await this.request('PUT', `/_matrix/client/v3/profile/${user}/displayname`, {
userId,
body: { displayname: displayName },
});
}
/** Read an account_data object on the AS sender user. Returns null when the
* key has never been written (M_NOT_FOUND), so callers can treat that as an
* empty store; any other error propagates. */
async getSenderAccountData(type: string): Promise<Record<string, unknown> | null> {
const user = encodeURIComponent(this.senderUserId);
const key = encodeURIComponent(type);
try {
return await this.request('GET', `/_matrix/client/v3/user/${user}/account_data/${key}`, {
userId: this.senderUserId,
});
} catch (err) {
if (err instanceof MatrixApiError && err.errcode === 'M_NOT_FOUND') return null;
throw err;
}
}
/** Write an account_data object on the AS sender user. */
async setSenderAccountData(type: string, content: Record<string, unknown>): Promise<void> {
const user = encodeURIComponent(this.senderUserId);
const key = encodeURIComponent(type);
await this.request('PUT', `/_matrix/client/v3/user/${user}/account_data/${key}`, {
userId: this.senderUserId,
body: content,
});
}
}
@@ -0,0 +1,76 @@
import type { AppserviceConfig } from './types.js';
export interface RegistrationOptions {
/** Unique appservice id in Synapse. Default: "mosaic-as". */
id?: string;
/** URL where Synapse reaches the appservice, e.g. http://mosaic-as:8008 */
url: string;
/** Alias namespace regex prefix. Default: "#mosaic-". */
aliasPrefix?: string;
}
const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* Build the Synapse appservice registration document (mosaic-as.yaml).
* Deployment (infrastructure repo) serializes this to YAML and mounts it via
* app_service_config_files.
*/
export function buildRegistration(cfg: AppserviceConfig, options: RegistrationOptions) {
const prefix = cfg.userPrefix ?? 'agent-';
return {
id: options.id ?? 'mosaic-as',
url: options.url,
as_token: cfg.asToken,
hs_token: cfg.hsToken,
sender_localpart: cfg.senderLocalpart ?? 'mosaic-as',
rate_limited: false,
namespaces: {
users: [
{
regex: `@${escapeRegex(prefix)}.*:${escapeRegex(cfg.domain)}`,
exclusive: true,
},
],
aliases: [
{
regex: `${escapeRegex(options.aliasPrefix ?? '#mosaic-')}.*:${escapeRegex(cfg.domain)}`,
exclusive: false,
},
],
rooms: [],
},
};
}
const assertYamlSafe = (field: string, value: string): string => {
// Tokens/urls/ids are single-line opaque strings; control characters would
// let a crafted value terminate the scalar and inject YAML keys.
if (/[\r\n\x00-\x08\x0b-\x1f]/.test(value)) {
throw new Error(`registration field ${field} contains control characters`);
}
return value.replace(/'/g, "''");
};
/** Minimal YAML serialization for the flat registration document. */
export function registrationToYaml(registration: ReturnType<typeof buildRegistration>): string {
const ns = registration.namespaces;
const nsBlock = (entries: Array<{ regex: string; exclusive: boolean }>): string =>
entries.length === 0
? ' []'
: '\n' +
entries.map((e) => ` - regex: '${e.regex}'\n exclusive: ${e.exclusive}`).join('\n');
return [
`id: '${assertYamlSafe('id', registration.id)}'`,
`url: '${assertYamlSafe('url', registration.url)}'`,
`as_token: '${assertYamlSafe('as_token', registration.as_token)}'`,
`hs_token: '${assertYamlSafe('hs_token', registration.hs_token)}'`,
`sender_localpart: '${assertYamlSafe('sender_localpart', registration.sender_localpart)}'`,
`rate_limited: ${registration.rate_limited}`,
'namespaces:',
` users:${nsBlock(ns.users)}`,
` aliases:${nsBlock(ns.aliases)}`,
` rooms:${nsBlock(ns.rooms)}`,
'',
].join('\n');
}
@@ -0,0 +1,89 @@
import { timingSafeEqual } from 'node:crypto';
import type { EventHandler, HandlerResult, Transaction } from './types.js';
const MAX_SEEN_TXN_IDS = 1000;
function safeTokenCompare(presented: string | undefined, expected: string): boolean {
if (presented === undefined) return false;
const a = Buffer.from(presented);
const b = Buffer.from(expected);
if (a.length !== b.length) {
// Compare against a same-length dummy so length is not a timing oracle.
timingSafeEqual(a, Buffer.alloc(a.length));
return false;
}
return timingSafeEqual(a, b);
}
export interface TransactionHandlerOptions {
hsToken: string;
onEvent: EventHandler;
/** Called for handler errors; events are at-most-once, errors must not 500. */
onError?: (error: unknown, txnId: string) => void;
}
/**
* Framework-agnostic handler for the Application Service transactions API
* (PUT /_matrix/app/v1/transactions/{txnId}). Host apps (Fastify/Nest) wrap
* this in a route.
*
* Spec requirements covered: hs_token verification (Authorization: Bearer,
* with legacy ?access_token fallback), txnId idempotency, always-200 on
* accepted transactions (homeserver retries on any other status).
*
* KNOWN LIMITATION: the txnId dedupe ring is in-process memory only. After a
* restart the homeserver may redeliver pending transactions — event handlers
* must be idempotent (delivery is at-least-once across process lifetimes).
*/
export class TransactionHandler {
private readonly seen: string[] = [];
private readonly seenSet = new Set<string>();
constructor(private readonly options: TransactionHandlerOptions) {}
authorized(
authorizationHeader: string | undefined,
accessTokenParam: string | undefined,
): boolean {
const bearer = authorizationHeader?.startsWith('Bearer ')
? authorizationHeader.slice('Bearer '.length)
: undefined;
const presented = bearer ?? accessTokenParam;
return safeTokenCompare(presented, this.options.hsToken);
}
async handle(
txnId: string,
body: unknown,
auth: { authorizationHeader?: string; accessTokenParam?: string },
): Promise<HandlerResult> {
if (!this.authorized(auth.authorizationHeader, auth.accessTokenParam)) {
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad hs_token' } };
}
if (this.seenSet.has(txnId)) {
return { status: 200, body: {} };
}
this.markSeen(txnId);
const txn = (body ?? {}) as Partial<Transaction>;
for (const event of txn.events ?? []) {
try {
await this.options.onEvent(event);
} catch (error) {
// A failing handler must not fail the transaction: the homeserver
// would retry the whole batch forever.
this.options.onError?.(error, txnId);
}
}
return { status: 200, body: {} };
}
private markSeen(txnId: string): void {
this.seen.push(txnId);
this.seenSet.add(txnId);
while (this.seen.length > MAX_SEEN_TXN_IDS) {
const evicted = this.seen.shift();
if (evicted !== undefined) this.seenSet.delete(evicted);
}
}
}
+35
View File
@@ -0,0 +1,35 @@
export interface AppserviceConfig {
/** Homeserver client-server API base, e.g. https://chat.uscllc.com */
homeserverUrl: string;
/** Server name used in user IDs, e.g. chat.uscllc.com */
domain: string;
/** Token the appservice presents to the homeserver (as_token). */
asToken: string;
/** Token the homeserver presents to the appservice (hs_token). */
hsToken: string;
/** Localpart prefix owned by this appservice. Default: "agent-". */
userPrefix?: string;
/** The appservice's own sender user localpart. Default: "mosaic-as". */
senderLocalpart?: string;
}
export interface MatrixEvent {
type: string;
event_id?: string;
room_id?: string;
sender?: string;
state_key?: string;
content?: Record<string, unknown>;
origin_server_ts?: number;
}
export interface Transaction {
events: MatrixEvent[];
}
export type EventHandler = (event: MatrixEvent) => void | Promise<void>;
export interface HandlerResult {
status: number;
body: Record<string, unknown>;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@mosaicstack/auth",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/auth"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"dependencies": {
"@mosaicstack/db": "workspace:^",
"better-auth": "^1.5.5"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
"access": "public"
},
"files": [
"dist"
]
}
+161
View File
@@ -0,0 +1,161 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { buildOAuthProviders } from './auth.js';
describe('buildOAuthProviders', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env['AUTHENTIK_CLIENT_ID'];
delete process.env['AUTHENTIK_CLIENT_SECRET'];
delete process.env['AUTHENTIK_ISSUER'];
delete process.env['WORKOS_CLIENT_ID'];
delete process.env['WORKOS_CLIENT_SECRET'];
delete process.env['WORKOS_ISSUER'];
delete process.env['KEYCLOAK_CLIENT_ID'];
delete process.env['KEYCLOAK_CLIENT_SECRET'];
delete process.env['KEYCLOAK_ISSUER'];
delete process.env['KEYCLOAK_URL'];
delete process.env['KEYCLOAK_REALM'];
});
afterEach(() => {
process.env = originalEnv;
});
it('returns empty array when no SSO env vars are set', () => {
const providers = buildOAuthProviders();
expect(providers).toHaveLength(0);
});
describe('WorkOS', () => {
it('includes workos provider when all required env vars are set', () => {
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
process.env['WORKOS_CLIENT_SECRET'] = 'sk_live_test';
process.env['WORKOS_ISSUER'] = 'https://example.authkit.app/';
const providers = buildOAuthProviders();
const workos = providers.find((p) => p.providerId === 'workos');
expect(workos).toBeDefined();
expect(workos?.clientId).toBe('client_test123');
expect(workos?.issuer).toBe('https://example.authkit.app');
expect(workos?.discoveryUrl).toBe(
'https://example.authkit.app/.well-known/openid-configuration',
);
expect(workos?.scopes).toEqual(['openid', 'email', 'profile']);
});
it('throws when WorkOS is partially configured', () => {
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
expect(() => buildOAuthProviders()).toThrow(
'@mosaicstack/auth: WorkOS SSO requires WORKOS_ISSUER, WORKOS_CLIENT_ID, WORKOS_CLIENT_SECRET.',
);
});
it('excludes workos provider when WorkOS is not configured', () => {
const providers = buildOAuthProviders();
const workos = providers.find((p) => p.providerId === 'workos');
expect(workos).toBeUndefined();
});
});
describe('Keycloak', () => {
it('includes keycloak provider when KEYCLOAK_ISSUER is set', () => {
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
process.env['KEYCLOAK_ISSUER'] = 'https://auth.example.com/realms/myrealm/';
const providers = buildOAuthProviders();
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
expect(keycloakProvider).toBeDefined();
expect(keycloakProvider?.clientId).toBe('mosaic');
expect(keycloakProvider?.discoveryUrl).toBe(
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
);
expect(keycloakProvider?.scopes).toEqual(['openid', 'email', 'profile']);
});
it('supports deriving the Keycloak issuer from KEYCLOAK_URL and KEYCLOAK_REALM', () => {
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
process.env['KEYCLOAK_URL'] = 'https://auth.example.com/';
process.env['KEYCLOAK_REALM'] = 'myrealm';
const providers = buildOAuthProviders();
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
expect(keycloakProvider?.discoveryUrl).toBe(
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
);
});
it('throws when Keycloak is partially configured', () => {
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
expect(() => buildOAuthProviders()).toThrow(
'@mosaicstack/auth: Keycloak SSO requires KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER.',
);
});
it('excludes keycloak provider when Keycloak is not configured', () => {
const providers = buildOAuthProviders();
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
expect(keycloakProvider).toBeUndefined();
});
});
describe('Authentik', () => {
it('includes authentik provider when all required env vars are set', () => {
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
process.env['AUTHENTIK_CLIENT_SECRET'] = 'authentik-secret';
process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic/';
const providers = buildOAuthProviders();
const authentik = providers.find((p) => p.providerId === 'authentik');
expect(authentik).toBeDefined();
expect(authentik?.clientId).toBe('authentik-client');
expect(authentik?.issuer).toBe('https://auth.example.com/application/o/mosaic');
expect(authentik?.discoveryUrl).toBe(
'https://auth.example.com/application/o/mosaic/.well-known/openid-configuration',
);
});
it('throws when Authentik is partially configured', () => {
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
expect(() => buildOAuthProviders()).toThrow(
'@mosaicstack/auth: Authentik SSO requires AUTHENTIK_ISSUER, AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_SECRET.',
);
});
it('excludes authentik provider when Authentik is not configured', () => {
const providers = buildOAuthProviders();
const authentik = providers.find((p) => p.providerId === 'authentik');
expect(authentik).toBeUndefined();
});
});
it('registers all three providers when all env vars are set', () => {
process.env['AUTHENTIK_CLIENT_ID'] = 'a-id';
process.env['AUTHENTIK_CLIENT_SECRET'] = 'a-secret';
process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic';
process.env['WORKOS_CLIENT_ID'] = 'w-id';
process.env['WORKOS_CLIENT_SECRET'] = 'w-secret';
process.env['WORKOS_ISSUER'] = 'https://example.authkit.app';
process.env['KEYCLOAK_CLIENT_ID'] = 'k-id';
process.env['KEYCLOAK_CLIENT_SECRET'] = 'k-secret';
process.env['KEYCLOAK_ISSUER'] = 'https://kc.example.com/realms/test';
const providers = buildOAuthProviders();
expect(providers).toHaveLength(3);
const ids = providers.map((p) => p.providerId);
expect(ids).toContain('authentik');
expect(ids).toContain('workos');
expect(ids).toContain('keycloak');
});
});
+63
View File
@@ -0,0 +1,63 @@
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { admin } from 'better-auth/plugins';
import { genericOAuth, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
import type { Db } from '@mosaicstack/db';
import { buildGenericOidcProviderConfigs } from './sso.js';
export interface AuthConfig {
db: Db;
baseURL?: string;
secret?: string;
}
export function buildOAuthProviders(): GenericOAuthConfig[] {
return buildGenericOidcProviderConfigs() as GenericOAuthConfig[];
}
export function createAuth(config: AuthConfig) {
const { db, baseURL, secret } = config;
const oidcConfigs = buildOAuthProviders();
const plugins =
oidcConfigs.length > 0
? [
genericOAuth({
config: oidcConfigs,
}),
]
: undefined;
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
return betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
usePlural: true,
}),
baseURL: baseURL ?? process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242',
secret: secret ?? process.env['BETTER_AUTH_SECRET'],
basePath: '/api/auth',
trustedOrigins,
emailAndPassword: {
enabled: true,
},
user: {
additionalFields: {
role: {
type: 'string',
required: false,
defaultValue: 'member',
input: false,
},
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // refresh daily
},
plugins: [...(plugins ?? []), admin({ defaultRole: 'member', adminRoles: ['admin'] })],
});
}
export type Auth = ReturnType<typeof createAuth>;
+13
View File
@@ -0,0 +1,13 @@
export { createAuth, type Auth, type AuthConfig } from './auth.js';
export {
buildGenericOidcProviderConfigs,
buildSsoDiscovery,
listSsoStartupWarnings,
type GenericOidcProviderConfig,
type SsoLoginMode,
type SsoProtocol,
type SsoProviderDiscovery,
type SsoTeamSyncConfig,
type SupportedSsoProviderId,
} from './sso.js';
export { seal, unseal } from './seal.js';
+52
View File
@@ -0,0 +1,52 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96-bit IV for GCM
const TAG_LENGTH = 16; // 128-bit auth tag
/**
* Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256.
* Throws if BETTER_AUTH_SECRET is not set.
*/
function deriveKey(): Buffer {
const secret = process.env['BETTER_AUTH_SECRET'];
if (!secret) {
throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key');
}
return createHash('sha256').update(secret).digest();
}
/**
* Seal a plaintext string using AES-256-GCM.
* Output format: base64(IV || authTag || ciphertext)
*/
export function seal(plaintext: string): string {
const key = deriveKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
const combined = Buffer.concat([iv, authTag, encrypted]);
return combined.toString('base64');
}
/**
* Unseal a value sealed by `seal()`.
* Throws on authentication failure (tampered data) or if BETTER_AUTH_SECRET is unset.
*/
export function unseal(encoded: string): string {
const key = deriveKey();
const combined = Buffer.from(encoded, 'base64');
const iv = combined.subarray(0, IV_LENGTH);
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString('utf8');
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import {
buildGenericOidcProviderConfigs,
buildSsoDiscovery,
listSsoStartupWarnings,
} from './sso.js';
describe('SSO provider config helpers', () => {
it('builds OIDC configs for Authentik, WorkOS, and Keycloak when fully configured', () => {
const configs = buildGenericOidcProviderConfigs({
AUTHENTIK_CLIENT_ID: 'authentik-client',
AUTHENTIK_CLIENT_SECRET: 'authentik-secret',
AUTHENTIK_ISSUER: 'https://authentik.example.com',
WORKOS_CLIENT_ID: 'workos-client',
WORKOS_CLIENT_SECRET: 'workos-secret',
WORKOS_ISSUER: 'https://auth.workos.com/sso/client_123',
KEYCLOAK_CLIENT_ID: 'keycloak-client',
KEYCLOAK_CLIENT_SECRET: 'keycloak-secret',
KEYCLOAK_ISSUER: 'https://sso.example.com/realms/mosaic',
});
expect(configs.map((config) => config.providerId)).toEqual(['authentik', 'workos', 'keycloak']);
expect(configs.find((config) => config.providerId === 'workos')).toMatchObject({
discoveryUrl: 'https://auth.workos.com/sso/client_123/.well-known/openid-configuration',
pkce: true,
requireIssuerValidation: true,
});
expect(configs.find((config) => config.providerId === 'keycloak')).toMatchObject({
discoveryUrl: 'https://sso.example.com/realms/mosaic/.well-known/openid-configuration',
pkce: true,
});
});
it('exposes Keycloak SAML fallback when OIDC is not configured', () => {
const providers = buildSsoDiscovery({
KEYCLOAK_SAML_LOGIN_URL: 'https://sso.example.com/realms/mosaic/protocol/saml',
});
expect(providers.find((provider) => provider.id === 'keycloak')).toMatchObject({
configured: true,
loginMode: 'saml',
samlFallback: {
configured: true,
loginUrl: 'https://sso.example.com/realms/mosaic/protocol/saml',
},
});
});
it('reports partial provider configuration as startup warnings', () => {
const warnings = listSsoStartupWarnings({
WORKOS_CLIENT_ID: 'workos-client',
KEYCLOAK_CLIENT_ID: 'keycloak-client',
});
expect(warnings).toContain(
'workos OIDC is partially configured. Missing: WORKOS_CLIENT_SECRET, WORKOS_ISSUER',
);
expect(warnings).toContain(
'keycloak OIDC is partially configured. Missing: KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER',
);
});
});
+241
View File
@@ -0,0 +1,241 @@
export type SupportedSsoProviderId = 'authentik' | 'workos' | 'keycloak';
export type SsoProtocol = 'oidc' | 'saml';
export type SsoLoginMode = 'oidc' | 'saml' | null;
type EnvMap = Record<string, string | undefined>;
export interface GenericOidcProviderConfig {
providerId: SupportedSsoProviderId;
clientId: string;
clientSecret: string;
discoveryUrl?: string;
issuer?: string;
authorizationUrl?: string;
tokenUrl?: string;
userInfoUrl?: string;
scopes: string[];
pkce?: boolean;
requireIssuerValidation?: boolean;
}
export interface SsoTeamSyncConfig {
enabled: boolean;
claim: string | null;
}
export interface SsoProviderDiscovery {
id: SupportedSsoProviderId;
name: string;
protocols: SsoProtocol[];
configured: boolean;
loginMode: SsoLoginMode;
callbackPath: string | null;
teamSync: SsoTeamSyncConfig;
samlFallback: {
configured: boolean;
loginUrl: string | null;
};
warnings: string[];
}
const DEFAULT_SCOPES = ['openid', 'email', 'profile'];
function readEnv(env: EnvMap, key: string): string | undefined {
const value = env[key]?.trim();
return value ? value : undefined;
}
function toDiscoveryUrl(issuer: string): string {
return `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
}
function getTeamSyncClaim(env: EnvMap, envKey: string, fallbackClaim?: string): SsoTeamSyncConfig {
const claim = readEnv(env, envKey) ?? fallbackClaim ?? null;
return {
enabled: claim !== null,
claim,
};
}
function buildAuthentikConfig(env: EnvMap): GenericOidcProviderConfig | null {
const issuer = readEnv(env, 'AUTHENTIK_ISSUER');
const clientId = readEnv(env, 'AUTHENTIK_CLIENT_ID');
const clientSecret = readEnv(env, 'AUTHENTIK_CLIENT_SECRET');
const fields = [issuer, clientId, clientSecret];
const presentCount = fields.filter(Boolean).length;
if (presentCount > 0 && presentCount < fields.length) {
throw new Error(
'@mosaicstack/auth: Authentik SSO requires AUTHENTIK_ISSUER, AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_SECRET.',
);
}
if (!issuer || !clientId || !clientSecret) {
return null;
}
const baseIssuer = issuer.replace(/\/$/, '');
return {
providerId: 'authentik',
issuer: baseIssuer,
clientId,
clientSecret,
discoveryUrl: toDiscoveryUrl(baseIssuer),
authorizationUrl: `${baseIssuer}/application/o/authorize/`,
tokenUrl: `${baseIssuer}/application/o/token/`,
userInfoUrl: `${baseIssuer}/application/o/userinfo/`,
scopes: DEFAULT_SCOPES,
};
}
function buildWorkosConfig(env: EnvMap): GenericOidcProviderConfig | null {
const issuer = readEnv(env, 'WORKOS_ISSUER');
const clientId = readEnv(env, 'WORKOS_CLIENT_ID');
const clientSecret = readEnv(env, 'WORKOS_CLIENT_SECRET');
const fields = [issuer, clientId, clientSecret];
const presentCount = fields.filter(Boolean).length;
if (presentCount > 0 && presentCount < fields.length) {
throw new Error(
'@mosaicstack/auth: WorkOS SSO requires WORKOS_ISSUER, WORKOS_CLIENT_ID, WORKOS_CLIENT_SECRET.',
);
}
if (!issuer || !clientId || !clientSecret) {
return null;
}
const normalizedIssuer = issuer.replace(/\/$/, '');
return {
providerId: 'workos',
issuer: normalizedIssuer,
clientId,
clientSecret,
discoveryUrl: toDiscoveryUrl(normalizedIssuer),
scopes: DEFAULT_SCOPES,
pkce: true,
requireIssuerValidation: true,
};
}
function buildKeycloakConfig(env: EnvMap): GenericOidcProviderConfig | null {
const explicitIssuer = readEnv(env, 'KEYCLOAK_ISSUER');
const keycloakUrl = readEnv(env, 'KEYCLOAK_URL');
const keycloakRealm = readEnv(env, 'KEYCLOAK_REALM');
const clientId = readEnv(env, 'KEYCLOAK_CLIENT_ID');
const clientSecret = readEnv(env, 'KEYCLOAK_CLIENT_SECRET');
// Derive issuer from KEYCLOAK_URL + KEYCLOAK_REALM if KEYCLOAK_ISSUER not set
const issuer =
explicitIssuer ??
(keycloakUrl && keycloakRealm
? `${keycloakUrl.replace(/\/$/, '')}/realms/${keycloakRealm}`
: undefined);
const anySet = !!(issuer || clientId || clientSecret);
if (anySet && (!issuer || !clientId || !clientSecret)) {
throw new Error(
'@mosaicstack/auth: Keycloak SSO requires KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER.',
);
}
if (!issuer || !clientId || !clientSecret) {
return null;
}
const normalizedIssuer = issuer.replace(/\/$/, '');
return {
providerId: 'keycloak',
issuer: normalizedIssuer,
clientId,
clientSecret,
discoveryUrl: toDiscoveryUrl(normalizedIssuer),
scopes: DEFAULT_SCOPES,
pkce: true,
requireIssuerValidation: true,
};
}
function collectWarnings(env: EnvMap, provider: SupportedSsoProviderId): string[] {
const prefix = provider.toUpperCase();
const oidcFields = [
`${prefix}_CLIENT_ID`,
`${prefix}_CLIENT_SECRET`,
`${prefix}_ISSUER`,
] as const;
const presentOidcFields = oidcFields.filter((field) => readEnv(env, field));
const warnings: string[] = [];
if (presentOidcFields.length > 0 && presentOidcFields.length < oidcFields.length) {
const missing = oidcFields.filter((field) => !readEnv(env, field));
warnings.push(`${provider} OIDC is partially configured. Missing: ${missing.join(', ')}`);
}
return warnings;
}
export function buildGenericOidcProviderConfigs(
env: EnvMap = process.env,
): GenericOidcProviderConfig[] {
return [buildAuthentikConfig(env), buildWorkosConfig(env), buildKeycloakConfig(env)].filter(
(config): config is GenericOidcProviderConfig => config !== null,
);
}
export function listSsoStartupWarnings(env: EnvMap = process.env): string[] {
return ['authentik', 'workos', 'keycloak'].flatMap((provider) =>
collectWarnings(env, provider as SupportedSsoProviderId),
);
}
export function buildSsoDiscovery(env: EnvMap = process.env): SsoProviderDiscovery[] {
const oidcConfigs = new Map(
buildGenericOidcProviderConfigs(env).map((config) => [config.providerId, config]),
);
const keycloakSamlLoginUrl = readEnv(env, 'KEYCLOAK_SAML_LOGIN_URL') ?? null;
return [
{
id: 'authentik',
name: 'Authentik',
protocols: ['oidc'],
configured: oidcConfigs.has('authentik'),
loginMode: oidcConfigs.has('authentik') ? 'oidc' : null,
callbackPath: oidcConfigs.has('authentik') ? '/api/auth/oauth2/callback/authentik' : null,
teamSync: getTeamSyncClaim(env, 'AUTHENTIK_TEAM_SYNC_CLAIM', 'groups'),
samlFallback: {
configured: false,
loginUrl: null,
},
warnings: collectWarnings(env, 'authentik'),
},
{
id: 'workos',
name: 'WorkOS',
protocols: ['oidc'],
configured: oidcConfigs.has('workos'),
loginMode: oidcConfigs.has('workos') ? 'oidc' : null,
callbackPath: oidcConfigs.has('workos') ? '/api/auth/oauth2/callback/workos' : null,
teamSync: getTeamSyncClaim(env, 'WORKOS_TEAM_SYNC_CLAIM', 'organization_id'),
samlFallback: {
configured: false,
loginUrl: null,
},
warnings: collectWarnings(env, 'workos'),
},
{
id: 'keycloak',
name: 'Keycloak',
protocols: ['oidc', 'saml'],
configured: oidcConfigs.has('keycloak') || keycloakSamlLoginUrl !== null,
loginMode: oidcConfigs.has('keycloak') ? 'oidc' : keycloakSamlLoginUrl ? 'saml' : null,
callbackPath: oidcConfigs.has('keycloak') ? '/api/auth/oauth2/callback/keycloak' : null,
teamSync: getTeamSyncClaim(env, 'KEYCLOAK_TEAM_SYNC_CLAIM', 'groups'),
samlFallback: {
configured: keycloakSamlLoginUrl !== null,
loginUrl: keycloakSamlLoginUrl,
},
warnings: collectWarnings(env, 'keycloak'),
},
];
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@mosaicstack/brain",
"version": "0.0.3",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/brain"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@mosaicstack/db": "workspace:^",
"@mosaicstack/types": "workspace:*",
"commander": "^13.0.0"
},
"devDependencies": {
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
"access": "public"
},
"files": [
"dist"
]
}
+89
View File
@@ -0,0 +1,89 @@
import { eq, and, or, type Db, agents } from '@mosaicstack/db';
export type Agent = typeof agents.$inferSelect;
export type NewAgent = typeof agents.$inferInsert;
export function createAgentsRepo(db: Db) {
return {
async findAll(): Promise<Agent[]> {
return db.select().from(agents);
},
async findById(id: string): Promise<Agent | undefined> {
const rows = await db.select().from(agents).where(eq(agents.id, id));
return rows[0];
},
async findByName(name: string): Promise<Agent | undefined> {
const rows = await db.select().from(agents).where(eq(agents.name, name));
return rows[0];
},
async findByProject(projectId: string): Promise<Agent[]> {
return db.select().from(agents).where(eq(agents.projectId, projectId));
},
async findSystem(): Promise<Agent[]> {
return db.select().from(agents).where(eq(agents.isSystem, true));
},
/**
* Return only agents the user may access: their own agents plus all system agents.
* Never returns other users' private agents.
*/
async findAccessible(ownerId: string): Promise<Agent[]> {
return db
.select()
.from(agents)
.where(or(eq(agents.ownerId, ownerId), eq(agents.isSystem, true)));
},
async create(data: NewAgent): Promise<Agent> {
const rows = await db.insert(agents).values(data).returning();
return rows[0]!;
},
/**
* Update an agent.
*
* For user-owned agents pass `ownerId` — the WHERE clause will enforce ownership so that
* one user cannot overwrite another user's agent. For system agents the caller must
* omit `ownerId` (admin-only path) and the WHERE clause only matches on `id`.
*
* Returns undefined when no row was matched (not found or ownership mismatch).
*/
async update(
id: string,
data: Partial<NewAgent>,
ownerId?: string,
): Promise<Agent | undefined> {
const condition =
ownerId !== undefined
? and(eq(agents.id, id), eq(agents.ownerId, ownerId))
: eq(agents.id, id);
const rows = await db
.update(agents)
.set({ ...data, updatedAt: new Date() })
.where(condition)
.returning();
return rows[0];
},
/**
* Delete a user-owned agent, scoped to the given owner.
* Will not match system agents even if the id is correct, because system agents have
* `ownerId = null` which cannot equal a real user id.
* Returns false when no row was matched (not found, wrong owner, or system agent).
*/
async remove(id: string, ownerId: string): Promise<boolean> {
const rows = await db
.delete(agents)
.where(and(eq(agents.id, id), eq(agents.ownerId, ownerId)))
.returning();
return rows.length > 0;
},
};
}
export type AgentsRepo = ReturnType<typeof createAgentsRepo>;
+27
View File
@@ -0,0 +1,27 @@
import type { Db } from '@mosaicstack/db';
import { createProjectsRepo, type ProjectsRepo } from './projects.js';
import { createMissionsRepo, type MissionsRepo } from './missions.js';
import { createMissionTasksRepo, type MissionTasksRepo } from './mission-tasks.js';
import { createTasksRepo, type TasksRepo } from './tasks.js';
import { createConversationsRepo, type ConversationsRepo } from './conversations.js';
import { createAgentsRepo, type AgentsRepo } from './agents.js';
export interface Brain {
projects: ProjectsRepo;
missions: MissionsRepo;
missionTasks: MissionTasksRepo;
tasks: TasksRepo;
conversations: ConversationsRepo;
agents: AgentsRepo;
}
export function createBrain(db: Db): Brain {
return {
projects: createProjectsRepo(db),
missions: createMissionsRepo(db),
missionTasks: createMissionTasksRepo(db),
tasks: createTasksRepo(db),
conversations: createConversationsRepo(db),
agents: createAgentsRepo(db),
};
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import { Command } from 'commander';
import { registerBrainCommand } from './cli.js';
/**
* Smoke test: verifies the command tree is correctly registered.
* No database connection is opened — we only inspect Commander metadata.
*/
describe('registerBrainCommand', () => {
function buildProgram(): Command {
const program = new Command('mosaic');
// Prevent Commander from calling process.exit on parse errors during tests.
program.exitOverride();
registerBrainCommand(program);
return program;
}
it('registers a top-level "brain" command', () => {
const program = buildProgram();
const brainCmd = program.commands.find((c) => c.name() === 'brain');
expect(brainCmd).toBeDefined();
});
it('registers "brain projects" with "list" and "create" subcommands', () => {
const program = buildProgram();
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
const projectsCmd = brainCmd.commands.find((c) => c.name() === 'projects');
expect(projectsCmd).toBeDefined();
const subNames = projectsCmd!.commands.map((c) => c.name());
expect(subNames).toContain('list');
expect(subNames).toContain('create');
});
it('registers "brain missions" with "list" subcommand', () => {
const program = buildProgram();
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
const missionsCmd = brainCmd.commands.find((c) => c.name() === 'missions');
expect(missionsCmd).toBeDefined();
const subNames = missionsCmd!.commands.map((c) => c.name());
expect(subNames).toContain('list');
});
it('registers "brain tasks" with "list" subcommand', () => {
const program = buildProgram();
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
const tasksCmd = brainCmd.commands.find((c) => c.name() === 'tasks');
expect(tasksCmd).toBeDefined();
const subNames = tasksCmd!.commands.map((c) => c.name());
expect(subNames).toContain('list');
});
it('registers "brain conversations" with "list" subcommand', () => {
const program = buildProgram();
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
const conversationsCmd = brainCmd.commands.find((c) => c.name() === 'conversations');
expect(conversationsCmd).toBeDefined();
const subNames = conversationsCmd!.commands.map((c) => c.name());
expect(subNames).toContain('list');
});
it('"brain projects list" accepts --db and --limit options', () => {
const program = buildProgram();
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
const projectsCmd = brainCmd.commands.find((c) => c.name() === 'projects')!;
const listCmd = projectsCmd.commands.find((c) => c.name() === 'list')!;
const optionNames = listCmd.options.map((o) => o.long);
expect(optionNames).toContain('--db');
expect(optionNames).toContain('--limit');
});
it('"brain missions list" accepts --project option', () => {
const program = buildProgram();
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
const missionsCmd = brainCmd.commands.find((c) => c.name() === 'missions')!;
const listCmd = missionsCmd.commands.find((c) => c.name() === 'list')!;
const optionNames = listCmd.options.map((o) => o.long);
expect(optionNames).toContain('--project');
});
it('"brain tasks list" accepts --project option', () => {
const program = buildProgram();
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
const tasksCmd = brainCmd.commands.find((c) => c.name() === 'tasks')!;
const listCmd = tasksCmd.commands.find((c) => c.name() === 'list')!;
const optionNames = listCmd.options.map((o) => o.long);
expect(optionNames).toContain('--project');
});
});
+142
View File
@@ -0,0 +1,142 @@
import type { Command } from 'commander';
import { createDb, type DbHandle } from '@mosaicstack/db';
import { createBrain } from './brain.js';
/**
* Build and attach the `brain` subcommand tree onto an existing Commander program.
* Uses the caller's Command instance to avoid cross-package Commander version mismatches.
*/
export function registerBrainCommand(parent: Command): void {
const brain = parent.command('brain').description('Inspect and manage brain data stores');
// ─── shared DB option helper ─────────────────────────────────────────────
function addDbOption(cmd: Command): Command {
return cmd.option(
'--db <connection-string>',
'PostgreSQL connection string (overrides MOSAIC_DB_URL)',
);
}
function resolveDb(opts: { db?: string }): ReturnType<typeof createBrain> {
const connectionString = opts.db ?? process.env['MOSAIC_DB_URL'];
if (!connectionString) {
console.error('No DB connection string provided. Pass --db <url> or set MOSAIC_DB_URL.');
process.exit(1);
}
const handle: DbHandle = createDb(connectionString);
return createBrain(handle.db);
}
// ─── projects ────────────────────────────────────────────────────────────
const projects = brain.command('projects').description('Manage projects');
addDbOption(
projects
.command('list')
.description('List all projects')
.option('--limit <n>', 'Maximum number of results', '50'),
).action(async (opts: { db?: string; limit: string }) => {
const b = resolveDb(opts);
const limit = parseInt(opts.limit, 10);
const rows = await b.projects.findAll();
const sliced = rows.slice(0, limit);
if (sliced.length === 0) {
console.log('No projects found.');
return;
}
for (const p of sliced) {
console.log(`${p.id} ${p.name}`);
}
});
addDbOption(
projects
.command('create <name>')
.description('Create a new project')
.requiredOption('--owner-id <id>', 'Owner user ID'),
).action(async (name: string, opts: { db?: string; ownerId: string }) => {
const b = resolveDb(opts);
const created = await b.projects.create({
name,
ownerId: opts.ownerId,
ownerType: 'user',
});
console.log(`Created project: ${created.id} ${created.name}`);
});
// ─── missions ────────────────────────────────────────────────────────────
const missions = brain.command('missions').description('Manage missions');
addDbOption(
missions
.command('list')
.description('List all missions')
.option('--limit <n>', 'Maximum number of results', '50')
.option('--project <id>', 'Filter by project ID'),
).action(async (opts: { db?: string; limit: string; project?: string }) => {
const b = resolveDb(opts);
const limit = parseInt(opts.limit, 10);
const rows = opts.project
? await b.missions.findByProject(opts.project)
: await b.missions.findAll();
const sliced = rows.slice(0, limit);
if (sliced.length === 0) {
console.log('No missions found.');
return;
}
for (const m of sliced) {
console.log(`${m.id} ${m.name}`);
}
});
// ─── tasks ────────────────────────────────────────────────────────────────
const tasks = brain.command('tasks').description('Manage generic tasks');
addDbOption(
tasks
.command('list')
.description('List all tasks')
.option('--limit <n>', 'Maximum number of results', '50')
.option('--project <id>', 'Filter by project ID'),
).action(async (opts: { db?: string; limit: string; project?: string }) => {
const b = resolveDb(opts);
const limit = parseInt(opts.limit, 10);
const rows = opts.project ? await b.tasks.findByProject(opts.project) : await b.tasks.findAll();
const sliced = rows.slice(0, limit);
if (sliced.length === 0) {
console.log('No tasks found.');
return;
}
for (const t of sliced) {
console.log(`${t.id} ${t.title} [${t.status}]`);
}
});
// ─── conversations ────────────────────────────────────────────────────────
const conversations = brain.command('conversations').description('Manage conversations');
addDbOption(
conversations
.command('list')
.description('List conversations for a user')
.option('--limit <n>', 'Maximum number of results', '50')
.requiredOption('--user-id <id>', 'User ID to scope the query'),
).action(async (opts: { db?: string; limit: string; userId: string }) => {
const b = resolveDb(opts);
const limit = parseInt(opts.limit, 10);
const rows = await b.conversations.findAll(opts.userId);
const sliced = rows.slice(0, limit);
if (sliced.length === 0) {
console.log('No conversations found.');
return;
}
for (const c of sliced) {
console.log(`${c.id} ${c.title ?? '(untitled)'}`);
}
});
}
+147
View File
@@ -0,0 +1,147 @@
import { eq, and, asc, desc, ilike, type Db, conversations, messages } from '@mosaicstack/db';
/** Maximum number of conversations returned per list query. */
const MAX_CONVERSATIONS = 200;
/** Maximum number of messages returned per conversation history query. */
const MAX_MESSAGES = 500;
export type Conversation = typeof conversations.$inferSelect;
export type NewConversation = typeof conversations.$inferInsert;
export type Message = typeof messages.$inferSelect;
export type NewMessage = typeof messages.$inferInsert;
export interface MessageSearchResult {
messageId: string;
conversationId: string;
conversationTitle: string | null;
role: 'user' | 'assistant' | 'system';
content: string;
createdAt: Date;
}
export function createConversationsRepo(db: Db) {
return {
async findAll(userId: string): Promise<Conversation[]> {
return db
.select()
.from(conversations)
.where(eq(conversations.userId, userId))
.orderBy(desc(conversations.updatedAt))
.limit(MAX_CONVERSATIONS);
},
/**
* Find a conversation by ID, scoped to the given user.
* Returns undefined if the conversation does not exist or belongs to a different user.
*/
async findById(id: string, userId: string): Promise<Conversation | undefined> {
const rows = await db
.select()
.from(conversations)
.where(and(eq(conversations.id, id), eq(conversations.userId, userId)));
return rows[0];
},
async create(data: NewConversation): Promise<Conversation> {
const rows = await db.insert(conversations).values(data).returning();
return rows[0]!;
},
/**
* Update a conversation, scoped to the given user.
* Returns undefined if the conversation does not exist or belongs to a different user.
*/
async update(
id: string,
userId: string,
data: Partial<NewConversation>,
): Promise<Conversation | undefined> {
const rows = await db
.update(conversations)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(conversations.id, id), eq(conversations.userId, userId)))
.returning();
return rows[0];
},
/**
* Delete a conversation, scoped to the given user.
* Returns false if the conversation does not exist or belongs to a different user.
*/
async remove(id: string, userId: string): Promise<boolean> {
const rows = await db
.delete(conversations)
.where(and(eq(conversations.id, id), eq(conversations.userId, userId)))
.returning();
return rows.length > 0;
},
/**
* Find messages for a conversation, scoped to the given user.
* Returns an empty array if the conversation does not exist or belongs to a different user.
*/
async findMessages(conversationId: string, userId: string): Promise<Message[]> {
// Verify ownership of the parent conversation before returning messages.
const conv = await db
.select()
.from(conversations)
.where(and(eq(conversations.id, conversationId), eq(conversations.userId, userId)));
if (conv.length === 0) return [];
return db
.select()
.from(messages)
.where(eq(messages.conversationId, conversationId))
.orderBy(asc(messages.createdAt))
.limit(MAX_MESSAGES);
},
/**
* Search messages by content across all conversations belonging to the user.
* Uses ILIKE for case-insensitive substring matching.
*/
async searchMessages(
userId: string,
query: string,
limit: number,
offset: number,
): Promise<MessageSearchResult[]> {
const rows = await db
.select({
messageId: messages.id,
conversationId: conversations.id,
conversationTitle: conversations.title,
role: messages.role,
content: messages.content,
createdAt: messages.createdAt,
})
.from(messages)
.innerJoin(conversations, eq(messages.conversationId, conversations.id))
.where(and(eq(conversations.userId, userId), ilike(messages.content, `%${query}%`)))
.orderBy(desc(messages.createdAt))
.limit(limit)
.offset(offset);
return rows;
},
/**
* Add a message to a conversation, scoped to the given user.
* Verifies the parent conversation belongs to the user before inserting.
* Returns undefined if the conversation does not exist or belongs to a different user.
*/
async addMessage(data: NewMessage, userId: string): Promise<Message | undefined> {
// Verify ownership of the parent conversation before inserting the message.
const conv = await db
.select()
.from(conversations)
.where(and(eq(conversations.id, data.conversationId), eq(conversations.userId, userId)));
if (conv.length === 0) return undefined;
const rows = await db.insert(messages).values(data).returning();
return rows[0]!;
},
};
}
export type ConversationsRepo = ReturnType<typeof createConversationsRepo>;
+36
View File
@@ -0,0 +1,36 @@
export { createBrain, type Brain } from './brain.js';
export { registerBrainCommand } from './cli.js';
export {
createProjectsRepo,
type ProjectsRepo,
type Project,
type NewProject,
} from './projects.js';
export {
createMissionsRepo,
type MissionsRepo,
type Mission,
type NewMission,
} from './missions.js';
export {
createMissionTasksRepo,
type MissionTasksRepo,
type MissionTask,
type NewMissionTask,
} from './mission-tasks.js';
export { createTasksRepo, type TasksRepo, type Task, type NewTask } from './tasks.js';
export {
createConversationsRepo,
type ConversationsRepo,
type Conversation,
type NewConversation,
type Message,
type NewMessage,
type MessageSearchResult,
} from './conversations.js';
export {
createAgentsRepo,
type AgentsRepo,
type Agent as AgentConfig,
type NewAgent as NewAgentConfig,
} from './agents.js';
@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from 'vitest';
import { createMissionTasksRepo } from './mission-tasks.js';
/**
* SHARED-CONTRACT §5.5 "mission_tasks.status write prohibition": this repo is
* the sole path that authors mission_tasks.status from caller input (storage
* tier migration is row transport and preserves stored values; the generic
* storage adapters have no mission_tasks caller), and it must never forward a
* caller-supplied status to the database on create or update. Callers keep
* working (the field is accepted and ignored), so these tests assert on what
* reaches the Drizzle chain, not on rejection.
*/
function makeInsertDb(returned: unknown[]) {
const values = vi.fn((_v: unknown) => ({ returning: vi.fn().mockResolvedValue(returned) }));
return { db: { insert: vi.fn(() => ({ values })) }, values };
}
function makeUpdateDb(returned: unknown[]) {
const set = vi.fn((_v: unknown) => ({
where: vi.fn(() => ({ returning: vi.fn().mockResolvedValue(returned) })),
}));
return { db: { update: vi.fn(() => ({ set })) }, set };
}
describe('createMissionTasksRepo — status write prohibition', () => {
it('create strips a caller-supplied status before insert', async () => {
const { db, values } = makeInsertDb([{ id: 'mt1', status: 'not-started' }]);
const repo = createMissionTasksRepo(db as never);
const result = await repo.create({
missionId: 'm1',
userId: 'u1',
status: 'done',
description: 'd',
} as never);
expect(values).toHaveBeenCalledTimes(1);
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
expect('status' in inserted).toBe(false);
expect(inserted.missionId).toBe('m1');
expect(inserted.description).toBe('d');
expect(result.id).toBe('mt1');
});
it('create without status still inserts (DB default applies)', async () => {
const { db, values } = makeInsertDb([{ id: 'mt2' }]);
const repo = createMissionTasksRepo(db as never);
await repo.create({ missionId: 'm1', userId: 'u1' } as never);
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
expect('status' in inserted).toBe(false);
});
it('update strips a caller-supplied status but keeps the other fields', async () => {
const { db, set } = makeUpdateDb([{ id: 'mt1', notes: 'n' }]);
const repo = createMissionTasksRepo(db as never);
const result = await repo.update('mt1', { status: 'done', notes: 'n' } as never);
expect(set).toHaveBeenCalledTimes(1);
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
expect('status' in updated).toBe(false);
expect(updated.notes).toBe('n');
expect(updated.updatedAt).toBeInstanceOf(Date);
expect(result?.id).toBe('mt1');
});
it('update with only status degenerates to a timestamp-only update', async () => {
const { db, set } = makeUpdateDb([{ id: 'mt1' }]);
const repo = createMissionTasksRepo(db as never);
await repo.update('mt1', { status: 'blocked' } as never);
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
expect(Object.keys(updated)).toEqual(['updatedAt']);
});
});
+79
View File
@@ -0,0 +1,79 @@
import { eq, and, type Db, missionTasks } from '@mosaicstack/db';
export type MissionTask = typeof missionTasks.$inferSelect;
export type NewMissionTask = typeof missionTasks.$inferInsert;
// SHARED-CONTRACT §5.1 phase 1 / §5.4: mission_tasks.status is prohibited as a
// write source through the N-1 window. This repo is the sole path that authors
// status from caller input, so the field is stripped here — accepted and
// ignored rather than rejected, because the legacy surface is frozen with
// existing consumers kept working (tool-gateway-mapping.md §3.2). Two other
// surfaces touch the column and are deliberately NOT stripped:
// packages/storage/migrate-tier.ts copies whole rows between storage tiers and
// must preserve the stored value verbatim, and the generic table-keyed storage
// adapters register mission_tasks but have no caller that targets it (runtime
// callers use fixed collection constants). Neither authors a new status. The
// column keeps its DB default, stays declared and readable, and is retired
// only after no readers remain.
function stripStatus<T extends { status?: unknown }>(data: T): Omit<T, 'status'> {
const rest = { ...data };
delete rest.status;
return rest;
}
export function createMissionTasksRepo(db: Db) {
return {
async findByMission(missionId: string): Promise<MissionTask[]> {
return db.select().from(missionTasks).where(eq(missionTasks.missionId, missionId));
},
async findByMissionAndUser(missionId: string, userId: string): Promise<MissionTask[]> {
return db
.select()
.from(missionTasks)
.where(and(eq(missionTasks.missionId, missionId), eq(missionTasks.userId, userId)));
},
async findById(id: string): Promise<MissionTask | undefined> {
const rows = await db.select().from(missionTasks).where(eq(missionTasks.id, id));
return rows[0];
},
async findByIdAndUser(id: string, userId: string): Promise<MissionTask | undefined> {
const rows = await db
.select()
.from(missionTasks)
.where(and(eq(missionTasks.id, id), eq(missionTasks.userId, userId)));
return rows[0];
},
async create(data: NewMissionTask): Promise<MissionTask> {
const rows = await db.insert(missionTasks).values(stripStatus(data)).returning();
return rows[0]!;
},
async update(id: string, data: Partial<NewMissionTask>): Promise<MissionTask | undefined> {
const rows = await db
.update(missionTasks)
.set({ ...stripStatus(data), updatedAt: new Date() })
.where(eq(missionTasks.id, id))
.returning();
return rows[0];
},
async remove(id: string): Promise<boolean> {
const rows = await db.delete(missionTasks).where(eq(missionTasks.id, id)).returning();
return rows.length > 0;
},
async removeByMission(missionId: string): Promise<number> {
const rows = await db
.delete(missionTasks)
.where(eq(missionTasks.missionId, missionId))
.returning();
return rows.length;
},
};
}
export type MissionTasksRepo = ReturnType<typeof createMissionTasksRepo>;
+61
View File
@@ -0,0 +1,61 @@
import { eq, and, type Db, missions } from '@mosaicstack/db';
export type Mission = typeof missions.$inferSelect;
export type NewMission = typeof missions.$inferInsert;
export function createMissionsRepo(db: Db) {
return {
async findAll(): Promise<Mission[]> {
return db.select().from(missions);
},
async findAllByUser(userId: string): Promise<Mission[]> {
return db.select().from(missions).where(eq(missions.userId, userId));
},
async findById(id: string): Promise<Mission | undefined> {
const rows = await db.select().from(missions).where(eq(missions.id, id));
return rows[0];
},
async findByIdAndUser(id: string, userId: string): Promise<Mission | undefined> {
const rows = await db
.select()
.from(missions)
.where(and(eq(missions.id, id), eq(missions.userId, userId)));
return rows[0];
},
async findByProject(projectId: string): Promise<Mission[]> {
return db.select().from(missions).where(eq(missions.projectId, projectId));
},
async findByProjectAndUser(projectId: string, userId: string): Promise<Mission[]> {
return db
.select()
.from(missions)
.where(and(eq(missions.projectId, projectId), eq(missions.userId, userId)));
},
async create(data: NewMission): Promise<Mission> {
const rows = await db.insert(missions).values(data).returning();
return rows[0]!;
},
async update(id: string, data: Partial<NewMission>): Promise<Mission | undefined> {
const rows = await db
.update(missions)
.set({ ...data, updatedAt: new Date() })
.where(eq(missions.id, id))
.returning();
return rows[0];
},
async remove(id: string): Promise<boolean> {
const rows = await db.delete(missions).where(eq(missions.id, id)).returning();
return rows.length > 0;
},
};
}
export type MissionsRepo = ReturnType<typeof createMissionsRepo>;
+82
View File
@@ -0,0 +1,82 @@
import { describe, it, expect, vi } from 'vitest';
import { createProjectsRepo } from './projects.js';
/**
* Build a minimal Drizzle mock. Each call to db.select() returns a fresh
* chain that resolves `where()` to the provided rows for that call.
*
* `calls` is an ordered list: the first item is returned for the first
* db.select() call, the second for the second, and so on.
*/
function makeDb(calls: unknown[][]) {
let callIndex = 0;
const selectSpy = vi.fn(() => {
const rows = calls[callIndex++] ?? [];
const chain = {
where: vi.fn().mockResolvedValue(rows),
} as { where: ReturnType<typeof vi.fn>; from?: ReturnType<typeof vi.fn> };
// from() returns the chain so .where() can be chained, but also resolves
// directly (as a thenable) for queries with no .where() call.
chain.from = vi.fn(() => Object.assign(Promise.resolve(rows), chain));
return chain;
});
return { select: selectSpy };
}
describe('createProjectsRepo — findAllForUser', () => {
it('filters by userId when user has no team memberships', async () => {
// First select: teamMembers query → empty
// Second select: projects query → one owned project
const db = makeDb([
[], // teamMembers rows
[{ id: 'p1', ownerId: 'user-1', teamId: null, ownerType: 'user' }],
]);
const repo = createProjectsRepo(db as never);
const result = await repo.findAllForUser('user-1');
expect(db.select).toHaveBeenCalledTimes(2);
expect(result).toHaveLength(1);
expect(result[0]?.id).toBe('p1');
});
it('includes team projects when user is a team member', async () => {
// First select: teamMembers → user belongs to one team
// Second select: projects query → two projects (own + team)
const db = makeDb([
[{ teamId: 'team-1' }],
[
{ id: 'p1', ownerId: 'user-1', teamId: null, ownerType: 'user' },
{ id: 'p2', ownerId: null, teamId: 'team-1', ownerType: 'team' },
],
]);
const repo = createProjectsRepo(db as never);
const result = await repo.findAllForUser('user-1');
expect(db.select).toHaveBeenCalledTimes(2);
expect(result).toHaveLength(2);
});
it('returns empty array when user has no projects and no teams', async () => {
const db = makeDb([[], []]);
const repo = createProjectsRepo(db as never);
const result = await repo.findAllForUser('user-no-projects');
expect(result).toHaveLength(0);
});
});
describe('createProjectsRepo — findAll', () => {
it('returns all rows without any user filter', async () => {
const rows = [
{ id: 'p1', ownerId: 'user-1', teamId: null, ownerType: 'user' },
{ id: 'p2', ownerId: 'user-2', teamId: null, ownerType: 'user' },
];
const db = makeDb([rows]);
const repo = createProjectsRepo(db as never);
const result = await repo.findAll();
expect(result).toHaveLength(2);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { eq, or, inArray, type Db, projects, teamMembers } from '@mosaicstack/db';
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;
export function createProjectsRepo(db: Db) {
return {
async findAll(): Promise<Project[]> {
return db.select().from(projects);
},
/**
* Return only the projects visible to a given user:
* projects directly owned by the user (ownerType = 'user', ownerId = userId), OR
* projects owned by a team the user belongs to (ownerType = 'team', teamId IN user's teams)
*/
async findAllForUser(userId: string): Promise<Project[]> {
// Fetch the team IDs the user is a member of.
const memberRows = await db
.select({ teamId: teamMembers.teamId })
.from(teamMembers)
.where(eq(teamMembers.userId, userId));
const teamIds = memberRows.map((r) => r.teamId);
if (teamIds.length === 0) {
// No team memberships — return only directly owned projects.
return db.select().from(projects).where(eq(projects.ownerId, userId));
}
return db
.select()
.from(projects)
.where(or(eq(projects.ownerId, userId), inArray(projects.teamId, teamIds)));
},
async findById(id: string): Promise<Project | undefined> {
const rows = await db.select().from(projects).where(eq(projects.id, id));
return rows[0];
},
async create(data: NewProject): Promise<Project> {
const rows = await db.insert(projects).values(data).returning();
return rows[0]!;
},
async update(id: string, data: Partial<NewProject>): Promise<Project | undefined> {
const rows = await db
.update(projects)
.set({ ...data, updatedAt: new Date() })
.where(eq(projects.id, id))
.returning();
return rows[0];
},
async remove(id: string): Promise<boolean> {
const rows = await db.delete(projects).where(eq(projects.id, id)).returning();
return rows.length > 0;
},
};
}
export type ProjectsRepo = ReturnType<typeof createProjectsRepo>;
+50
View File
@@ -0,0 +1,50 @@
import { eq, type Db, tasks } from '@mosaicstack/db';
export type Task = typeof tasks.$inferSelect;
export type NewTask = typeof tasks.$inferInsert;
export function createTasksRepo(db: Db) {
return {
async findAll(): Promise<Task[]> {
return db.select().from(tasks);
},
async findById(id: string): Promise<Task | undefined> {
const rows = await db.select().from(tasks).where(eq(tasks.id, id));
return rows[0];
},
async findByProject(projectId: string): Promise<Task[]> {
return db.select().from(tasks).where(eq(tasks.projectId, projectId));
},
async findByMission(missionId: string): Promise<Task[]> {
return db.select().from(tasks).where(eq(tasks.missionId, missionId));
},
async findByStatus(status: Task['status']): Promise<Task[]> {
return db.select().from(tasks).where(eq(tasks.status, status));
},
async create(data: NewTask): Promise<Task> {
const rows = await db.insert(tasks).values(data).returning();
return rows[0]!;
},
async update(id: string, data: Partial<NewTask>): Promise<Task | undefined> {
const rows = await db
.update(tasks)
.set({ ...data, updatedAt: new Date() })
.where(eq(tasks.id, id))
.returning();
return rows[0];
},
async remove(id: string): Promise<boolean> {
const rows = await db.delete(tasks).where(eq(tasks.id, id)).returning();
return rows.length > 0;
},
};
}
export type TasksRepo = ReturnType<typeof createTasksRepo>;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
+41
View File
@@ -0,0 +1,41 @@
# @mosaicstack/comms
MACP presence SDK — the **P1 (presence)** slice of RFC-001 (§4.5 liveness,
§4.2 event envelope). Minimal by design: set Matrix presence, run the
`mosaic.presence` heartbeat, and compute **deterministic** fleet liveness.
Out of P1 scope (later phases): enrollment/auto-detect, room taxonomy,
per-agent token minting, signed-authorship, federation.
## API
- `classifyLiveness(ageMs, policy)` / `computeFleetLiveness(observations, now, policy)`
— pure, deterministic online/away/offline from heartbeat age. The
authoritative liveness source (RFC-001 §4.5): native Matrix presence is _not_
relied upon.
- `HeartbeatEmitter` / `startHeartbeatLoop(...)` — build and drive the
`mosaic.presence` heartbeat (monotonic `seq`, `interval_ms`).
- `MinimalMatrixClient` — tiny C-S client: `setPresence`, `sendHeartbeat`,
`readHeartbeats`, `joinRoom`. Supports Application-Service masquerade
(`actAsUserId`) for the P1 provisioner, or a per-agent `accessToken`.
- `PresenceAgent` — high-level: join the fleet room, go present, heartbeat.
`pauseHeartbeat()` models a crash (no graceful signal).
- `FleetLivenessReader` — reads the fleet room and computes the liveness board
(`read()` / `formatBoard()`), the surface a human or watchdog reads.
## Liveness policy (RFC-001 §4.5)
```
online : age <= heartbeatIntervalMs * missTolerance
away : age < darkThresholdMs
offline: otherwise (or never-seen / non-finite age -> fail safe to offline)
```
Defaults: interval 30s, miss-tolerance 2, dark-threshold 10min
(`DEFAULT_LIVENESS_POLICY`). All runtime-tunable per RFC-002 §5.3.
## Tests
`pnpm --filter @mosaicstack/comms test` — the liveness core is written
RED-FIRST; an end-to-end proof against a real Synapse lives in
`tools/matrix-presence-harness`.
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@mosaicstack/comms",
"version": "0.0.1",
"type": "module",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/comms"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^2.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
"access": "public"
},
"files": [
"dist"
]
}
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HeartbeatEmitter, startHeartbeatLoop } from '../heartbeat.js';
import type { PresenceHeartbeatContent } from '../types.js';
const agent = { mxid: '@agent-alpha:matrix.localhost', slug: 'alpha', harness: 'claude-code' };
describe('HeartbeatEmitter', () => {
it('increments seq starting at 1 and stamps the envelope', () => {
let t = 1000;
const em = new HeartbeatEmitter({ agent, intervalMs: 5000, now: () => t });
const a = em.next();
t = 6000;
const b = em.next('away');
expect(a.seq).toBe(1);
expect(a.ts).toBe(1000);
expect(a.status).toBe('online');
expect(a.macp_type).toBe('presence');
expect(a.msgtype).toBe('mosaic.presence');
expect(a.macp_version).toBe('1.0');
expect(a.interval_ms).toBe(5000);
expect(a.agent).toEqual(agent);
expect(a.body).toContain('alpha');
expect(b.seq).toBe(2);
expect(b.ts).toBe(6000);
expect(b.status).toBe('away');
expect(em.currentSeq).toBe(2);
});
it('includes mission_id only when provided', () => {
const withMission = new HeartbeatEmitter({
agent,
intervalMs: 1000,
missionId: 'KBN-101',
}).next();
const without = new HeartbeatEmitter({ agent, intervalMs: 1000 }).next();
expect(withMission.mission_id).toBe('KBN-101');
expect(without.mission_id).toBeUndefined();
});
});
describe('startHeartbeatLoop', () => {
afterEach(() => vi.useRealTimers());
it('emits immediately, then once per interval, until stopped', () => {
vi.useFakeTimers();
const sent: PresenceHeartbeatContent[] = [];
const em = new HeartbeatEmitter({ agent, intervalMs: 1000, now: () => Date.now() });
const loop = startHeartbeatLoop({
emitter: em,
intervalMs: 1000,
send: (c) => {
sent.push(c);
},
});
expect(sent).toHaveLength(1); // immediate beat
vi.advanceTimersByTime(3000);
expect(sent).toHaveLength(4); // +3 beats
expect(sent.map((s) => s.seq)).toEqual([1, 2, 3, 4]);
loop.stop();
vi.advanceTimersByTime(5000);
expect(sent).toHaveLength(4); // no more after stop
loop.stop(); // idempotent
});
it('routes a rejected async send to onError without killing the loop', async () => {
vi.useFakeTimers();
const onError = vi.fn();
let n = 0;
const em = new HeartbeatEmitter({ agent, intervalMs: 1000 });
const loop = startHeartbeatLoop({
emitter: em,
intervalMs: 1000,
onError,
send: () => {
n += 1;
return Promise.reject(new Error(`boom ${n}`));
},
});
await vi.advanceTimersByTimeAsync(2000); // immediate + 2
expect(n).toBe(3);
expect(onError).toHaveBeenCalledTimes(3);
loop.stop();
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { classifyLiveness, computeFleetLiveness } from '../liveness.js';
import type { HeartbeatObservation, LivenessPolicy } from '../types.js';
// Small, dev-scale policy so the arithmetic is obvious:
// online window = interval * missTolerance = 1000 * 2 = 2000ms
// dark threshold = 5000ms
const policy: LivenessPolicy = {
heartbeatIntervalMs: 1000,
missTolerance: 2,
darkThresholdMs: 5000,
};
describe('classifyLiveness (deterministic, heartbeat-age based — RFC-001 §4.5)', () => {
it('is online when age is within interval * missTolerance', () => {
expect(classifyLiveness(0, policy)).toBe('online');
expect(classifyLiveness(1999, policy)).toBe('online');
expect(classifyLiveness(2000, policy)).toBe('online'); // inclusive boundary
});
it('is away when past the online window but before dark threshold', () => {
expect(classifyLiveness(2001, policy)).toBe('away');
expect(classifyLiveness(4999, policy)).toBe('away');
});
it('is offline/dark at or past the dark threshold', () => {
expect(classifyLiveness(5000, policy)).toBe('offline');
expect(classifyLiveness(50_000, policy)).toBe('offline');
});
it('treats a never-seen agent (Infinity age) as offline', () => {
expect(classifyLiveness(Number.POSITIVE_INFINITY, policy)).toBe('offline');
});
it('never returns online for a negative-but-huge misconfig (guards NaN)', () => {
// A NaN age must fail safe to offline, not silently report online.
expect(classifyLiveness(Number.NaN, policy)).toBe('offline');
});
});
describe('computeFleetLiveness (A2/A3 core)', () => {
const now = 100_000;
const obs = (slug: string, lastSeenTs: number, lastSeq = 1): HeartbeatObservation => ({
slug,
mxid: `@agent-${slug}:matrix.localhost`,
lastSeenTs,
lastSeq,
assertedStatus: 'online',
});
it('classifies a live fleet: fresh=online, stale=away, dark=offline', () => {
const result = computeFleetLiveness(
[
obs('alpha', now - 500), // 500ms old -> online
obs('bravo', now - 3000), // 3000ms old -> away
obs('charlie', now - 8000), // 8000ms old -> offline
],
now,
policy,
);
const byslug = Object.fromEntries(result.map((r) => [r.slug, r.status]));
expect(byslug).toEqual({ alpha: 'online', bravo: 'away', charlie: 'offline' });
});
it('A3: a previously-online agent flips to offline once age crosses dark threshold', () => {
const lastBeat = 100_000; // agent was hard-killed right after this beat
// Just before the threshold it is still merely "away"...
const justBefore = computeFleetLiveness([obs('victim', lastBeat, 7)], lastBeat + 4999, policy);
expect(justBefore[0]?.status).toBe('away');
// ...and the instant age reaches darkThresholdMs it is deterministically offline,
// with no dependence on native Matrix presence timeouts.
const atThreshold = computeFleetLiveness([obs('victim', lastBeat, 7)], lastBeat + 5000, policy);
expect(atThreshold[0]?.status).toBe('offline');
expect(atThreshold[0]?.ageMs).toBe(5000);
expect(atThreshold[0]?.lastSeq).toBe(7);
});
it('reports ageMs and preserves mxid/slug/seq for the human view', () => {
const [row] = computeFleetLiveness([obs('alpha', now - 1200, 42)], now, policy);
expect(row).toMatchObject({
slug: 'alpha',
mxid: '@agent-alpha:matrix.localhost',
ageMs: 1200,
lastSeq: 42,
status: 'online',
});
});
});
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from 'vitest';
import { MatrixError, MinimalMatrixClient, toMatrixPresence } from '../matrix-client.js';
const jsonResponse = (status: number, body: unknown): Response =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
// A fetch mock typed with the (URL, RequestInit?) shape the client actually
// calls, so mock.calls has a proper tuple type under noUncheckedIndexedAccess.
const mkFetch = (impl: (url: URL, init?: RequestInit) => Promise<Response>) => vi.fn(impl);
const cfg = {
homeserverUrl: 'https://matrix.localhost:8448',
accessToken: 'as-secret',
actAsUserId: '@agent-alpha:matrix.localhost',
};
describe('toMatrixPresence', () => {
it('maps liveness states to native presence EDU values', () => {
expect(toMatrixPresence('online')).toBe('online');
expect(toMatrixPresence('away')).toBe('unavailable');
expect(toMatrixPresence('offline')).toBe('offline');
});
});
describe('MinimalMatrixClient', () => {
it('setPresence PUTs native presence and masquerades via user_id', async () => {
const fetchMock = mkFetch(async () => jsonResponse(200, {}));
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
await client.setPresence('@agent-alpha:matrix.localhost', 'away', 'hb');
const [url, init] = fetchMock.mock.calls[0]!;
const u = new URL((url as URL).toString());
expect(u.pathname).toBe('/_matrix/client/v3/presence/%40agent-alpha%3Amatrix.localhost/status');
expect(u.searchParams.get('user_id')).toBe('@agent-alpha:matrix.localhost');
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
presence: 'unavailable',
status_msg: 'hb',
});
expect((init as RequestInit).method).toBe('PUT');
});
it('sendHeartbeat posts an m.room.message and returns the event_id', async () => {
const fetchMock = mkFetch(async () => jsonResponse(200, { event_id: '$evt1' }));
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
const id = await client.sendHeartbeat('!room:matrix.localhost', {
macp_version: '1.0',
macp_type: 'presence',
msgtype: 'mosaic.presence',
agent: { mxid: cfg.actAsUserId, slug: 'alpha', harness: 'claude-code' },
ts: 1,
body: 'alpha online (seq 1)',
status: 'online',
seq: 1,
interval_ms: 1000,
});
expect(id).toBe('$evt1');
const [url] = fetchMock.mock.calls[0]!;
expect((url as URL).pathname).toContain('/rooms/!room%3Amatrix.localhost/send/m.room.message/');
});
it('throws a MatrixError carrying errcode on a non-2xx', async () => {
const fetchMock = mkFetch(async () =>
jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'nope' }),
);
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
await expect(client.whoami()).rejects.toMatchObject({
name: 'MatrixError',
status: 403,
errcode: 'M_FORBIDDEN',
});
await expect(client.whoami()).rejects.toBeInstanceOf(MatrixError);
});
it('readHeartbeats reduces the timeline to the latest beat per agent', async () => {
// Timeline (dir=b => most-recent first). alpha has two beats; keep highest seq.
const chunk = [
{
sender: '@agent-bravo:matrix.localhost',
origin_server_ts: 9000,
content: {
msgtype: 'mosaic.presence',
agent: { slug: 'bravo', mxid: '@agent-bravo:matrix.localhost' },
seq: 5,
status: 'online',
ts: 8999,
},
},
{
sender: '@agent-alpha:matrix.localhost',
origin_server_ts: 8000,
content: {
msgtype: 'mosaic.presence',
agent: { slug: 'alpha', mxid: '@agent-alpha:matrix.localhost' },
seq: 12,
status: 'online',
ts: 7999,
},
},
{
// an ordinary chat message must be ignored
sender: '@human:matrix.localhost',
origin_server_ts: 7000,
content: { msgtype: 'm.text', body: 'hi' },
},
{
sender: '@agent-alpha:matrix.localhost',
origin_server_ts: 6000,
content: {
msgtype: 'mosaic.presence',
agent: { slug: 'alpha', mxid: '@agent-alpha:matrix.localhost' },
seq: 11,
status: 'online',
ts: 5999,
},
},
];
const fetchMock = mkFetch(async () => jsonResponse(200, { chunk }));
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
const obs = await client.readHeartbeats('!room:matrix.localhost');
const bySlug = Object.fromEntries(obs.map((o) => [o.slug, o]));
expect(Object.keys(bySlug).sort()).toEqual(['alpha', 'bravo']);
expect(bySlug.alpha).toMatchObject({ lastSeq: 12, lastSeenTs: 8000 }); // highest seq wins, server ts
expect(bySlug.bravo).toMatchObject({ lastSeq: 5, lastSeenTs: 9000 });
const [url] = fetchMock.mock.calls[0]!;
const u = new URL((url as URL).toString());
expect(u.searchParams.get('dir')).toBe('b');
});
});
@@ -0,0 +1,151 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FleetLivenessReader } from '../liveness-reader.js';
import type { MinimalMatrixClient } from '../matrix-client.js';
import { PresenceAgent } from '../presence-agent.js';
import type { HeartbeatObservation, LivenessPolicy, PresenceStatus } from '../types.js';
/**
* An in-memory fake homeserver room: records heartbeats with a controllable
* server clock and reduces them exactly like the real readHeartbeats. Lets us
* prove the PresenceAgent -> room -> FleetLivenessReader flow (including the A3
* hard-kill -> offline transition) deterministically, with no network.
*/
class FakeRoomClient {
readonly beats: Array<{
slug: string;
mxid: string;
seq: number;
ts: number;
status: PresenceStatus;
}> = [];
presence: Record<string, PresenceStatus> = {};
constructor(private readonly clock: () => number) {}
async joinRoom(roomId: string): Promise<string> {
return roomId;
}
async setPresence(userId: string, status: PresenceStatus): Promise<void> {
this.presence[userId] = status;
}
async sendHeartbeat(
_roomId: string,
content: { agent: { slug: string; mxid: string }; seq: number; status: PresenceStatus },
): Promise<string> {
this.beats.push({
slug: content.agent.slug,
mxid: content.agent.mxid,
seq: content.seq,
ts: this.clock(), // server receive time
status: content.status,
});
return `$evt${this.beats.length}`;
}
async readHeartbeats(): Promise<HeartbeatObservation[]> {
const bySlug = new Map<string, HeartbeatObservation>();
for (const b of this.beats) {
const prev = bySlug.get(b.slug);
if (!prev || b.seq > prev.lastSeq) {
bySlug.set(b.slug, {
slug: b.slug,
mxid: b.mxid,
lastSeenTs: b.ts,
lastSeq: b.seq,
assertedStatus: b.status,
});
}
}
return [...bySlug.values()];
}
}
const policy: LivenessPolicy = {
heartbeatIntervalMs: 1000,
missTolerance: 2,
darkThresholdMs: 5000,
};
describe('presence flow (A2 + A3 at unit level)', () => {
afterEach(() => vi.useRealTimers());
it('shows agents online while beating, then A3: a hard-killed agent goes offline within dark_threshold', async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const fake = new FakeRoomClient(() => Date.now());
const client = fake as unknown as MinimalMatrixClient;
const reader = new FleetLivenessReader({
client,
roomId: '!fleet',
policy,
now: () => Date.now(),
});
const mk = (slug: string) =>
new PresenceAgent({
client,
agent: { mxid: `@agent-${slug}:matrix.localhost`, slug, harness: 'claude-code' },
roomId: '!fleet',
intervalMs: 1000,
policy,
});
const alpha = mk('alpha');
const bravo = mk('bravo');
const charlie = mk('charlie');
for (const a of [alpha, bravo, charlie]) {
await a.connect();
a.start();
}
// native presence set online for all three (Element dot)
expect(fake.presence['@agent-alpha:matrix.localhost']).toBe('online');
// let a couple of beats flow — all three fresh => online (A2)
await vi.advanceTimersByTimeAsync(1500);
const board1 = Object.fromEntries((await reader.read()).map((r) => [r.slug, r.status]));
expect(board1).toEqual({ alpha: 'online', bravo: 'online', charlie: 'online' });
// HARD-KILL charlie: stop its loop, no more beats. alpha/bravo keep beating.
charlie.pauseHeartbeat(); // hard-kill: no graceful presence signal
// advance to just before dark threshold from charlie's last beat...
await vi.advanceTimersByTimeAsync(3000);
const mid = Object.fromEntries((await reader.read()).map((r) => [r.slug, r.status]));
expect(mid.alpha).toBe('online');
expect(mid.charlie).not.toBe('online'); // already stale (away)
// ...advance past dark_threshold: charlie is deterministically offline.
await vi.advanceTimersByTimeAsync(4000);
const final = await reader.read();
const byslug = Object.fromEntries(final.map((r) => [r.slug, r]));
expect(byslug.charlie!.status).toBe('offline');
expect(byslug.alpha!.status).toBe('online');
expect(byslug.bravo!.status).toBe('online');
for (const a of [alpha, bravo]) await a.stop();
});
it('formatBoard renders a human-readable liveness board (A4)', async () => {
const fake = new FakeRoomClient(() => 10_000);
fake.beats.push({
slug: 'alpha',
mxid: '@agent-alpha:matrix.localhost',
seq: 3,
ts: 9_500,
status: 'online',
});
const reader = new FleetLivenessReader({
client: fake as unknown as MinimalMatrixClient,
roomId: '!fleet',
policy,
now: () => 10_000,
});
const board = await reader.formatBoard();
expect(board).toContain('Fleet presence');
expect(board).toContain('alpha');
expect(board).toContain('online');
expect(board).toContain('online=1');
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* `mosaic.presence` heartbeat construction and loop (RFC-001 §4.2/§4.5).
*
* The emitter is deterministic and side-effect free (easy to unit test): it
* owns the monotonic `seq` and stamps each beat. The loop wires the emitter to
* a sender on an interval; timers are injectable so the loop is testable with
* fake clocks.
*/
import { MACP_VERSION, type PresenceHeartbeatContent, type PresenceStatus } from './types.js';
export interface HeartbeatAgentIdentity {
mxid: string;
slug: string;
harness: string;
}
export interface HeartbeatEmitterOptions {
agent: HeartbeatAgentIdentity;
/** Nominal interval advertised in each beat (interval_ms). */
intervalMs: number;
/** Optional mission correlation (RFC-001 §4.2 envelope). */
missionId?: string;
/** Injectable clock for deterministic tests. Default Date.now. */
now?: () => number;
}
/**
* Produces successive heartbeat contents with a monotonically increasing seq.
* The first `next()` returns seq=1.
*/
export class HeartbeatEmitter {
private seq = 0;
private readonly now: () => number;
constructor(private readonly opts: HeartbeatEmitterOptions) {
this.now = opts.now ?? Date.now;
}
/** Current sequence number (0 before the first beat). */
get currentSeq(): number {
return this.seq;
}
/** Build the next heartbeat content, advancing the sequence. */
next(status: PresenceStatus = 'online'): PresenceHeartbeatContent {
this.seq += 1;
const ts = this.now();
const content: PresenceHeartbeatContent = {
macp_version: MACP_VERSION,
macp_type: 'presence',
msgtype: 'mosaic.presence',
agent: {
mxid: this.opts.agent.mxid,
slug: this.opts.agent.slug,
harness: this.opts.agent.harness,
},
ts,
body: `${this.opts.agent.slug} ${status} (seq ${this.seq})`,
status,
seq: this.seq,
interval_ms: this.opts.intervalMs,
};
if (this.opts.missionId !== undefined) {
content.mission_id = this.opts.missionId;
}
return content;
}
}
export type HeartbeatSender = (content: PresenceHeartbeatContent) => void | Promise<void>;
export interface HeartbeatLoopOptions {
emitter: HeartbeatEmitter;
send: HeartbeatSender;
intervalMs: number;
/** Status supplier evaluated each beat. Default: always 'online'. */
status?: () => PresenceStatus;
/** Called if a beat's send rejects (so a transient failure doesn't kill the loop). */
onError?: (err: unknown) => void;
/** Injectable timer (tests). Defaults to global setInterval/clearInterval. */
setIntervalFn?: (cb: () => void, ms: number) => unknown;
clearIntervalFn?: (handle: unknown) => void;
}
/** A running heartbeat loop; call stop() to end it. */
export interface HeartbeatLoopHandle {
stop: () => void;
}
/**
* Start a heartbeat loop: emits one beat immediately, then every intervalMs.
* Returns a handle whose `stop()` is idempotent.
*/
export function startHeartbeatLoop(opts: HeartbeatLoopOptions): HeartbeatLoopHandle {
const status = opts.status ?? (() => 'online' as PresenceStatus);
const onError = opts.onError ?? (() => {});
const setIntervalFn = opts.setIntervalFn ?? ((cb, ms) => setInterval(cb, ms));
const clearIntervalFn =
opts.clearIntervalFn ?? ((h) => clearInterval(h as ReturnType<typeof setInterval>));
const beat = (): void => {
try {
const result = opts.send(opts.emitter.next(status()));
if (result instanceof Promise) {
result.catch(onError);
}
} catch (err) {
onError(err);
}
};
beat(); // immediate first beat so liveness is fresh at once
const handle = setIntervalFn(beat, opts.intervalMs);
let stopped = false;
return {
stop: () => {
if (stopped) return;
stopped = true;
clearIntervalFn(handle);
},
};
}
+42
View File
@@ -0,0 +1,42 @@
/**
* @mosaicstack/comms — MACP presence SDK (RFC-001 P1).
*
* Minimal, dev-validated slice: set Matrix presence, run the `mosaic.presence`
* heartbeat, and compute deterministic fleet liveness. Enrollment, room
* taxonomy, token minting and signed-authorship are explicitly out of P1.
*/
export { classifyLiveness, computeFleetLiveness } from './liveness.js';
export {
HeartbeatEmitter,
startHeartbeatLoop,
type HeartbeatAgentIdentity,
type HeartbeatEmitterOptions,
type HeartbeatSender,
type HeartbeatLoopOptions,
type HeartbeatLoopHandle,
} from './heartbeat.js';
export {
MinimalMatrixClient,
MatrixError,
toMatrixPresence,
type MatrixClientConfig,
} from './matrix-client.js';
export { FleetLivenessReader, type FleetLivenessReaderOptions } from './liveness-reader.js';
export { PresenceAgent, type PresenceAgentOptions } from './presence-agent.js';
export {
DEFAULT_LIVENESS_POLICY,
MACP_VERSION,
type AgentLiveness,
type HeartbeatObservation,
type LivenessPolicy,
type MacpEnvelope,
type MatrixPresence,
type PresenceHeartbeatContent,
type PresenceStatus,
} from './types.js';
+58
View File
@@ -0,0 +1,58 @@
/**
* Fleet liveness reader (RFC-001 §4.5, A2/A4).
*
* Reads `mosaic.presence` heartbeats from the fleet presence room and computes
* deterministic online/away/offline for every agent. This is the surface a
* human (or the escalation watchdog, P2+) reads to answer "who's alive?".
*/
import { computeFleetLiveness } from './liveness.js';
import type { MinimalMatrixClient } from './matrix-client.js';
import { DEFAULT_LIVENESS_POLICY, type AgentLiveness, type LivenessPolicy } from './types.js';
export interface FleetLivenessReaderOptions {
client: MinimalMatrixClient;
/** The fleet presence room (id or resolved id). */
roomId: string;
policy?: LivenessPolicy;
/** Injectable clock for tests. Default Date.now. */
now?: () => number;
/** How many timeline events to scan back. Default 200. */
scanLimit?: number;
}
export class FleetLivenessReader {
private readonly policy: LivenessPolicy;
private readonly now: () => number;
constructor(private readonly opts: FleetLivenessReaderOptions) {
this.policy = opts.policy ?? DEFAULT_LIVENESS_POLICY;
this.now = opts.now ?? Date.now;
}
/** Read the room and compute current liveness for every seen agent. */
async read(): Promise<AgentLiveness[]> {
const observations = await this.opts.client.readHeartbeats(
this.opts.roomId,
this.opts.scanLimit ?? 200,
);
return computeFleetLiveness(observations, this.now(), this.policy);
}
/** A compact human-readable liveness board (A4 CLI view). */
async formatBoard(): Promise<string> {
const rows = await this.read();
rows.sort((a, b) => a.slug.localeCompare(b.slug));
const dot: Record<string, string> = { online: '🟢', away: '🟡', offline: '🔴' };
const lines = rows.map(
(r) =>
`${dot[r.status] ?? '⚪'} ${r.slug.padEnd(16)} ${r.status.padEnd(8)} ` +
`age=${(r.ageMs / 1000).toFixed(1)}s seq=${r.lastSeq} ${r.mxid}`,
);
const summary =
`online=${rows.filter((r) => r.status === 'online').length} ` +
`away=${rows.filter((r) => r.status === 'away').length} ` +
`offline=${rows.filter((r) => r.status === 'offline').length}`;
return [`Fleet presence — ${summary}`, ...lines].join('\n');
}
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Deterministic liveness computation (RFC-001 §4.5).
*
* The authoritative liveness signal is the `mosaic.presence` heartbeat, NOT
* native Matrix presence. Given the age of an agent's last heartbeat and a
* policy, these pure functions classify online/away/offline the same way every
* time — which is exactly what makes the A3 "hard-killed agent flips to
* offline within dark_threshold" guarantee deterministic and testable without
* standing up a homeserver.
*/
import type {
AgentLiveness,
HeartbeatObservation,
LivenessPolicy,
PresenceStatus,
} from './types.js';
/**
* Classify a single agent from the age (ms) of its last heartbeat.
*
* - `age <= heartbeatIntervalMs * missTolerance` → **online**
* - `age < darkThresholdMs` → **away**
* - otherwise (or non-finite age) → **offline / dark**
*
* A non-finite age (never seen / NaN) fails safe to `offline`: we never assert
* a liveness we cannot substantiate.
*/
export function classifyLiveness(ageMs: number, policy: LivenessPolicy): PresenceStatus {
if (!Number.isFinite(ageMs)) {
return 'offline';
}
const onlineWindowMs = policy.heartbeatIntervalMs * policy.missTolerance;
if (ageMs <= onlineWindowMs) {
return 'online';
}
if (ageMs < policy.darkThresholdMs) {
return 'away';
}
return 'offline';
}
/**
* Compute liveness for every observed agent at wall-clock `nowMs`.
* The result order mirrors the input order (stable for display).
*/
export function computeFleetLiveness(
observations: readonly HeartbeatObservation[],
nowMs: number,
policy: LivenessPolicy,
): AgentLiveness[] {
return observations.map((o) => {
const ageMs = nowMs - o.lastSeenTs;
return {
slug: o.slug,
mxid: o.mxid,
status: classifyLiveness(ageMs, policy),
lastSeenTs: o.lastSeenTs,
ageMs,
lastSeq: o.lastSeq,
};
});
}
+204
View File
@@ -0,0 +1,204 @@
/**
* Minimal Matrix Client-Server API client for the P1 presence slice.
*
* Deliberately tiny: only the calls presence needs (whoami, set native
* presence, send a timeline event, read recent timeline). Auth is a single
* bearer token; an optional `actAsUserId` enables Application-Service
* masquerade (`?user_id=`) so the P1 provisioner can drive several virtual
* agents with one as_token in dev (RFC-001 §2.2 step 4/Appendix A). Agents
* holding their own access_token simply omit `actAsUserId`.
*
* `fetch` is injectable for unit tests.
*/
import crypto from 'node:crypto';
import type {
HeartbeatObservation,
MatrixPresence,
PresenceHeartbeatContent,
PresenceStatus,
} from './types.js';
export interface MatrixClientConfig {
/** Client-Server API base, e.g. https://matrix.localhost:8448 */
homeserverUrl: string;
/** Bearer token (a per-agent access_token, or an as_token for masquerade). */
accessToken: string;
/** If set, all calls masquerade as this MXID via ?user_id= (AS mode). */
actAsUserId?: string;
}
export class MatrixError extends Error {
constructor(
readonly status: number,
readonly errcode: string | undefined,
message: string,
) {
super(message);
this.name = 'MatrixError';
}
}
type FetchLike = typeof fetch;
/** Map our authoritative liveness state to the native Matrix presence EDU. */
export function toMatrixPresence(status: PresenceStatus): MatrixPresence {
switch (status) {
case 'online':
return 'online';
case 'away':
return 'unavailable';
case 'offline':
return 'offline';
}
}
export class MinimalMatrixClient {
private readonly fetchImpl: FetchLike;
constructor(
private readonly cfg: MatrixClientConfig,
fetchImpl?: FetchLike,
) {
this.fetchImpl = fetchImpl ?? fetch;
}
private async request(
method: string,
path: string,
options: { query?: Record<string, string>; body?: unknown } = {},
): Promise<Record<string, unknown>> {
const url = new URL(this.cfg.homeserverUrl.replace(/\/$/, '') + path);
if (this.cfg.actAsUserId) {
url.searchParams.set('user_id', this.cfg.actAsUserId);
}
for (const [k, v] of Object.entries(options.query ?? {})) {
url.searchParams.set(k, v);
}
const res = await this.fetchImpl(url, {
method,
headers: {
Authorization: `Bearer ${this.cfg.accessToken}`,
'Content-Type': 'application/json',
},
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
const text = await res.text();
const data = (text ? JSON.parse(text) : {}) as Record<string, unknown>;
if (!res.ok) {
throw new MatrixError(
res.status,
typeof data.errcode === 'string' ? data.errcode : undefined,
`${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`,
);
}
return data;
}
/** GET /account/whoami — resolves the acting MXID. */
async whoami(): Promise<string> {
const data = await this.request('GET', '/_matrix/client/v3/account/whoami');
if (typeof data.user_id !== 'string') {
throw new MatrixError(500, undefined, 'whoami returned no user_id');
}
return data.user_id;
}
/**
* Set the native Matrix presence EDU (so Element shows the right dot for
* humans). NOT the authoritative liveness signal — the heartbeat is.
*/
async setPresence(userId: string, status: PresenceStatus, statusMsg?: string): Promise<void> {
const user = encodeURIComponent(userId);
await this.request('PUT', `/_matrix/client/v3/presence/${user}/status`, {
body: {
presence: toMatrixPresence(status),
...(statusMsg ? { status_msg: statusMsg } : {}),
},
});
}
/** Send an arbitrary timeline event; returns its event_id. */
async sendEvent(
roomId: string,
eventType: string,
content: Record<string, unknown>,
): Promise<string> {
const room = encodeURIComponent(roomId);
const txn = `mosaic-comms-${crypto.randomUUID()}`;
const data = await this.request(
'PUT',
`/_matrix/client/v3/rooms/${room}/send/${encodeURIComponent(eventType)}/${txn}`,
{ body: content },
);
if (typeof data.event_id !== 'string') {
throw new MatrixError(500, undefined, 'send returned no event_id');
}
return data.event_id;
}
/** Post a `mosaic.presence` heartbeat (m.room.message carrier) to the room. */
async sendHeartbeat(roomId: string, content: PresenceHeartbeatContent): Promise<string> {
return this.sendEvent(roomId, 'm.room.message', content as unknown as Record<string, unknown>);
}
/** Join a room (by id or alias). Idempotent on the server. */
async joinRoom(roomIdOrAlias: string): Promise<string> {
const data = await this.request(
'POST',
`/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}`,
{ body: {} },
);
if (typeof data.room_id !== 'string') {
throw new MatrixError(500, undefined, 'join returned no room_id');
}
return data.room_id;
}
/**
* Read recent `mosaic.presence` heartbeats from a room and reduce them to the
* latest observation per agent. Walks the timeline backwards (most-recent
* first) and keeps, per slug, the beat with the highest seq.
*
* `lastSeenTs` uses the server's `origin_server_ts` (honest "when we last
* heard from it"), falling back to the agent-stamped envelope `ts`.
*/
async readHeartbeats(roomId: string, limit = 200): Promise<HeartbeatObservation[]> {
const room = encodeURIComponent(roomId);
const data = await this.request('GET', `/_matrix/client/v3/rooms/${room}/messages`, {
query: { dir: 'b', limit: String(limit) },
});
const chunk = Array.isArray(data.chunk) ? (data.chunk as Array<Record<string, unknown>>) : [];
const bySlug = new Map<string, HeartbeatObservation>();
for (const ev of chunk) {
const content = ev.content as Record<string, unknown> | undefined;
if (!content || content.msgtype !== 'mosaic.presence') continue;
const agent = content.agent as Record<string, unknown> | undefined;
const slug = agent && typeof agent.slug === 'string' ? agent.slug : undefined;
const mxid =
agent && typeof agent.mxid === 'string'
? agent.mxid
: typeof ev.sender === 'string'
? ev.sender
: undefined;
if (!slug || !mxid) continue;
const seq = typeof content.seq === 'number' ? content.seq : 0;
const serverTs = typeof ev.origin_server_ts === 'number' ? ev.origin_server_ts : undefined;
const envelopeTs = typeof content.ts === 'number' ? content.ts : undefined;
const lastSeenTs = serverTs ?? envelopeTs ?? 0;
const assertedStatus =
content.status === 'online' || content.status === 'away' || content.status === 'offline'
? (content.status as PresenceStatus)
: 'offline';
const prev = bySlug.get(slug);
if (!prev || seq > prev.lastSeq) {
bySlug.set(slug, { slug, mxid, lastSeenTs, lastSeq: seq, assertedStatus });
}
}
return [...bySlug.values()];
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* High-level presence agent (RFC-001 §4.1 steps 1011, §4.5).
*
* Ties the pieces together for one agent: join the fleet presence room, set
* native Matrix presence online (for Element's dot), and run the authoritative
* `mosaic.presence` heartbeat loop. This is the P1 slice of what a harness does
* on spin — no enrollment/token-minting/introductions (those are P2).
*/
import {
HeartbeatEmitter,
startHeartbeatLoop,
type HeartbeatAgentIdentity,
type HeartbeatLoopHandle,
} from './heartbeat.js';
import type { MinimalMatrixClient } from './matrix-client.js';
import { DEFAULT_LIVENESS_POLICY, type LivenessPolicy, type PresenceStatus } from './types.js';
export interface PresenceAgentOptions {
client: MinimalMatrixClient;
agent: HeartbeatAgentIdentity;
/** Fleet presence room id (or alias) to heartbeat into. */
roomId: string;
/** Heartbeat cadence; defaults to the policy interval. */
intervalMs?: number;
policy?: LivenessPolicy;
missionId?: string;
onError?: (err: unknown) => void;
}
export class PresenceAgent {
private readonly intervalMs: number;
private readonly emitter: HeartbeatEmitter;
private loop: HeartbeatLoopHandle | undefined;
private resolvedRoomId: string | undefined;
constructor(private readonly opts: PresenceAgentOptions) {
const policy = opts.policy ?? DEFAULT_LIVENESS_POLICY;
this.intervalMs = opts.intervalMs ?? policy.heartbeatIntervalMs;
this.emitter = new HeartbeatEmitter({
agent: opts.agent,
intervalMs: this.intervalMs,
missionId: opts.missionId,
});
}
/** Join the fleet room and go present. Returns the resolved room id. */
async connect(): Promise<string> {
this.resolvedRoomId = await this.opts.client.joinRoom(this.opts.roomId);
await this.opts.client.setPresence(this.opts.agent.mxid, 'online', 'mosaic.presence heartbeat');
return this.resolvedRoomId;
}
/** Start the heartbeat loop (emits immediately, then every intervalMs). */
start(status: () => PresenceStatus = () => 'online'): void {
const roomId = this.resolvedRoomId ?? this.opts.roomId;
this.loop = startHeartbeatLoop({
emitter: this.emitter,
intervalMs: this.intervalMs,
status,
onError: this.opts.onError,
send: async (content) => {
await this.opts.client.sendHeartbeat(roomId, content);
},
});
}
get currentSeq(): number {
return this.emitter.currentSeq;
}
/**
* Stop only the heartbeat loop, sending NO graceful signal. This models a
* hard crash/kill: the authoritative liveness path must detect it purely from
* the absence of heartbeats (RFC-001 §4.5, A3), not from any native presence
* change. Idempotent.
*/
pauseHeartbeat(): void {
this.loop?.stop();
this.loop = undefined;
}
/** Graceful stop: stop heartbeating and drop native presence to offline. */
async stop(): Promise<void> {
this.pauseHeartbeat();
try {
await this.opts.client.setPresence(this.opts.agent.mxid, 'offline');
} catch (err) {
this.opts.onError?.(err);
}
}
}
+99
View File
@@ -0,0 +1,99 @@
/**
* @mosaicstack/comms — MACP P1 (presence) types.
*
* Implements the presence/liveness slice of RFC-001 §4.5 and the MACP event
* envelope of RFC-001 §4.2. P1 scope only: presence heartbeat + deterministic
* liveness. No enrollment, room-taxonomy, token-minting or signed-authorship
* (those are P2+).
*/
/** The three human-visible liveness states (RFC-001 §4.5). */
export type PresenceStatus = 'online' | 'away' | 'offline';
/**
* Native Matrix presence EDU states. We still emit these (so Element shows the
* right dot for humans, RFC-001 §4.5) but they are NOT the authoritative
* liveness source — the heartbeat is.
*/
export type MatrixPresence = 'online' | 'unavailable' | 'offline';
/**
* Common MACP event envelope carried in `content` on every custom event
* (RFC-001 §4.2). P1 uses only the fields the presence heartbeat needs; the
* `signature` field (gate actions, §4.4) is intentionally absent in P1.
*/
export interface MacpEnvelope {
macp_version: string;
macp_type: string;
agent: {
mxid: string;
slug: string;
harness: string;
};
ts: number;
mission_id?: string;
}
/**
* `mosaic.presence` heartbeat content (RFC-001 §4.2 "presence" row + §4.5).
* Carried as an `m.room.message` with `msgtype: "mosaic.presence"` and a
* human-visible `body` fallback, posted into the fleet presence room.
*/
export interface PresenceHeartbeatContent extends MacpEnvelope {
macp_type: 'presence';
msgtype: 'mosaic.presence';
/** Human-visible fallback so the event renders in a stock client. */
body: string;
/** Liveness state the agent asserts about itself. */
status: PresenceStatus;
/** Monotonic per-agent sequence number, increments once per beat. */
seq: number;
/** The agent's configured heartbeat interval, so readers can reason. */
interval_ms: number;
}
/**
* Deterministic liveness policy (RFC-001 §4.5). Defaults per §4.5/§5.3:
* interval 30s, miss-tolerance 2, dark threshold a policy value (10 min in
* prod §5; small in dev harness).
*/
export interface LivenessPolicy {
/** Nominal heartbeat interval in ms. Default 30_000. */
heartbeatIntervalMs: number;
/** How many intervals may be missed before "away". Default 2. */
missTolerance: number;
/** Age past which an agent is declared offline/dark. Default 600_000. */
darkThresholdMs: number;
}
/** A single agent's last observed heartbeat, as read from the fleet room. */
export interface HeartbeatObservation {
slug: string;
mxid: string;
/** Wall-clock ms of the last heartbeat seen for this agent. */
lastSeenTs: number;
/** Last seq observed (monotonic per agent). */
lastSeq: number;
/** The status the agent last asserted about itself. */
assertedStatus: PresenceStatus;
}
/** Computed liveness for one agent (what a human/watchdog reads). */
export interface AgentLiveness {
slug: string;
mxid: string;
/** Authoritative, heartbeat-derived status. */
status: PresenceStatus;
lastSeenTs: number;
/** now - lastSeenTs, in ms. */
ageMs: number;
lastSeq: number;
}
export const DEFAULT_LIVENESS_POLICY: LivenessPolicy = {
heartbeatIntervalMs: 30_000,
missTolerance: 2,
darkThresholdMs: 600_000,
};
export const MACP_VERSION = '1.0';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/index.ts'],
},
},
});
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@mosaicstack/config",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/config"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@mosaicstack/memory": "workspace:^",
"@mosaicstack/queue": "workspace:^",
"@mosaicstack/storage": "workspace:^"
},
"devDependencies": {
"eslint": "^9.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
"access": "public"
},
"files": [
"dist"
]
}
+9
View File
@@ -0,0 +1,9 @@
export type { MosaicConfig, StorageTier, MemoryConfigRef } from './mosaic-config.js';
export {
DEFAULT_LOCAL_CONFIG,
DEFAULT_STANDALONE_CONFIG,
DEFAULT_FEDERATED_CONFIG,
loadConfig,
validateConfig,
detectFromEnv,
} from './mosaic-config.js';
@@ -0,0 +1,170 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
validateConfig,
detectFromEnv,
DEFAULT_LOCAL_CONFIG,
DEFAULT_STANDALONE_CONFIG,
DEFAULT_FEDERATED_CONFIG,
} from './mosaic-config.js';
describe('validateConfig — tier enum', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let stderrSpy: any;
beforeEach(() => {
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
});
it('accepts tier="local"', () => {
const result = validateConfig({
tier: 'local',
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
queue: { type: 'local', dataDir: '.mosaic/queue' },
memory: { type: 'keyword' },
});
expect(result.tier).toBe('local');
});
it('accepts tier="standalone"', () => {
const result = validateConfig({
tier: 'standalone',
storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5432/mosaic' },
queue: { type: 'bullmq' },
memory: { type: 'keyword' },
});
expect(result.tier).toBe('standalone');
});
it('accepts tier="federated"', () => {
const result = validateConfig({
tier: 'federated',
storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5433/mosaic' },
queue: { type: 'bullmq' },
memory: { type: 'pgvector' },
});
expect(result.tier).toBe('federated');
});
it('accepts deprecated tier="team" as alias for "standalone" and emits a deprecation warning', () => {
const result = validateConfig({
tier: 'team',
storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5432/mosaic' },
queue: { type: 'bullmq' },
memory: { type: 'keyword' },
});
expect(result.tier).toBe('standalone');
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('DEPRECATED'));
});
it('rejects an invalid tier with an error listing all three valid values', () => {
expect(() =>
validateConfig({
tier: 'invalid',
storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5432/mosaic' },
queue: { type: 'bullmq' },
memory: { type: 'keyword' },
}),
).toThrow(/local.*standalone.*federated|federated.*standalone.*local/);
});
it('error message for invalid tier mentions all three valid values', () => {
let message = '';
try {
validateConfig({
tier: 'invalid',
storage: { type: 'postgres', url: 'postgresql://...' },
queue: { type: 'bullmq' },
memory: { type: 'keyword' },
});
} catch (err) {
message = err instanceof Error ? err.message : String(err);
}
expect(message).toContain('"local"');
expect(message).toContain('"standalone"');
expect(message).toContain('"federated"');
});
});
describe('DEFAULT_* config constants', () => {
it('DEFAULT_LOCAL_CONFIG has tier="local"', () => {
expect(DEFAULT_LOCAL_CONFIG.tier).toBe('local');
});
it('DEFAULT_STANDALONE_CONFIG has tier="standalone"', () => {
expect(DEFAULT_STANDALONE_CONFIG.tier).toBe('standalone');
});
it('DEFAULT_FEDERATED_CONFIG has tier="federated" and pgvector memory', () => {
expect(DEFAULT_FEDERATED_CONFIG.tier).toBe('federated');
expect(DEFAULT_FEDERATED_CONFIG.memory.type).toBe('pgvector');
});
it('DEFAULT_FEDERATED_CONFIG uses port 5433 (distinct from standalone 5432)', () => {
const url = (DEFAULT_FEDERATED_CONFIG.storage as { url: string }).url;
expect(url).toContain('5433');
});
it('DEFAULT_FEDERATED_CONFIG has enableVector=true on storage', () => {
const storage = DEFAULT_FEDERATED_CONFIG.storage as {
type: string;
url: string;
enableVector?: boolean;
};
expect(storage.enableVector).toBe(true);
});
});
describe('detectFromEnv — tier env-var routing', () => {
const originalEnv = process.env;
beforeEach(() => {
// Work on a fresh copy so individual tests can set/delete keys freely.
process.env = { ...originalEnv };
delete process.env['MOSAIC_STORAGE_TIER'];
delete process.env['DATABASE_URL'];
delete process.env['VALKEY_URL'];
});
afterEach(() => {
process.env = originalEnv;
});
it('no env vars → returns local config', () => {
const config = detectFromEnv();
expect(config.tier).toBe('local');
expect(config.storage.type).toBe('pglite');
expect(config.memory.type).toBe('keyword');
});
it('MOSAIC_STORAGE_TIER=federated alone → returns federated config with enableVector=true', () => {
process.env['MOSAIC_STORAGE_TIER'] = 'federated';
const config = detectFromEnv();
expect(config.tier).toBe('federated');
expect(config.memory.type).toBe('pgvector');
const storage = config.storage as { type: string; enableVector?: boolean };
expect(storage.enableVector).toBe(true);
});
it('MOSAIC_STORAGE_TIER=federated + DATABASE_URL → uses the URL and still has enableVector=true', () => {
process.env['MOSAIC_STORAGE_TIER'] = 'federated';
process.env['DATABASE_URL'] = 'postgresql://custom:[email protected]:5432/mydb';
const config = detectFromEnv();
expect(config.tier).toBe('federated');
const storage = config.storage as { type: string; url: string; enableVector?: boolean };
expect(storage.url).toBe('postgresql://custom:[email protected]:5432/mydb');
expect(storage.enableVector).toBe(true);
expect(config.memory.type).toBe('pgvector');
});
it('MOSAIC_STORAGE_TIER=standalone alone → returns standalone-shaped config (not local)', () => {
process.env['MOSAIC_STORAGE_TIER'] = 'standalone';
const config = detectFromEnv();
expect(config.tier).toBe('standalone');
expect(config.storage.type).toBe('postgres');
expect(config.memory.type).toBe('keyword');
});
});
+204
View File
@@ -0,0 +1,204 @@
import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import type { StorageConfig } from '@mosaicstack/storage';
import type { QueueAdapterConfig as QueueConfig } from '@mosaicstack/queue';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type StorageTier = 'local' | 'standalone' | 'federated';
export interface MemoryConfigRef {
type: 'pgvector' | 'sqlite-vec' | 'keyword';
}
export interface MosaicConfig {
tier: StorageTier;
storage: StorageConfig;
queue: QueueConfig;
memory: MemoryConfigRef;
}
/* ------------------------------------------------------------------ */
/* Defaults */
/* ------------------------------------------------------------------ */
export const DEFAULT_LOCAL_CONFIG: MosaicConfig = {
tier: 'local',
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
queue: { type: 'local', dataDir: '.mosaic/queue' },
memory: { type: 'keyword' },
};
export const DEFAULT_STANDALONE_CONFIG: MosaicConfig = {
tier: 'standalone',
storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5432/mosaic' },
queue: { type: 'bullmq' },
memory: { type: 'keyword' },
};
export const DEFAULT_FEDERATED_CONFIG: MosaicConfig = {
tier: 'federated',
storage: {
type: 'postgres',
url: 'postgresql://mosaic:mosaic@localhost:5433/mosaic',
enableVector: true,
},
queue: { type: 'bullmq' },
memory: { type: 'pgvector' },
};
/* ------------------------------------------------------------------ */
/* Validation */
/* ------------------------------------------------------------------ */
const VALID_TIERS = new Set<string>(['local', 'standalone', 'federated']);
const VALID_STORAGE_TYPES = new Set<string>(['postgres', 'pglite', 'files']);
const VALID_QUEUE_TYPES = new Set<string>(['bullmq', 'local']);
const VALID_MEMORY_TYPES = new Set<string>(['pgvector', 'sqlite-vec', 'keyword']);
export function validateConfig(raw: unknown): MosaicConfig {
if (typeof raw !== 'object' || raw === null) {
throw new Error('MosaicConfig must be a non-null object');
}
const obj = raw as Record<string, unknown>;
// tier
let tier = obj['tier'];
// Deprecated alias: 'team' → 'standalone' (kept for backward-compat with 0.0.x installs)
if (tier === 'team') {
process.stderr.write(
'[mosaic] DEPRECATED: tier="team" is deprecated — use "standalone" instead. ' +
'Update your mosaic.config.json.\n',
);
tier = 'standalone';
}
if (typeof tier !== 'string' || !VALID_TIERS.has(tier)) {
throw new Error(
`Invalid tier "${String(tier)}" — expected "local", "standalone", or "federated"`,
);
}
// storage
const storage = obj['storage'];
if (typeof storage !== 'object' || storage === null) {
throw new Error('config.storage must be a non-null object');
}
const storageType = (storage as Record<string, unknown>)['type'];
if (typeof storageType !== 'string' || !VALID_STORAGE_TYPES.has(storageType)) {
throw new Error(`Invalid storage.type "${String(storageType)}"`);
}
// queue
const queue = obj['queue'];
if (typeof queue !== 'object' || queue === null) {
throw new Error('config.queue must be a non-null object');
}
const queueType = (queue as Record<string, unknown>)['type'];
if (typeof queueType !== 'string' || !VALID_QUEUE_TYPES.has(queueType)) {
throw new Error(`Invalid queue.type "${String(queueType)}"`);
}
// memory
const memory = obj['memory'];
if (typeof memory !== 'object' || memory === null) {
throw new Error('config.memory must be a non-null object');
}
const memoryType = (memory as Record<string, unknown>)['type'];
if (typeof memoryType !== 'string' || !VALID_MEMORY_TYPES.has(memoryType)) {
throw new Error(`Invalid memory.type "${String(memoryType)}"`);
}
return {
tier: tier as StorageTier,
storage: storage as StorageConfig,
queue: queue as QueueConfig,
memory: memory as MemoryConfigRef,
};
}
/* ------------------------------------------------------------------ */
/* Loader */
/* ------------------------------------------------------------------ */
export function detectFromEnv(): MosaicConfig {
const tier = process.env['MOSAIC_STORAGE_TIER'];
if (tier === 'federated') {
if (process.env['DATABASE_URL']) {
return {
...DEFAULT_FEDERATED_CONFIG,
storage: {
type: 'postgres',
url: process.env['DATABASE_URL'],
enableVector: true,
},
queue: {
type: 'bullmq',
url: process.env['VALKEY_URL'],
},
};
}
// MOSAIC_STORAGE_TIER=federated without DATABASE_URL — use the default
// federated config (port 5433, enableVector: true, pgvector memory).
return DEFAULT_FEDERATED_CONFIG;
}
if (tier === 'standalone') {
if (process.env['DATABASE_URL']) {
return {
...DEFAULT_STANDALONE_CONFIG,
storage: {
type: 'postgres',
url: process.env['DATABASE_URL'],
},
queue: {
type: 'bullmq',
url: process.env['VALKEY_URL'],
},
};
}
// MOSAIC_STORAGE_TIER=standalone without DATABASE_URL — use the default
// standalone config instead of silently falling back to local.
return DEFAULT_STANDALONE_CONFIG;
}
// Legacy: DATABASE_URL set without MOSAIC_STORAGE_TIER — treat as standalone.
if (process.env['DATABASE_URL']) {
return {
...DEFAULT_STANDALONE_CONFIG,
storage: {
type: 'postgres',
url: process.env['DATABASE_URL'],
},
queue: {
type: 'bullmq',
url: process.env['VALKEY_URL'],
},
};
}
return DEFAULT_LOCAL_CONFIG;
}
export function loadConfig(configPath?: string): MosaicConfig {
// 1. Explicit path or default location
const paths = configPath
? [resolve(configPath)]
: [
resolve(process.cwd(), 'mosaic.config.json'),
resolve(process.cwd(), '../../mosaic.config.json'), // monorepo root when cwd is apps/gateway
];
for (const p of paths) {
if (existsSync(p)) {
const raw: unknown = JSON.parse(readFileSync(p, 'utf-8'));
return validateConfig(raw);
}
}
// 2. Fall back to env-var detection
return detectFromEnv();
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@mosaicstack/coord",
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/coord"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@mosaicstack/types": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
"access": "public"
},
"files": [
"dist"
]
}
@@ -0,0 +1,154 @@
import { describe, expect, it, vi } from 'vitest';
import {
InMemoryInteractionCoordinationPort,
InteractionCoordinationClient,
type CoordinationScope,
type InteractionCoordinationAuthorityError,
type InteractionCoordinationPort,
} from '../index.js';
const scope: CoordinationScope = {
actorId: 'operator-1',
tenantId: 'tenant-a',
correlationId: 'corr-1',
requesterAgentId: 'Nova',
};
function client(
port: InteractionCoordinationPort,
handoffIdFactory: () => string = (): string => 'handoff-1',
): InteractionCoordinationClient {
return new InteractionCoordinationClient(
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' },
port,
handoffIdFactory,
);
}
describe('InteractionCoordinationClient', (): void => {
it('round-trips handoff, observation, and result through the native port with identities as data', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const coordination = client(adapter);
await expect(
coordination.handoff(
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
scope,
),
).resolves.toEqual({
handoffId: 'handoff-1',
targetAgentId: 'Conductor',
status: 'queued',
correlationId: 'corr-1',
});
adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request');
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
await expect(coordination.observe('handoff-1', scope)).resolves.toMatchObject({
status: 'completed',
targetAgentId: 'Conductor',
activity: expect.arrayContaining([
expect.objectContaining({ status: 'queued' }),
expect.objectContaining({ status: 'running' }),
expect.objectContaining({ status: 'completed' }),
]),
});
await expect(coordination.result('handoff-1', scope)).resolves.toEqual({
handoffId: 'handoff-1',
targetAgentId: 'Conductor',
status: 'completed',
correlationId: 'corr-1',
summary: 'Merged by orchestrator',
});
expect(coordination).not.toHaveProperty('dispatch');
expect(coordination).not.toHaveProperty('assign');
expect(coordination).not.toHaveProperty('review');
expect(coordination).not.toHaveProperty('merge');
expect(coordination).not.toHaveProperty('cancel');
});
it('fails closed before delivery when an unconfigured agent requests orchestrator work', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
await expect(
client(adapter).handoff(
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
{ ...scope, requesterAgentId: 'Untrusted' },
),
).rejects.toMatchObject({
code: 'requester_forbidden',
} satisfies Partial<InteractionCoordinationAuthorityError>);
});
it('rejects self-delegation configuration before constructing a client', (): void => {
expect(
(): InteractionCoordinationClient =>
new InteractionCoordinationClient(
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
new InMemoryInteractionCoordinationPort(),
),
).toThrow('Interaction and orchestration identities must differ');
});
it('rejects whitespace-equivalent self-delegation identities', (): void => {
expect(
(): InteractionCoordinationClient =>
new InteractionCoordinationClient(
{ interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' },
new InMemoryInteractionCoordinationPort(),
),
).toThrow('Interaction and orchestration identities must differ');
});
it('does not expose another tenant handoff to observe or result', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const coordination = client(adapter);
await coordination.handoff(
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
scope,
);
const otherTenantScope = { ...scope, tenantId: 'tenant-b' };
await expect(coordination.observe('handoff-1', otherTenantScope)).rejects.toMatchObject({
code: 'forbidden',
});
await expect(coordination.result('handoff-1', otherTenantScope)).rejects.toMatchObject({
code: 'forbidden',
});
});
it('bounds native handoff retention by evicting the oldest handoff', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort({ maxHandoffs: 1 });
const first = client(adapter, (): string => 'handoff-1');
const second = client(adapter, (): string => 'handoff-2');
await first.handoff({ idempotencyKey: 'handoff-request-1', summary: 'First request' }, scope);
await second.handoff({ idempotencyKey: 'handoff-request-2', summary: 'Second request' }, scope);
await expect(first.observe('handoff-1', scope)).rejects.toMatchObject({ code: 'not_found' });
await expect(second.observe('handoff-2', scope)).resolves.toMatchObject({ status: 'queued' });
});
it('fails closed when a transport reports target drift', async (): Promise<void> => {
const adapter: InteractionCoordinationPort = {
handoff: vi.fn(async () => ({
handoffId: 'handoff-1',
targetAgentId: 'Unexpected',
status: 'accepted' as const,
correlationId: 'corr-1',
})),
observe: vi.fn(),
result: vi.fn(),
};
await expect(
client(adapter).handoff(
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
scope,
),
).rejects.toMatchObject({
code: 'target_drift',
} satisfies Partial<InteractionCoordinationAuthorityError>);
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { resolveLaunchCommand } from '../runner.js';
describe('coord consequential-runtime launch gate', () => {
it('routes default and direct configured Claude commands through mosaic', () => {
expect(resolveLaunchCommand('claude', 'continue', undefined)).toEqual([
'mosaic',
'claude',
'-p',
'continue',
]);
expect(resolveLaunchCommand('claude', 'continue', ['claude', '-p', '{prompt}'])).toEqual([
'mosaic',
'claude',
'-p',
'continue',
]);
});
it('preserves an already-gated Claude command and rejects unknown launchers', () => {
expect(
resolveLaunchCommand('claude', 'continue', ['mosaic', 'yolo', 'claude', '{prompt}']),
).toEqual(['mosaic', 'yolo', 'claude', 'continue']);
expect(() => resolveLaunchCommand('claude', 'continue', ['custom-launcher'])).toThrow(
/must use `mosaic claude`/,
);
});
it('does not change the out-of-scope Codex command contract', () => {
expect(resolveLaunchCommand('codex', 'continue', undefined)).toEqual([
'codex',
'-p',
'continue',
]);
expect(resolveLaunchCommand('codex', 'continue', ['codex', '{prompt}'])).toEqual([
'codex',
'continue',
]);
});
});
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import { parseTasksFile, writeTasksFile } from '../tasks-file.js';
import type { MissionTask } from '../types.js';
const SAMPLE_TASKS_MD = `# Tasks — MVP
> Single-writer: orchestrator only. Workers read but never modify.
| id | status | milestone | description | pr | notes |
| ------ | ----------- | --------- | ------------------------------------------ | --- | ----- |
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
| P0-002 | done | Phase 0 | @mosaicstack/types — migrate and extend | #65 | #2 |
| P1-001 | in-progress | Phase 1 | apps/gateway scaffold | #61 | #10 |
| P2-001 | not-started | Phase 2 | @mosaicstack/agent — Pi SDK integration | — | #19 |
| P2-002 | blocked | Phase 2 | Multi-provider support | — | #20 |
`;
describe('parseTasksFile', () => {
it('parses a valid TASKS.md into MissionTask[]', () => {
const tasks = parseTasksFile(SAMPLE_TASKS_MD);
expect(tasks).toHaveLength(5);
});
it('extracts task IDs correctly', () => {
const tasks = parseTasksFile(SAMPLE_TASKS_MD);
expect(tasks.map((t) => t.id)).toEqual(['P0-001', 'P0-002', 'P1-001', 'P2-001', 'P2-002']);
});
it('extracts statuses correctly', () => {
const tasks = parseTasksFile(SAMPLE_TASKS_MD);
expect(tasks.map((t) => t.status)).toEqual([
'done',
'done',
'in-progress',
'not-started',
'blocked',
]);
});
it('extracts milestones correctly', () => {
const tasks = parseTasksFile(SAMPLE_TASKS_MD);
expect(tasks[0]!.milestone).toBe('Phase 0');
expect(tasks[2]!.milestone).toBe('Phase 1');
expect(tasks[3]!.milestone).toBe('Phase 2');
});
it('extracts PR references', () => {
const tasks = parseTasksFile(SAMPLE_TASKS_MD);
expect(tasks[0]!.pr).toBe('#60');
expect(tasks[3]!.pr).toBe('—');
});
it('returns empty array for empty content', () => {
expect(parseTasksFile('')).toEqual([]);
expect(parseTasksFile('# No table here')).toEqual([]);
});
it('handles legacy status values', () => {
const content = `| id | status | description |
|----|--------|-------------|
| T1 | completed | Task one |
| T2 | pending | Task two |
| T3 | failed | Task three |
`;
const tasks = parseTasksFile(content);
expect(tasks[0]!.status).toBe('done');
expect(tasks[1]!.status).toBe('not-started');
expect(tasks[2]!.status).toBe('blocked');
});
});
describe('writeTasksFile', () => {
it('generates valid markdown table', () => {
const tasks: MissionTask[] = [
{
id: 'T-001',
title: 'Test task',
status: 'done',
dependencies: [],
milestone: 'Phase 1',
pr: '#42',
notes: '#1',
},
];
const output = writeTasksFile(tasks);
expect(output).toContain('| T-001 | done | Phase 1 | Test task | #42 | #1 |');
expect(output).toContain('# Tasks');
});
it('roundtrips parse/write', () => {
const tasks = parseTasksFile(SAMPLE_TASKS_MD);
const output = writeTasksFile(tasks);
const reparsed = parseTasksFile(output);
expect(reparsed).toHaveLength(tasks.length);
for (let i = 0; i < tasks.length; i++) {
expect(reparsed[i]!.id).toBe(tasks[i]!.id);
expect(reparsed[i]!.status).toBe(tasks[i]!.status);
expect(reparsed[i]!.title).toBe(tasks[i]!.title);
}
});
});
@@ -0,0 +1,208 @@
import {
type CoordinationObservation,
type CoordinationResult,
type CoordinationScope,
type InteractionCoordinationActivity,
type InteractionCoordinationPort,
type Handoff,
type HandoffReceipt,
type HandoffStatus,
} from './interaction-coordination.js';
const DEFAULT_HANDOFF_TTL_MS = 60 * 60 * 1_000;
const DEFAULT_MAX_HANDOFFS = 1_000;
interface StoredHandoff {
readonly handoff: Handoff;
status: HandoffStatus;
readonly activity: InteractionCoordinationActivity[];
readonly expiresAt: number;
result?: CoordinationResult;
}
export interface InMemoryInteractionCoordinationPortOptions {
now?: () => Date;
handoffTtlMs?: number;
maxHandoffs?: number;
}
/**
* Native deterministic queue/port adapter for the coordination boundary.
* It intentionally has no fleet/tmux dependency. A future deployment adapter
* implements InteractionCoordinationPort without changing interaction-plane callers.
*/
export class InMemoryInteractionCoordinationPort implements InteractionCoordinationPort {
private readonly handoffs = new Map<string, StoredHandoff>();
private readonly now: () => Date;
private readonly handoffTtlMs: number;
private readonly maxHandoffs: number;
constructor(options: InMemoryInteractionCoordinationPortOptions = {}) {
this.now = options.now ?? (() => new Date());
this.handoffTtlMs = options.handoffTtlMs ?? DEFAULT_HANDOFF_TTL_MS;
this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS;
}
async handoff(handoff: Handoff): Promise<HandoffReceipt> {
this.pruneExpiredHandoffs();
const existing = this.handoffs.get(handoff.handoffId);
if (existing !== undefined) {
this.assertSameHandoff(existing.handoff, handoff);
return this.receipt(existing.handoff, existing.status);
}
const stored: StoredHandoff = {
handoff: snapshotHandoff(handoff),
status: 'queued',
activity: [activity('queued', 'Handoff accepted by the native coordination queue', this.now)],
expiresAt: this.now().getTime() + this.handoffTtlMs,
};
this.handoffs.set(handoff.handoffId, stored);
this.enforceHandoffLimit();
return this.receipt(stored.handoff, stored.status);
}
async observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation> {
this.pruneExpiredHandoffs();
const stored = this.requireScopedHandoff(handoffId, scope);
return {
handoffId: stored.handoff.handoffId,
targetAgentId: stored.handoff.targetAgentId,
status: stored.status,
correlationId: stored.handoff.scope.correlationId,
activity: stored.activity.map(copyActivity),
};
}
async result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult> {
this.pruneExpiredHandoffs();
const stored = this.requireScopedHandoff(handoffId, scope);
return (
stored.result ?? {
handoffId: stored.handoff.handoffId,
targetAgentId: stored.handoff.targetAgentId,
status: 'pending',
correlationId: stored.handoff.scope.correlationId,
}
);
}
/** Host-side progression seam; interaction clients never receive this capability. */
recordActivity(handoffId: string, status: HandoffStatus, summary: string): void {
this.pruneExpiredHandoffs();
const stored = this.requireHandoff(handoffId);
stored.status = status;
stored.activity.push(activity(status, summary, this.now));
}
/** Host-side result seam for deterministic qualification; not an orchestrator consumer. */
recordResult(handoffId: string, status: 'completed' | 'failed', summary: string): void {
this.pruneExpiredHandoffs();
const stored = this.requireHandoff(handoffId);
stored.status = status;
stored.activity.push(activity(status, summary, this.now));
stored.result = {
handoffId: stored.handoff.handoffId,
targetAgentId: stored.handoff.targetAgentId,
status,
correlationId: stored.handoff.scope.correlationId,
summary,
};
}
private receipt(handoff: Handoff, status: HandoffStatus): HandoffReceipt {
return {
handoffId: handoff.handoffId,
targetAgentId: handoff.targetAgentId,
status: status === 'accepted' ? 'accepted' : 'queued',
correlationId: handoff.scope.correlationId,
};
}
private pruneExpiredHandoffs(): void {
const nowMs = this.now().getTime();
for (const [handoffId, handoff] of this.handoffs) {
if (handoff.expiresAt <= nowMs) this.handoffs.delete(handoffId);
}
}
private enforceHandoffLimit(): void {
while (this.handoffs.size > this.maxHandoffs) {
const oldest = this.handoffs.keys().next().value;
if (typeof oldest !== 'string') return;
this.handoffs.delete(oldest);
}
}
private requireScopedHandoff(handoffId: string, scope: CoordinationScope): StoredHandoff {
const stored = this.requireHandoff(handoffId);
if (
stored.handoff.scope.tenantId !== scope.tenantId ||
stored.handoff.scope.actorId !== scope.actorId ||
stored.handoff.scope.requesterAgentId !== scope.requesterAgentId
) {
throw new InMemoryInteractionCoordinationError('forbidden', 'Handoff scope does not match');
}
return stored;
}
private requireHandoff(handoffId: string): StoredHandoff {
const stored = this.handoffs.get(handoffId);
if (stored === undefined) {
throw new InMemoryInteractionCoordinationError('not_found', 'Handoff was not found');
}
return stored;
}
private assertSameHandoff(existing: Handoff, incoming: Handoff): void {
if (
existing.targetAgentId !== incoming.targetAgentId ||
existing.request.idempotencyKey !== incoming.request.idempotencyKey ||
existing.request.summary !== incoming.request.summary ||
existing.request.context !== incoming.request.context ||
existing.request.missionId !== incoming.request.missionId ||
existing.scope.actorId !== incoming.scope.actorId ||
existing.scope.tenantId !== incoming.scope.tenantId ||
existing.scope.correlationId !== incoming.scope.correlationId ||
existing.scope.requesterAgentId !== incoming.scope.requesterAgentId
) {
throw new InMemoryInteractionCoordinationError(
'conflict',
'Handoff ID is already bound to different immutable input',
);
}
}
}
export type InMemoryInteractionCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found';
export class InMemoryInteractionCoordinationError extends Error {
constructor(
readonly code: InMemoryInteractionCoordinationErrorCode,
message: string,
) {
super(message);
this.name = InMemoryInteractionCoordinationError.name;
}
}
function activity(
status: HandoffStatus,
summary: string,
now: () => Date,
): InteractionCoordinationActivity {
return { occurredAt: now().toISOString(), status, summary };
}
function copyActivity(entry: InteractionCoordinationActivity): InteractionCoordinationActivity {
return { ...entry };
}
function snapshotHandoff(handoff: Handoff): Handoff {
return Object.freeze({
handoffId: handoff.handoffId,
targetAgentId: handoff.targetAgentId,
request: Object.freeze({ ...handoff.request }),
scope: Object.freeze({ ...handoff.scope }),
});
}
+40
View File
@@ -0,0 +1,40 @@
export { createMission, loadMission, missionFilePath, saveMission } from './mission.js';
export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.js';
export { runTask, resumeTask } from './runner.js';
export { getMissionStatus, getTaskStatus } from './status.js';
export {
InMemoryInteractionCoordinationError,
InMemoryInteractionCoordinationPort,
} from './in-memory-interaction-coordination-port.js';
export {
InteractionCoordinationAuthorityError,
InteractionCoordinationClient,
} from './interaction-coordination.js';
export type {
CoordinationObservation,
CoordinationResult,
CoordinationScope,
InteractionCoordinationActivity,
InteractionCoordinationIdentity,
InteractionCoordinationPort,
Handoff,
HandoffReceipt,
HandoffRequest,
HandoffStatus,
} from './interaction-coordination.js';
export type {
CreateMissionOptions,
Mission,
MissionMilestone,
MissionRuntime,
MissionSession,
MissionStatus,
MissionStatusSummary,
MissionTask,
NextTaskCapsule,
RunTaskOptions,
TaskDetail,
TaskRun,
TaskStatus,
} from './types.js';
export { isMissionStatus, isTaskStatus, normalizeTaskStatus } from './types.js';
@@ -0,0 +1,209 @@
export type HandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed';
export interface CoordinationScope {
readonly actorId: string;
readonly tenantId: string;
readonly correlationId: string;
/** Trusted gateway/configuration identity; never supplied by a channel client. */
readonly requesterAgentId: string;
}
export interface InteractionCoordinationIdentity {
readonly interactionAgentId: string;
readonly orchestrationAgentId: string;
}
export interface HandoffRequest {
readonly idempotencyKey: string;
readonly summary: string;
readonly context?: string;
readonly missionId?: string;
}
export interface Handoff {
readonly handoffId: string;
readonly targetAgentId: string;
readonly request: HandoffRequest;
readonly scope: CoordinationScope;
}
export interface HandoffReceipt {
readonly handoffId: string;
readonly targetAgentId: string;
readonly status: 'queued' | 'accepted';
readonly correlationId: string;
}
export interface InteractionCoordinationActivity {
readonly occurredAt: string;
readonly status: HandoffStatus;
readonly summary: string;
}
export interface CoordinationObservation {
readonly handoffId: string;
readonly targetAgentId: string;
readonly status: HandoffStatus;
readonly correlationId: string;
readonly activity: readonly InteractionCoordinationActivity[];
}
export interface CoordinationResult {
readonly handoffId: string;
readonly targetAgentId: string;
readonly status: 'completed' | 'failed' | 'pending';
readonly correlationId: string;
readonly summary?: string;
}
/**
* Transport-neutral boundary. The interaction plane can request work and read
* its progress/result, but it cannot issue worker, review, merge, or other
* general orchestration commands.
*/
export interface InteractionCoordinationPort {
handoff(handoff: Handoff): Promise<HandoffReceipt>;
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
}
export type InteractionCoordinationAuthorityErrorCode =
| 'invalid_identity'
| 'requester_forbidden'
| 'target_drift'
| 'correlation_drift';
export class InteractionCoordinationAuthorityError extends Error {
constructor(
readonly code: InteractionCoordinationAuthorityErrorCode,
message: string,
) {
super(message);
this.name = InteractionCoordinationAuthorityError.name;
}
}
/**
* Enforces the interaction-to-orchestration authority boundary before a
* transport is reached. Identity names remain configuration data.
*/
export class InteractionCoordinationClient {
private readonly identity: InteractionCoordinationIdentity;
constructor(
identity: InteractionCoordinationIdentity,
private readonly port: InteractionCoordinationPort,
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
) {
this.identity = normalizeIdentity(identity);
}
async handoff(request: HandoffRequest, scope: CoordinationScope): Promise<HandoffReceipt> {
this.assertRequester(scope);
const handoff: Handoff = {
handoffId: this.handoffIdFactory(),
targetAgentId: this.identity.orchestrationAgentId,
request: snapshotRequest(request),
scope: snapshotScope(scope),
};
const receipt = await this.port.handoff(handoff);
if (
receipt.handoffId !== handoff.handoffId ||
receipt.targetAgentId !== handoff.targetAgentId
) {
throw new InteractionCoordinationAuthorityError(
'target_drift',
'Interaction coordination transport returned a mismatched handoff target',
);
}
if (receipt.correlationId !== handoff.scope.correlationId) {
throw new InteractionCoordinationAuthorityError(
'correlation_drift',
'Interaction coordination transport returned a mismatched correlation ID',
);
}
return receipt;
}
async observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation> {
this.assertRequester(scope);
return this.assertObservation(await this.port.observe(handoffId, snapshotScope(scope)), scope);
}
async result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult> {
this.assertRequester(scope);
return this.assertResult(await this.port.result(handoffId, snapshotScope(scope)), scope);
}
private assertRequester(scope: CoordinationScope): void {
if (scope.requesterAgentId !== this.identity.interactionAgentId) {
throw new InteractionCoordinationAuthorityError(
'requester_forbidden',
'Requester is not the configured interaction agent',
);
}
}
private assertObservation(
observation: CoordinationObservation,
scope: CoordinationScope,
): CoordinationObservation {
if (observation.targetAgentId !== this.identity.orchestrationAgentId) {
throw new InteractionCoordinationAuthorityError(
'target_drift',
'Interaction coordination transport returned an unexpected observation target',
);
}
if (observation.correlationId !== scope.correlationId) {
throw new InteractionCoordinationAuthorityError(
'correlation_drift',
'Interaction coordination transport returned a mismatched observation correlation ID',
);
}
return observation;
}
private assertResult(result: CoordinationResult, scope: CoordinationScope): CoordinationResult {
if (result.targetAgentId !== this.identity.orchestrationAgentId) {
throw new InteractionCoordinationAuthorityError(
'target_drift',
'Interaction coordination transport returned an unexpected result target',
);
}
if (result.correlationId !== scope.correlationId) {
throw new InteractionCoordinationAuthorityError(
'correlation_drift',
'Interaction coordination transport returned a mismatched result correlation ID',
);
}
return result;
}
}
function normalizeIdentity(
identity: InteractionCoordinationIdentity,
): InteractionCoordinationIdentity {
const interactionAgentId = identity.interactionAgentId.trim();
const orchestrationAgentId = identity.orchestrationAgentId.trim();
if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) {
throw new InteractionCoordinationAuthorityError(
'invalid_identity',
'Interaction and orchestration identities are required',
);
}
if (interactionAgentId === orchestrationAgentId) {
throw new InteractionCoordinationAuthorityError(
'invalid_identity',
'Interaction and orchestration identities must differ',
);
}
return Object.freeze({ interactionAgentId, orchestrationAgentId });
}
function snapshotRequest(request: HandoffRequest): HandoffRequest {
return Object.freeze({ ...request });
}
function snapshotScope(scope: CoordinationScope): CoordinationScope {
return Object.freeze({ ...scope });
}
+395
View File
@@ -0,0 +1,395 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { writeTasksFile } from './tasks-file.js';
import type { CreateMissionOptions, Mission, MissionMilestone, MissionSession } from './types.js';
import { isMissionStatus } from './types.js';
const DEFAULT_ORCHESTRATOR_DIR = '.mosaic/orchestrator';
const DEFAULT_MISSION_FILE = 'mission.json';
const DEFAULT_TASKS_FILE = 'docs/TASKS.md';
const DEFAULT_MANIFEST_FILE = 'docs/MISSION-MANIFEST.md';
const DEFAULT_SCRATCHPAD_DIR = 'docs/scratchpads';
const DEFAULT_MILESTONE_VERSION = '0.0.1';
function asRecord(value: unknown): Record<string, unknown> {
if (typeof value === 'object' && value !== null) {
return value as Record<string, unknown>;
}
return {};
}
function readString(
source: Record<string, unknown>,
...keys: readonly string[]
): string | undefined {
for (const key of keys) {
const value = source[key];
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
}
return undefined;
}
function readNumber(
source: Record<string, unknown>,
...keys: readonly string[]
): number | undefined {
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
}
return undefined;
}
function normalizeMilestoneStatus(status: string | undefined): MissionMilestone['status'] {
if (status === 'completed') return 'completed';
if (status === 'in-progress') return 'in-progress';
if (status === 'blocked') return 'blocked';
return 'pending';
}
function normalizeSessionRuntime(runtime: string | undefined): MissionSession['runtime'] {
if (runtime === 'claude' || runtime === 'codex' || runtime === 'unknown') {
return runtime;
}
return 'unknown';
}
function normalizeEndedReason(reason: string | undefined): MissionSession['endedReason'] {
if (
reason === 'completed' ||
reason === 'paused' ||
reason === 'crashed' ||
reason === 'killed' ||
reason === 'unknown'
) {
return reason;
}
return undefined;
}
function normalizeMission(raw: unknown, resolvedProjectPath: string): Mission {
const source = asRecord(raw);
const id = readString(source, 'id', 'mission_id') ?? 'mission';
const name = readString(source, 'name') ?? 'Unnamed Mission';
const statusCandidate = readString(source, 'status') ?? 'inactive';
const status = isMissionStatus(statusCandidate) ? statusCandidate : 'inactive';
const mission: Mission = {
schemaVersion: 1,
id,
name,
description: readString(source, 'description'),
projectPath: readString(source, 'projectPath', 'project_path') ?? resolvedProjectPath,
createdAt: readString(source, 'createdAt', 'created_at') ?? new Date().toISOString(),
status,
tasksFile: readString(source, 'tasksFile', 'tasks_file') ?? DEFAULT_TASKS_FILE,
manifestFile: readString(source, 'manifestFile', 'manifest_file') ?? DEFAULT_MANIFEST_FILE,
scratchpadFile:
readString(source, 'scratchpadFile', 'scratchpad_file') ??
`${DEFAULT_SCRATCHPAD_DIR}/${id}.md`,
orchestratorDir:
readString(source, 'orchestratorDir', 'orchestrator_dir') ?? DEFAULT_ORCHESTRATOR_DIR,
taskPrefix: readString(source, 'taskPrefix', 'task_prefix'),
qualityGates: readString(source, 'qualityGates', 'quality_gates'),
milestoneVersion: readString(source, 'milestoneVersion', 'milestone_version'),
milestones: [],
sessions: [],
};
const milestonesRaw = Array.isArray(source.milestones) ? source.milestones : [];
mission.milestones = milestonesRaw.map(
(milestoneValue: unknown, index: number): MissionMilestone => {
const milestone = asRecord(milestoneValue);
return {
id: readString(milestone, 'id') ?? `phase-${index + 1}`,
name: readString(milestone, 'name') ?? `Phase ${index + 1}`,
status: normalizeMilestoneStatus(readString(milestone, 'status')),
branch: readString(milestone, 'branch'),
issueRef: readString(milestone, 'issueRef', 'issue_ref'),
startedAt: readString(milestone, 'startedAt', 'started_at'),
completedAt: readString(milestone, 'completedAt', 'completed_at'),
};
},
);
const sessionsRaw = Array.isArray(source.sessions) ? source.sessions : [];
mission.sessions = sessionsRaw.map((sessionValue: unknown, index: number): MissionSession => {
const session = asRecord(sessionValue);
const fallbackSessionId = `sess-${String(index + 1).padStart(3, '0')}`;
return {
sessionId: readString(session, 'sessionId', 'session_id') ?? fallbackSessionId,
runtime: normalizeSessionRuntime(readString(session, 'runtime')),
pid: readNumber(session, 'pid'),
startedAt: readString(session, 'startedAt', 'started_at') ?? mission.createdAt,
endedAt: readString(session, 'endedAt', 'ended_at'),
endedReason: normalizeEndedReason(readString(session, 'endedReason', 'ended_reason')),
milestoneId: readString(session, 'milestoneId', 'milestone_id'),
lastTaskId: readString(session, 'lastTaskId', 'last_task_id'),
durationSeconds: readNumber(session, 'durationSeconds', 'duration_seconds'),
};
});
return mission;
}
function missionIdFromName(name: string): string {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-');
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
return `${slug || 'mission'}-${date}`;
}
function toAbsolutePath(basePath: string, targetPath: string): string {
if (path.isAbsolute(targetPath)) {
return targetPath;
}
return path.join(basePath, targetPath);
}
function isNodeErrorWithCode(error: unknown, code: string): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === code
);
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch (error) {
if (isNodeErrorWithCode(error, 'ENOENT')) {
return false;
}
throw error;
}
}
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
const directory = path.dirname(filePath);
await fs.mkdir(directory, { recursive: true });
const tempPath = path.join(
directory,
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`,
);
await fs.writeFile(tempPath, content, 'utf8');
await fs.rename(tempPath, filePath);
}
function renderManifest(mission: Mission): string {
const milestoneRows = mission.milestones
.map((milestone, index) => {
const issue = milestone.issueRef ?? '—';
const branch = milestone.branch ?? '—';
const started = milestone.startedAt ?? '—';
const completed = milestone.completedAt ?? '—';
return `| ${index + 1} | ${milestone.id} | ${milestone.name} | ${milestone.status} | ${branch} | ${issue} | ${started} | ${completed} |`;
})
.join('\n');
const body = [
`# Mission Manifest — ${mission.name}`,
'',
'> Persistent document tracking full mission scope, status, and session history.',
'',
'## Mission',
'',
`**ID:** ${mission.id}`,
`**Statement:** ${mission.description ?? ''}`,
'**Phase:** Intake',
'**Current Milestone:** —',
`**Progress:** 0 / ${mission.milestones.length} milestones`,
`**Status:** ${mission.status}`,
`**Last Updated:** ${new Date().toISOString().replace('T', ' ').replace(/\..+/, ' UTC')}`,
'',
'## Milestones',
'',
'| # | ID | Name | Status | Branch | Issue | Started | Completed |',
'|---|-----|------|--------|--------|-------|---------|-----------|',
milestoneRows,
'',
'## Session History',
'',
'| Session | Runtime | Started | Duration | Ended Reason | Last Task |',
'|---------|---------|---------|----------|--------------|-----------|',
'',
`## Scratchpad\n\nPath: \`${mission.scratchpadFile}\``,
'',
];
return body.join('\n');
}
function renderScratchpad(mission: Mission): string {
return [
`# Mission Scratchpad — ${mission.name}`,
'',
'> Append-only log. NEVER delete entries. NEVER overwrite sections.',
'',
'## Original Mission Prompt',
'',
'```',
'(Paste the mission prompt here on first session)',
'```',
'',
'## Planning Decisions',
'',
'## Session Log',
'',
'| Session | Date | Milestone | Tasks Done | Outcome |',
'|---------|------|-----------|------------|---------|',
'',
'## Open Questions',
'',
'## Corrections',
'',
].join('\n');
}
function buildMissionFromOptions(
options: CreateMissionOptions,
resolvedProjectPath: string,
): Mission {
const id = missionIdFromName(options.name);
const milestones = (options.milestones ?? []).map((name, index): MissionMilestone => {
const cleanName = name.trim();
const milestoneName = cleanName.length > 0 ? cleanName : `Phase ${index + 1}`;
return {
id: `phase-${index + 1}`,
name: milestoneName,
status: 'pending' as const,
branch: milestoneName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, ''),
};
});
return {
schemaVersion: 1,
id,
name: options.name,
description: options.description,
projectPath: resolvedProjectPath,
createdAt: new Date().toISOString(),
status: 'active',
tasksFile: DEFAULT_TASKS_FILE,
manifestFile: DEFAULT_MANIFEST_FILE,
scratchpadFile: `${DEFAULT_SCRATCHPAD_DIR}/${id}.md`,
orchestratorDir: DEFAULT_ORCHESTRATOR_DIR,
taskPrefix: options.prefix,
qualityGates: options.qualityGates,
milestoneVersion: options.version ?? DEFAULT_MILESTONE_VERSION,
milestones,
sessions: [],
};
}
export function missionFilePath(projectPath: string, mission?: Mission): string {
const orchestratorDir = mission?.orchestratorDir ?? DEFAULT_ORCHESTRATOR_DIR;
const baseDir = path.isAbsolute(orchestratorDir)
? orchestratorDir
: path.join(projectPath, orchestratorDir);
return path.join(baseDir, DEFAULT_MISSION_FILE);
}
export async function saveMission(mission: Mission): Promise<void> {
const filePath = missionFilePath(mission.projectPath, mission);
const payload = `${JSON.stringify(mission, null, 2)}\n`;
await writeFileAtomic(filePath, payload);
}
export async function createMission(options: CreateMissionOptions): Promise<Mission> {
const name = options.name.trim();
if (name.length === 0) {
throw new Error('Mission name is required');
}
const resolvedProjectPath = path.resolve(options.projectPath ?? process.cwd());
const mission = buildMissionFromOptions({ ...options, name }, resolvedProjectPath);
const missionPath = missionFilePath(resolvedProjectPath, mission);
const hasExistingMission = await fileExists(missionPath);
if (hasExistingMission) {
const existingRaw = await fs.readFile(missionPath, 'utf8');
const existingMission = normalizeMission(JSON.parse(existingRaw), resolvedProjectPath);
const active = existingMission.status === 'active' || existingMission.status === 'paused';
if (active && options.force !== true) {
throw new Error(
`Active mission exists: ${existingMission.name} (${existingMission.status}). Use force to overwrite.`,
);
}
}
await saveMission(mission);
const manifestPath = toAbsolutePath(resolvedProjectPath, mission.manifestFile);
const scratchpadPath = toAbsolutePath(resolvedProjectPath, mission.scratchpadFile);
const tasksPath = toAbsolutePath(resolvedProjectPath, mission.tasksFile);
if (options.force === true || !(await fileExists(manifestPath))) {
await writeFileAtomic(manifestPath, renderManifest(mission));
}
if (!(await fileExists(scratchpadPath))) {
await writeFileAtomic(scratchpadPath, renderScratchpad(mission));
}
if (!(await fileExists(tasksPath))) {
await writeFileAtomic(tasksPath, writeTasksFile([]));
}
return mission;
}
export async function loadMission(projectPath: string): Promise<Mission> {
const resolvedProjectPath = path.resolve(projectPath);
const filePath = missionFilePath(resolvedProjectPath);
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf8');
} catch (error) {
if (isNodeErrorWithCode(error, 'ENOENT')) {
throw new Error(`No mission found at ${filePath}`);
}
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`Invalid JSON in mission file: ${filePath}`);
}
const mission = normalizeMission(parsed, resolvedProjectPath);
if (mission.status === 'inactive') {
throw new Error('Mission exists but is inactive. Re-initialize with mosaic coord init.');
}
return mission;
}
+481
View File
@@ -0,0 +1,481 @@
import { spawn, spawnSync } from 'node:child_process';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { loadMission, saveMission } from './mission.js';
import { parseTasksFile, updateTaskStatus } from './tasks-file.js';
import type {
Mission,
MissionMilestone,
MissionSession,
RunTaskOptions,
TaskRun,
} from './types.js';
const SESSION_LOCK_FILE = 'session.lock';
const NEXT_TASK_FILE = 'next-task.json';
interface SessionLockState {
session_id: string;
runtime: 'claude' | 'codex';
pid: number;
started_at: string;
project_path: string;
milestone_id?: string;
}
function orchestratorDirPath(mission: Mission): string {
if (path.isAbsolute(mission.orchestratorDir)) {
return mission.orchestratorDir;
}
return path.join(mission.projectPath, mission.orchestratorDir);
}
function sessionLockPath(mission: Mission): string {
return path.join(orchestratorDirPath(mission), SESSION_LOCK_FILE);
}
function nextTaskCapsulePath(mission: Mission): string {
return path.join(orchestratorDirPath(mission), NEXT_TASK_FILE);
}
function tasksFilePath(mission: Mission): string {
if (path.isAbsolute(mission.tasksFile)) {
return mission.tasksFile;
}
return path.join(mission.projectPath, mission.tasksFile);
}
function buildSessionId(mission: Mission): string {
return `sess-${String(mission.sessions.length + 1).padStart(3, '0')}`;
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function currentMilestone(mission: Mission): MissionMilestone | undefined {
return (
mission.milestones.find((milestone) => milestone.status === 'in-progress') ??
mission.milestones.find((milestone) => milestone.status === 'pending')
);
}
async function readTasks(mission: Mission) {
const filePath = tasksFilePath(mission);
try {
const content = await fs.readFile(filePath, 'utf8');
return parseTasksFile(content);
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'ENOENT'
) {
return [];
}
throw error;
}
}
function currentBranch(projectPath: string): string | undefined {
const result = spawnSync('git', ['-C', projectPath, 'branch', '--show-current'], {
encoding: 'utf8',
});
if (result.status !== 0) {
return undefined;
}
const branch = result.stdout.trim();
return branch.length > 0 ? branch : undefined;
}
function percentage(done: number, total: number): number {
if (total === 0) return 0;
return Math.floor((done / total) * 100);
}
function formatDurationSeconds(totalSeconds: number): string {
if (totalSeconds < 60) return `${totalSeconds}s`;
if (totalSeconds < 3600) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m ${seconds}s`;
}
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
function buildContinuationPrompt(params: {
mission: Mission;
taskId: string;
runtime: 'claude' | 'codex';
tasksDone: number;
tasksTotal: number;
currentMilestone?: MissionMilestone;
previousSession?: MissionSession;
branch?: string;
}): string {
const {
mission,
taskId,
runtime,
tasksDone,
tasksTotal,
currentMilestone: milestone,
previousSession,
branch,
} = params;
const pct = percentage(tasksDone, tasksTotal);
const previousDuration =
previousSession?.durationSeconds !== undefined
? formatDurationSeconds(previousSession.durationSeconds)
: '—';
return [
'## Continuation Mission',
'',
`Continue **${mission.name}** from existing state.`,
'',
'## Setup',
'',
`- **Project:** ${mission.projectPath}`,
`- **State:** ${mission.tasksFile} (${tasksDone}/${tasksTotal} tasks complete)`,
`- **Manifest:** ${mission.manifestFile}`,
`- **Scratchpad:** ${mission.scratchpadFile}`,
'- **Protocol:** ~/.config/mosaic/guides/ORCHESTRATOR.md',
`- **Quality gates:** ${mission.qualityGates ?? '—'}`,
`- **Target runtime:** ${runtime}`,
'',
'## Resume Point',
'',
`- **Current milestone:** ${milestone?.name ?? '—'} (${milestone?.id ?? '—'})`,
`- **Next task:** ${taskId}`,
`- **Progress:** ${tasksDone}/${tasksTotal} (${pct}%)`,
`- **Branch:** ${branch ?? '—'}`,
'',
'## Previous Session Context',
'',
`- **Session:** ${previousSession?.sessionId ?? '—'} (${previousSession?.runtime ?? '—'}, ${previousDuration})`,
`- **Ended:** ${previousSession?.endedReason ?? '—'}`,
`- **Last completed task:** ${previousSession?.lastTaskId ?? '—'}`,
'',
'## Instructions',
'',
'1. Read `~/.config/mosaic/guides/ORCHESTRATOR.md` for full protocol',
`2. Read \`${mission.manifestFile}\` for mission scope and status`,
`3. Read \`${mission.scratchpadFile}\` for session history and decisions`,
`4. Read \`${mission.tasksFile}\` for current task state`,
'5. `git pull --rebase` to sync latest changes',
`6. Launch runtime with \`mosaic ${runtime} -p\``,
`7. Continue execution from task **${taskId}**`,
'8. Follow Two-Phase Completion Protocol',
`9. You are the SOLE writer of \`${mission.tasksFile}\``,
].join('\n');
}
export function resolveLaunchCommand(
runtime: 'claude' | 'codex',
prompt: string,
configuredCommand: string[] | undefined,
): string[] {
if (configuredCommand === undefined || configuredCommand.length === 0) {
return runtime === 'claude' ? ['mosaic', 'claude', '-p', prompt] : [runtime, '-p', prompt];
}
const hasPromptPlaceholder = configuredCommand.some((value) => value === '{prompt}');
const withInterpolation = configuredCommand.map((value) =>
value === '{prompt}' ? prompt : value,
);
const command = hasPromptPlaceholder ? withInterpolation : [...withInterpolation, prompt];
if (runtime !== 'claude') return command;
if (
command[0] === 'mosaic' &&
(command[1] === 'claude' || (command[1] === 'yolo' && command[2] === 'claude'))
) {
return command;
}
if (command[0] === 'claude') {
return ['mosaic', 'claude', ...command.slice(1)];
}
throw new Error(
'Custom Claude task commands must use `mosaic claude` so lease registration cannot be bypassed.',
);
}
async function writeAtomicJson(filePath: string, payload: unknown): Promise<void> {
const directory = path.dirname(filePath);
await fs.mkdir(directory, { recursive: true });
const tempPath = path.join(
directory,
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`,
);
await fs.writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
await fs.rename(tempPath, filePath);
}
async function readSessionLock(mission: Mission): Promise<SessionLockState | undefined> {
const filePath = sessionLockPath(mission);
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf8');
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'ENOENT'
) {
return undefined;
}
throw error;
}
let data: Partial<SessionLockState>;
try {
data = JSON.parse(raw) as Partial<SessionLockState>;
} catch {
return undefined;
}
if (
typeof data.session_id !== 'string' ||
(data.runtime !== 'claude' && data.runtime !== 'codex') ||
typeof data.pid !== 'number' ||
typeof data.started_at !== 'string' ||
typeof data.project_path !== 'string'
) {
return undefined;
}
return {
session_id: data.session_id,
runtime: data.runtime,
pid: data.pid,
started_at: data.started_at,
project_path: data.project_path,
milestone_id: data.milestone_id,
};
}
async function writeSessionLock(mission: Mission, lock: SessionLockState): Promise<void> {
await writeAtomicJson(sessionLockPath(mission), lock);
}
function markSessionCrashed(mission: Mission, sessionId: string, endedAt: string): Mission {
const sessions = mission.sessions.map((session) => {
if (session.sessionId !== sessionId) return session;
if (session.endedAt !== undefined) return session;
const startedEpoch = Date.parse(session.startedAt);
const endedEpoch = Date.parse(endedAt);
const durationSeconds =
Number.isFinite(startedEpoch) && Number.isFinite(endedEpoch)
? Math.max(0, Math.floor((endedEpoch - startedEpoch) / 1000))
: undefined;
return {
...session,
endedAt,
endedReason: 'crashed' as const,
durationSeconds,
};
});
return { ...mission, sessions };
}
export async function runTask(
mission: Mission,
taskId: string,
options: RunTaskOptions = {},
): Promise<TaskRun> {
const runtime = options.runtime ?? 'claude';
const mode = options.mode ?? 'interactive';
const freshMission = await loadMission(mission.projectPath);
const tasks = await readTasks(freshMission);
const matches = tasks.filter((task) => task.id === taskId);
if (matches.length === 0) {
throw new Error(`Task not found: ${taskId}`);
}
if (matches.length > 1) {
throw new Error(`Duplicate task IDs found: ${taskId}`);
}
const task = matches[0]!;
if (task.status === 'done' || task.status === 'cancelled') {
throw new Error(`Task ${taskId} cannot be run from status ${task.status}`);
}
const tasksTotal = tasks.length;
const tasksDone = tasks.filter((candidate) => candidate.status === 'done').length;
const selectedMilestone =
freshMission.milestones.find((milestone) => milestone.id === options.milestoneId) ??
freshMission.milestones.find((milestone) => milestone.id === task.milestone) ??
currentMilestone(freshMission);
const continuationPrompt = buildContinuationPrompt({
mission: freshMission,
taskId,
runtime,
tasksDone,
tasksTotal,
currentMilestone: selectedMilestone,
previousSession: freshMission.sessions.at(-1),
branch: currentBranch(freshMission.projectPath),
});
const launchCommand = resolveLaunchCommand(runtime, continuationPrompt, options.command);
const startedAt = new Date().toISOString();
const sessionId = buildSessionId(freshMission);
const lockFile = sessionLockPath(freshMission);
await writeAtomicJson(nextTaskCapsulePath(freshMission), {
generated_at: startedAt,
runtime,
mission_id: freshMission.id,
mission_name: freshMission.name,
project_path: freshMission.projectPath,
quality_gates: freshMission.qualityGates ?? '',
current_milestone: {
id: selectedMilestone?.id ?? '',
name: selectedMilestone?.name ?? '',
},
next_task: taskId,
progress: {
tasks_done: tasksDone,
tasks_total: tasksTotal,
pct: percentage(tasksDone, tasksTotal),
},
current_branch: currentBranch(freshMission.projectPath) ?? '',
});
if (mode === 'print-only') {
return {
missionId: freshMission.id,
taskId,
sessionId,
runtime,
launchCommand,
startedAt,
lockFile,
};
}
await updateTaskStatus(freshMission, taskId, 'in-progress');
await writeSessionLock(freshMission, {
session_id: sessionId,
runtime,
pid: 0,
started_at: startedAt,
project_path: freshMission.projectPath,
milestone_id: selectedMilestone?.id,
});
const child = spawn(launchCommand[0]!, launchCommand.slice(1), {
cwd: freshMission.projectPath,
env: {
...process.env,
...(options.env ?? {}),
},
stdio: 'inherit',
});
await new Promise<void>((resolve, reject) => {
child.once('spawn', () => {
resolve();
});
child.once('error', (error) => {
reject(error);
});
});
const pid = child.pid;
if (pid === undefined) {
throw new Error('Failed to start task runtime process (pid missing)');
}
await writeSessionLock(freshMission, {
session_id: sessionId,
runtime,
pid,
started_at: startedAt,
project_path: freshMission.projectPath,
milestone_id: selectedMilestone?.id,
});
const updatedMission: Mission = {
...freshMission,
status: 'active',
sessions: [
...freshMission.sessions,
{
sessionId,
runtime,
pid,
startedAt,
milestoneId: selectedMilestone?.id,
lastTaskId: taskId,
},
],
};
await saveMission(updatedMission);
return {
missionId: updatedMission.id,
taskId,
sessionId,
runtime,
launchCommand,
startedAt,
pid,
lockFile,
};
}
export async function resumeTask(
mission: Mission,
taskId: string,
options: Omit<RunTaskOptions, 'milestoneId'> = {},
): Promise<TaskRun> {
const freshMission = await loadMission(mission.projectPath);
const lock = await readSessionLock(freshMission);
if (lock !== undefined && lock.pid > 0 && isPidAlive(lock.pid)) {
throw new Error(`Session ${lock.session_id} is still running (PID ${lock.pid}).`);
}
let nextMissionState = freshMission;
if (lock !== undefined) {
const endedAt = new Date().toISOString();
nextMissionState = markSessionCrashed(freshMission, lock.session_id, endedAt);
await saveMission(nextMissionState);
await fs.rm(sessionLockPath(nextMissionState), { force: true });
}
return runTask(nextMissionState, taskId, options);
}
+186
View File
@@ -0,0 +1,186 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { loadMission } from './mission.js';
import { parseTasksFile } from './tasks-file.js';
import type {
Mission,
MissionSession,
MissionStatusSummary,
MissionTask,
TaskDetail,
} from './types.js';
const SESSION_LOCK_FILE = 'session.lock';
interface SessionLockState {
session_id?: string;
runtime?: string;
pid?: number;
started_at?: string;
milestone_id?: string;
}
function tasksFilePath(mission: Mission): string {
if (path.isAbsolute(mission.tasksFile)) {
return mission.tasksFile;
}
return path.join(mission.projectPath, mission.tasksFile);
}
function sessionLockPath(mission: Mission): string {
const orchestratorDir = path.isAbsolute(mission.orchestratorDir)
? mission.orchestratorDir
: path.join(mission.projectPath, mission.orchestratorDir);
return path.join(orchestratorDir, SESSION_LOCK_FILE);
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function readTasks(mission: Mission): Promise<MissionTask[]> {
try {
const content = await fs.readFile(tasksFilePath(mission), 'utf8');
return parseTasksFile(content);
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'ENOENT'
) {
return [];
}
throw error;
}
}
async function readActiveSession(mission: Mission): Promise<MissionSession | undefined> {
let lockRaw: string;
try {
lockRaw = await fs.readFile(sessionLockPath(mission), 'utf8');
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'ENOENT'
) {
return undefined;
}
throw error;
}
let lock: SessionLockState;
try {
lock = JSON.parse(lockRaw) as SessionLockState;
} catch {
return undefined;
}
if (
typeof lock.session_id !== 'string' ||
(lock.runtime !== 'claude' && lock.runtime !== 'codex') ||
typeof lock.started_at !== 'string'
) {
return undefined;
}
const pid = typeof lock.pid === 'number' ? lock.pid : undefined;
if (pid !== undefined && pid > 0 && !isPidAlive(pid)) {
return undefined;
}
const existingSession = mission.sessions.find((session) => session.sessionId === lock.session_id);
if (existingSession !== undefined) {
return existingSession;
}
return {
sessionId: lock.session_id,
runtime: lock.runtime,
pid,
startedAt: lock.started_at,
milestoneId: lock.milestone_id,
};
}
async function buildStatusSummary(
freshMission: Mission,
tasks: MissionTask[],
): Promise<MissionStatusSummary> {
const done = tasks.filter((task) => task.status === 'done').length;
const inProgress = tasks.filter((task) => task.status === 'in-progress').length;
const pending = tasks.filter((task) => task.status === 'not-started').length;
const blocked = tasks.filter((task) => task.status === 'blocked').length;
const cancelled = tasks.filter((task) => task.status === 'cancelled').length;
const nextTask = tasks.find((task) => task.status === 'not-started');
const completedMilestones = freshMission.milestones.filter(
(milestone) => milestone.status === 'completed',
).length;
const currentMilestone =
freshMission.milestones.find((milestone) => milestone.status === 'in-progress') ??
freshMission.milestones.find((milestone) => milestone.status === 'pending');
const activeSession = await readActiveSession(freshMission);
return {
mission: {
id: freshMission.id,
name: freshMission.name,
status: freshMission.status,
projectPath: freshMission.projectPath,
},
milestones: {
total: freshMission.milestones.length,
completed: completedMilestones,
current: currentMilestone,
},
tasks: {
total: tasks.length,
done,
inProgress,
pending,
blocked,
cancelled,
},
nextTaskId: nextTask?.id,
activeSession,
};
}
export async function getMissionStatus(mission: Mission): Promise<MissionStatusSummary> {
const freshMission = await loadMission(mission.projectPath);
const tasks = await readTasks(freshMission);
return buildStatusSummary(freshMission, tasks);
}
export async function getTaskStatus(mission: Mission, taskId: string): Promise<TaskDetail> {
const freshMission = await loadMission(mission.projectPath);
const tasks = await readTasks(freshMission);
const matches = tasks.filter((task) => task.id === taskId);
if (matches.length === 0) {
throw new Error(`Task not found: ${taskId}`);
}
if (matches.length > 1) {
throw new Error(`Duplicate task IDs found: ${taskId}`);
}
const summary = await buildStatusSummary(freshMission, tasks);
return {
missionId: freshMission.id,
task: matches[0]!,
isNextTask: summary.nextTaskId === taskId,
activeSession: summary.activeSession,
};
}
+378
View File
@@ -0,0 +1,378 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import type { Mission, MissionTask, TaskStatus } from './types.js';
import { normalizeTaskStatus } from './types.js';
const TASKS_LOCK_FILE = '.TASKS.md.lock';
const TASKS_LOCK_STALE_MS = 5 * 60 * 1000;
const TASKS_LOCK_WAIT_MS = 5 * 1000;
const TASKS_LOCK_RETRY_MS = 100;
const DEFAULT_TABLE_HEADER = [
'| id | status | milestone | description | pr | notes |',
'|----|--------|-----------|-------------|----|-------|',
] as const;
const DEFAULT_TASKS_PREAMBLE = [
'# Tasks',
'',
'> Single-writer: orchestrator only. Workers read but never modify.',
'',
...DEFAULT_TABLE_HEADER,
] as const;
interface ParsedTableRow {
readonly lineIndex: number;
readonly cells: string[];
}
interface ParsedTable {
readonly headerLineIndex: number;
readonly separatorLineIndex: number;
readonly headers: string[];
readonly rows: ParsedTableRow[];
readonly idColumn: number;
readonly statusColumn: number;
}
function normalizeHeaderName(input: string): string {
return input.trim().toLowerCase();
}
function splitMarkdownRow(line: string): string[] {
const trimmed = line.trim();
if (!trimmed.startsWith('|')) {
return [];
}
const parts = trimmed.split(/(?<!\\)\|/);
if (parts.length < 3) {
return [];
}
return parts.slice(1, -1).map((part) => part.trim().replace(/\\\|/g, '|'));
}
function isSeparatorRow(cells: readonly string[]): boolean {
return (
cells.length > 0 &&
cells.every((cell) => {
const value = cell.trim();
return /^:?-{3,}:?$/.test(value);
})
);
}
function parseTable(content: string): ParsedTable | undefined {
const lines = content.split(/\r?\n/);
let headerLineIndex = -1;
let separatorLineIndex = -1;
let headers: string[] = [];
for (let index = 0; index < lines.length; index += 1) {
const cells = splitMarkdownRow(lines[index] as string);
if (cells.length === 0) {
continue;
}
const normalized = cells.map(normalizeHeaderName);
if (!normalized.includes('id') || !normalized.includes('status')) {
continue;
}
if (index + 1 >= lines.length) {
continue;
}
const separatorCells = splitMarkdownRow(lines[index + 1] as string);
if (!isSeparatorRow(separatorCells)) {
continue;
}
headerLineIndex = index;
separatorLineIndex = index + 1;
headers = normalized;
break;
}
if (headerLineIndex < 0 || separatorLineIndex < 0) {
return undefined;
}
const idColumn = headers.indexOf('id');
const statusColumn = headers.indexOf('status');
if (idColumn < 0 || statusColumn < 0) {
return undefined;
}
const rows: ParsedTableRow[] = [];
let sawData = false;
for (let index = separatorLineIndex + 1; index < lines.length; index += 1) {
const rawLine = lines[index] as string;
const trimmed = rawLine.trim();
if (!trimmed.startsWith('|')) {
if (sawData) {
break;
}
continue;
}
const cells = splitMarkdownRow(rawLine);
if (cells.length === 0) {
if (sawData) {
break;
}
continue;
}
sawData = true;
const normalizedRow = [...cells];
while (normalizedRow.length < headers.length) {
normalizedRow.push('');
}
rows.push({ lineIndex: index, cells: normalizedRow });
}
return {
headerLineIndex,
separatorLineIndex,
headers,
rows,
idColumn,
statusColumn,
};
}
function escapeTableCell(value: string): string {
return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
}
function formatTableRow(cells: readonly string[]): string {
const escaped = cells.map((cell) => escapeTableCell(cell));
return `| ${escaped.join(' | ')} |`;
}
function parseDependencies(raw: string | undefined): string[] {
if (raw === undefined || raw.trim().length === 0) {
return [];
}
return raw
.split(',')
.map((value) => value.trim())
.filter((value) => value.length > 0);
}
function resolveTasksFilePath(mission: Mission): string {
if (path.isAbsolute(mission.tasksFile)) {
return mission.tasksFile;
}
return path.join(mission.projectPath, mission.tasksFile);
}
function isNodeErrorWithCode(error: unknown, code: string): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === code
);
}
async function delay(ms: number): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function acquireLock(lockPath: string): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < TASKS_LOCK_WAIT_MS) {
try {
const handle = await fs.open(lockPath, 'wx');
await handle.writeFile(
JSON.stringify(
{
pid: process.pid,
acquiredAt: new Date().toISOString(),
},
null,
2,
),
);
await handle.close();
return;
} catch (error) {
if (!isNodeErrorWithCode(error, 'EEXIST')) {
throw error;
}
try {
const stats = await fs.stat(lockPath);
if (Date.now() - stats.mtimeMs > TASKS_LOCK_STALE_MS) {
await fs.rm(lockPath, { force: true });
await delay(TASKS_LOCK_RETRY_MS);
continue;
}
} catch (statError) {
if (!isNodeErrorWithCode(statError, 'ENOENT')) {
throw statError;
}
}
await delay(TASKS_LOCK_RETRY_MS);
}
}
throw new Error(`Timed out acquiring TASKS lock: ${lockPath}`);
}
async function releaseLock(lockPath: string): Promise<void> {
await fs.rm(lockPath, { force: true });
}
async function writeAtomic(filePath: string, content: string): Promise<void> {
const directory = path.dirname(filePath);
await fs.mkdir(directory, { recursive: true });
const tempPath = path.join(
directory,
`.TASKS.md.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
await fs.writeFile(tempPath, content, 'utf8');
await fs.rename(tempPath, filePath);
}
export function parseTasksFile(content: string): MissionTask[] {
const parsedTable = parseTable(content);
if (parsedTable === undefined) {
return [];
}
const headerToColumn = new Map<string, number>();
parsedTable.headers.forEach((header, index) => {
headerToColumn.set(header, index);
});
const descriptionColumn = headerToColumn.get('description') ?? headerToColumn.get('title') ?? -1;
const milestoneColumn = headerToColumn.get('milestone') ?? -1;
const prColumn = headerToColumn.get('pr') ?? -1;
const notesColumn = headerToColumn.get('notes') ?? -1;
const assigneeColumn = headerToColumn.get('assignee') ?? -1;
const dependenciesColumn = headerToColumn.get('dependencies') ?? -1;
const tasks: MissionTask[] = [];
for (const row of parsedTable.rows) {
const id = row.cells[parsedTable.idColumn]?.trim();
if (id === undefined || id.length === 0) {
continue;
}
const rawStatusValue = row.cells[parsedTable.statusColumn] ?? '';
const normalized = normalizeTaskStatus(rawStatusValue);
const title = descriptionColumn >= 0 ? (row.cells[descriptionColumn] ?? '') : '';
const milestone = milestoneColumn >= 0 ? (row.cells[milestoneColumn] ?? '') : '';
const pr = prColumn >= 0 ? (row.cells[prColumn] ?? '') : '';
const notes = notesColumn >= 0 ? (row.cells[notesColumn] ?? '') : '';
const assignee = assigneeColumn >= 0 ? (row.cells[assigneeColumn] ?? '') : '';
const dependenciesRaw = dependenciesColumn >= 0 ? (row.cells[dependenciesColumn] ?? '') : '';
tasks.push({
id,
title,
status: normalized.status,
dependencies: parseDependencies(dependenciesRaw),
milestone: milestone.length > 0 ? milestone : undefined,
pr: pr.length > 0 ? pr : undefined,
notes: notes.length > 0 ? notes : undefined,
assignee: assignee.length > 0 ? assignee : undefined,
rawStatus: normalized.rawStatus,
line: row.lineIndex + 1,
});
}
return tasks;
}
export function writeTasksFile(tasks: MissionTask[]): string {
const lines: string[] = [...DEFAULT_TASKS_PREAMBLE];
for (const task of tasks) {
lines.push(
formatTableRow([
task.id,
task.status,
task.milestone ?? '',
task.title,
task.pr ?? '',
task.notes ?? '',
]),
);
}
return `${lines.join('\n')}\n`;
}
export async function updateTaskStatus(
mission: Mission,
taskId: string,
status: TaskStatus,
): Promise<void> {
const tasksFilePath = resolveTasksFilePath(mission);
const lockPath = path.join(path.dirname(tasksFilePath), TASKS_LOCK_FILE);
await fs.mkdir(path.dirname(tasksFilePath), { recursive: true });
await acquireLock(lockPath);
try {
let content: string;
try {
content = await fs.readFile(tasksFilePath, 'utf8');
} catch (error) {
if (isNodeErrorWithCode(error, 'ENOENT')) {
throw new Error(`TASKS file not found: ${tasksFilePath}`);
}
throw error;
}
const table = parseTable(content);
if (table === undefined) {
throw new Error(`Could not parse TASKS table in ${tasksFilePath}`);
}
const matchingRows = table.rows.filter((row) => {
const rowTaskId = row.cells[table.idColumn]?.trim();
return rowTaskId === taskId;
});
if (matchingRows.length === 0) {
throw new Error(`Task not found in TASKS.md: ${taskId}`);
}
if (matchingRows.length > 1) {
throw new Error(`Duplicate task IDs found in TASKS.md: ${taskId}`);
}
const targetRow = matchingRows[0] as ParsedTableRow;
const updatedCells = [...targetRow.cells];
updatedCells[table.statusColumn] = status;
const lines = content.split(/\r?\n/);
lines[targetRow.lineIndex] = formatTableRow(updatedCells);
const updatedContent = `${lines.join('\n').replace(/\n+$/, '')}\n`;
await writeAtomic(tasksFilePath, updatedContent);
} finally {
await releaseLock(lockPath);
}
}
+184
View File
@@ -0,0 +1,184 @@
export type TaskStatus = 'not-started' | 'in-progress' | 'done' | 'blocked' | 'cancelled';
export type MissionStatus = 'active' | 'paused' | 'completed' | 'inactive';
export type MissionRuntime = 'claude' | 'codex' | 'unknown';
export interface MissionMilestone {
id: string;
name: string;
status: 'pending' | 'in-progress' | 'completed' | 'blocked';
branch?: string;
issueRef?: string;
startedAt?: string;
completedAt?: string;
}
export interface MissionSession {
sessionId: string;
runtime: MissionRuntime;
pid?: number;
startedAt: string;
endedAt?: string;
endedReason?: 'completed' | 'paused' | 'crashed' | 'killed' | 'unknown';
milestoneId?: string;
lastTaskId?: string;
durationSeconds?: number;
}
export interface Mission {
schemaVersion: 1;
id: string;
name: string;
description?: string;
projectPath: string;
createdAt: string;
status: MissionStatus;
tasksFile: string;
manifestFile: string;
scratchpadFile: string;
orchestratorDir: string;
taskPrefix?: string;
qualityGates?: string;
milestoneVersion?: string;
milestones: MissionMilestone[];
sessions: MissionSession[];
}
export interface MissionTask {
id: string;
title: string;
status: TaskStatus;
assignee?: string;
dependencies: string[];
milestone?: string;
pr?: string;
notes?: string;
rawStatus?: string;
line?: number;
}
export interface TaskRun {
missionId: string;
taskId: string;
sessionId: string;
runtime: 'claude' | 'codex';
launchCommand: string[];
startedAt: string;
pid?: number;
lockFile: string;
}
export interface MissionStatusSummary {
mission: Pick<Mission, 'id' | 'name' | 'status' | 'projectPath'>;
milestones: {
total: number;
completed: number;
current?: MissionMilestone;
};
tasks: {
total: number;
done: number;
inProgress: number;
pending: number;
blocked: number;
cancelled: number;
};
nextTaskId?: string;
activeSession?: MissionSession;
}
export interface TaskDetail {
missionId: string;
task: MissionTask;
isNextTask: boolean;
activeSession?: MissionSession;
}
export interface CreateMissionOptions {
name: string;
projectPath?: string;
prefix?: string;
milestones?: string[];
qualityGates?: string;
version?: string;
description?: string;
force?: boolean;
}
export interface RunTaskOptions {
runtime?: 'claude' | 'codex';
mode?: 'interactive' | 'print-only';
milestoneId?: string;
launchStrategy?: 'subprocess' | 'spawn-adapter';
env?: Record<string, string>;
command?: string[];
}
export interface NextTaskCapsule {
generatedAt: string;
runtime: 'claude' | 'codex';
missionId: string;
missionName: string;
projectPath: string;
qualityGates?: string;
currentMilestone: {
id?: string;
name?: string;
};
nextTask: string;
progress: {
tasksDone: number;
tasksTotal: number;
pct: number;
};
currentBranch?: string;
}
const LEGACY_TASK_STATUS: Readonly<Record<string, TaskStatus>> = {
'not-started': 'not-started',
pending: 'not-started',
todo: 'not-started',
'in-progress': 'in-progress',
in_progress: 'in-progress',
done: 'done',
completed: 'done',
blocked: 'blocked',
failed: 'blocked',
cancelled: 'cancelled',
};
export function normalizeTaskStatus(input: string): {
status: TaskStatus;
rawStatus?: string;
} {
const raw = input.trim().toLowerCase();
if (raw.length === 0) {
return { status: 'not-started' };
}
const normalized = LEGACY_TASK_STATUS[raw];
if (normalized === undefined) {
return { status: 'not-started', rawStatus: raw };
}
if (raw !== normalized) {
return { status: normalized, rawStatus: raw };
}
return { status: normalized };
}
export function isMissionStatus(value: string): value is MissionStatus {
return value === 'active' || value === 'paused' || value === 'completed' || value === 'inactive';
}
export function isTaskStatus(value: string): value is TaskStatus {
return (
value === 'not-started' ||
value === 'in-progress' ||
value === 'done' ||
value === 'blocked' ||
value === 'cancelled'
);
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic',
},
});
@@ -0,0 +1,166 @@
CREATE TABLE "accounts" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp with time zone,
"refresh_token_expires_at" timestamp with time zone,
"scope" text,
"password" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "agents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"provider" text NOT NULL,
"model" text NOT NULL,
"status" text DEFAULT 'idle' NOT NULL,
"config" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "appreciations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"from_user" text,
"to_user" text,
"message" text NOT NULL,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "conversations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text,
"user_id" text NOT NULL,
"project_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"type" text NOT NULL,
"title" text NOT NULL,
"description" text,
"date" timestamp with time zone DEFAULT now() NOT NULL,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"conversation_id" uuid NOT NULL,
"role" text NOT NULL,
"content" text NOT NULL,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "missions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"status" text DEFAULT 'planning' NOT NULL,
"project_id" uuid,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "projects" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"status" text DEFAULT 'active' NOT NULL,
"owner_id" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"token" text NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "sessions_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "tasks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text NOT NULL,
"description" text,
"status" text DEFAULT 'not-started' NOT NULL,
"priority" text DEFAULT 'medium' NOT NULL,
"project_id" uuid,
"mission_id" uuid,
"assignee" text,
"tags" jsonb,
"due_date" timestamp with time zone,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tickets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text NOT NULL,
"description" text,
"status" text DEFAULT 'open' NOT NULL,
"priority" text DEFAULT 'medium' NOT NULL,
"source" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"role" text DEFAULT 'member' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verifications" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "conversations" ADD CONSTRAINT "conversations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "conversations" ADD CONSTRAINT "conversations_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "missions" ADD CONSTRAINT "missions_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "projects" ADD CONSTRAINT "projects_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_mission_id_missions_id_fk" FOREIGN KEY ("mission_id") REFERENCES "public"."missions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "conversations_user_id_idx" ON "conversations" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "conversations_project_id_idx" ON "conversations" USING btree ("project_id");--> statement-breakpoint
CREATE INDEX "events_type_idx" ON "events" USING btree ("type");--> statement-breakpoint
CREATE INDEX "events_date_idx" ON "events" USING btree ("date");--> statement-breakpoint
CREATE INDEX "messages_conversation_id_idx" ON "messages" USING btree ("conversation_id");--> statement-breakpoint
CREATE INDEX "missions_project_id_idx" ON "missions" USING btree ("project_id");--> statement-breakpoint
CREATE INDEX "tasks_project_id_idx" ON "tasks" USING btree ("project_id");--> statement-breakpoint
CREATE INDEX "tasks_mission_id_idx" ON "tasks" USING btree ("mission_id");--> statement-breakpoint
CREATE INDEX "tasks_status_idx" ON "tasks" USING btree ("status");--> statement-breakpoint
CREATE INDEX "tickets_status_idx" ON "tickets" USING btree ("status");
@@ -0,0 +1,82 @@
CREATE EXTENSION IF NOT EXISTS vector;--> statement-breakpoint
CREATE TABLE "agent_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"session_id" text NOT NULL,
"user_id" text,
"level" text DEFAULT 'info' NOT NULL,
"category" text DEFAULT 'general' NOT NULL,
"content" text NOT NULL,
"metadata" jsonb,
"tier" text DEFAULT 'hot' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"summarized_at" timestamp with time zone,
"archived_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "insights" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"content" text NOT NULL,
"embedding" vector(1536),
"source" text DEFAULT 'agent' NOT NULL,
"category" text DEFAULT 'general' NOT NULL,
"relevance_score" real DEFAULT 1 NOT NULL,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"decayed_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "preferences" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"key" text NOT NULL,
"value" jsonb NOT NULL,
"category" text DEFAULT 'general' NOT NULL,
"source" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "skills" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"version" text,
"source" text DEFAULT 'custom' NOT NULL,
"config" jsonb,
"enabled" boolean DEFAULT true NOT NULL,
"installed_by" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "skills_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "summarization_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"logs_processed" integer DEFAULT 0 NOT NULL,
"insights_created" integer DEFAULT 0 NOT NULL,
"error_message" text,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "conversations" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "agent_logs" ADD CONSTRAINT "agent_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "insights" ADD CONSTRAINT "insights_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "preferences" ADD CONSTRAINT "preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "skills" ADD CONSTRAINT "skills_installed_by_users_id_fk" FOREIGN KEY ("installed_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "agent_logs_session_id_idx" ON "agent_logs" USING btree ("session_id");--> statement-breakpoint
CREATE INDEX "agent_logs_user_id_idx" ON "agent_logs" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "agent_logs_tier_idx" ON "agent_logs" USING btree ("tier");--> statement-breakpoint
CREATE INDEX "agent_logs_created_at_idx" ON "agent_logs" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "insights_user_id_idx" ON "insights" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "insights_category_idx" ON "insights" USING btree ("category");--> statement-breakpoint
CREATE INDEX "insights_relevance_idx" ON "insights" USING btree ("relevance_score");--> statement-breakpoint
CREATE INDEX "preferences_user_id_idx" ON "preferences" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "preferences_user_key_idx" ON "preferences" USING btree ("user_id","key");--> statement-breakpoint
CREATE INDEX "skills_enabled_idx" ON "skills" USING btree ("enabled");--> statement-breakpoint
CREATE INDEX "summarization_jobs_status_idx" ON "summarization_jobs" USING btree ("status");--> statement-breakpoint
CREATE INDEX "conversations_archived_idx" ON "conversations" USING btree ("archived");
@@ -0,0 +1,29 @@
CREATE TABLE "mission_tasks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"mission_id" uuid NOT NULL,
"task_id" uuid,
"user_id" text NOT NULL,
"status" text DEFAULT 'not-started' NOT NULL,
"description" text,
"notes" text,
"pr" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "missions" ADD COLUMN "user_id" text;--> statement-breakpoint
ALTER TABLE "missions" ADD COLUMN "phase" text;--> statement-breakpoint
ALTER TABLE "missions" ADD COLUMN "milestones" jsonb;--> statement-breakpoint
ALTER TABLE "missions" ADD COLUMN "config" jsonb;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "banned" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "ban_reason" text;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "ban_expires" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "mission_tasks" ADD CONSTRAINT "mission_tasks_mission_id_missions_id_fk" FOREIGN KEY ("mission_id") REFERENCES "public"."missions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mission_tasks" ADD CONSTRAINT "mission_tasks_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mission_tasks" ADD CONSTRAINT "mission_tasks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "mission_tasks_mission_id_idx" ON "mission_tasks" USING btree ("mission_id");--> statement-breakpoint
CREATE INDEX "mission_tasks_task_id_idx" ON "mission_tasks" USING btree ("task_id");--> statement-breakpoint
CREATE INDEX "mission_tasks_user_id_idx" ON "mission_tasks" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "mission_tasks_status_idx" ON "mission_tasks" USING btree ("status");--> statement-breakpoint
ALTER TABLE "missions" ADD CONSTRAINT "missions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "missions_user_id_idx" ON "missions" USING btree ("user_id");
@@ -0,0 +1,44 @@
CREATE TABLE "team_members" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"team_id" uuid NOT NULL,
"user_id" text NOT NULL,
"role" text DEFAULT 'member' NOT NULL,
"invited_by" text,
"joined_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "teams" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"owner_id" text NOT NULL,
"manager_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "teams_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN "project_id" uuid;--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN "owner_id" text;--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN "system_prompt" text;--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN "allowed_tools" jsonb;--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN "skills" jsonb;--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN "is_system" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "conversations" ADD COLUMN "agent_id" uuid;--> statement-breakpoint
ALTER TABLE "preferences" ADD COLUMN "mutable" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN "team_id" uuid;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN "owner_type" text DEFAULT 'user' NOT NULL;--> statement-breakpoint
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_invited_by_users_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "teams" ADD CONSTRAINT "teams_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "teams" ADD CONSTRAINT "teams_manager_id_users_id_fk" FOREIGN KEY ("manager_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "team_members_team_user_idx" ON "team_members" USING btree ("team_id","user_id");--> statement-breakpoint
ALTER TABLE "agents" ADD CONSTRAINT "agents_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "agents" ADD CONSTRAINT "agents_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "conversations" ADD CONSTRAINT "conversations_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "projects" ADD CONSTRAINT "projects_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "agents_project_id_idx" ON "agents" USING btree ("project_id");--> statement-breakpoint
CREATE INDEX "agents_owner_id_idx" ON "agents" USING btree ("owner_id");--> statement-breakpoint
CREATE INDEX "agents_is_system_idx" ON "agents" USING btree ("is_system");--> statement-breakpoint
CREATE INDEX "conversations_agent_id_idx" ON "conversations" USING btree ("agent_id");
@@ -0,0 +1,14 @@
DROP INDEX "agent_logs_session_id_idx";--> statement-breakpoint
DROP INDEX "agent_logs_tier_idx";--> statement-breakpoint
DROP INDEX "agent_logs_created_at_idx";--> statement-breakpoint
DROP INDEX "conversations_user_id_idx";--> statement-breakpoint
DROP INDEX "conversations_archived_idx";--> statement-breakpoint
DROP INDEX "preferences_user_key_idx";--> statement-breakpoint
CREATE INDEX "accounts_provider_account_idx" ON "accounts" USING btree ("provider_id","account_id");--> statement-breakpoint
CREATE INDEX "accounts_user_id_idx" ON "accounts" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "agent_logs_session_tier_idx" ON "agent_logs" USING btree ("session_id","tier");--> statement-breakpoint
CREATE INDEX "agent_logs_tier_created_at_idx" ON "agent_logs" USING btree ("tier","created_at");--> statement-breakpoint
CREATE INDEX "conversations_user_archived_idx" ON "conversations" USING btree ("user_id","archived");--> statement-breakpoint
CREATE INDEX "sessions_user_id_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "sessions_expires_at_idx" ON "sessions" USING btree ("expires_at");--> statement-breakpoint
CREATE UNIQUE INDEX "preferences_user_key_idx" ON "preferences" USING btree ("user_id","key");
@@ -0,0 +1,17 @@
CREATE TABLE "routing_rules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"priority" integer NOT NULL,
"scope" text DEFAULT 'system' NOT NULL,
"user_id" text,
"conditions" jsonb NOT NULL,
"action" jsonb NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "routing_rules" ADD CONSTRAINT "routing_rules_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "routing_rules_scope_priority_idx" ON "routing_rules" USING btree ("scope","priority");--> statement-breakpoint
CREATE INDEX "routing_rules_user_id_idx" ON "routing_rules" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "routing_rules_enabled_idx" ON "routing_rules" USING btree ("enabled");
@@ -0,0 +1,16 @@
CREATE TABLE "provider_credentials" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"provider" text NOT NULL,
"credential_type" text NOT NULL,
"encrypted_value" text NOT NULL,
"refresh_token" text,
"expires_at" timestamp with time zone,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "provider_credentials" ADD CONSTRAINT "provider_credentials_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "provider_credentials_user_provider_idx" ON "provider_credentials" USING btree ("user_id","provider");--> statement-breakpoint
CREATE INDEX "provider_credentials_user_id_idx" ON "provider_credentials" USING btree ("user_id");
@@ -0,0 +1 @@
ALTER TABLE "conversations" ADD COLUMN "session_id" text;
@@ -0,0 +1,75 @@
CREATE TYPE "public"."grant_status" AS ENUM('active', 'revoked', 'expired');--> statement-breakpoint
CREATE TYPE "public"."peer_state" AS ENUM('pending', 'active', 'suspended', 'revoked');--> statement-breakpoint
CREATE TABLE "admin_tokens" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"token_hash" text NOT NULL,
"label" text NOT NULL,
"scope" text DEFAULT 'admin' NOT NULL,
"expires_at" timestamp with time zone,
"last_used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "federation_audit_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"request_id" text NOT NULL,
"peer_id" uuid,
"subject_user_id" text,
"grant_id" uuid,
"verb" text NOT NULL,
"resource" text NOT NULL,
"status_code" integer NOT NULL,
"result_count" integer,
"denied_reason" text,
"latency_ms" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"query_hash" text,
"outcome" text,
"bytes_out" integer
);
--> statement-breakpoint
CREATE TABLE "federation_grants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"subject_user_id" text NOT NULL,
"peer_id" uuid NOT NULL,
"scope" jsonb NOT NULL,
"status" "grant_status" DEFAULT 'active' NOT NULL,
"expires_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"revoked_at" timestamp with time zone,
"revoked_reason" text
);
--> statement-breakpoint
CREATE TABLE "federation_peers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"common_name" text NOT NULL,
"display_name" text NOT NULL,
"cert_pem" text NOT NULL,
"cert_serial" text NOT NULL,
"cert_not_after" timestamp with time zone NOT NULL,
"client_key_pem" text,
"state" "peer_state" DEFAULT 'pending' NOT NULL,
"endpoint_url" text,
"last_seen_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"revoked_at" timestamp with time zone,
CONSTRAINT "federation_peers_common_name_unique" UNIQUE("common_name"),
CONSTRAINT "federation_peers_cert_serial_unique" UNIQUE("cert_serial")
);
--> statement-breakpoint
ALTER TABLE "admin_tokens" ADD CONSTRAINT "admin_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "federation_audit_log" ADD CONSTRAINT "federation_audit_log_peer_id_federation_peers_id_fk" FOREIGN KEY ("peer_id") REFERENCES "public"."federation_peers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "federation_audit_log" ADD CONSTRAINT "federation_audit_log_subject_user_id_users_id_fk" FOREIGN KEY ("subject_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "federation_audit_log" ADD CONSTRAINT "federation_audit_log_grant_id_federation_grants_id_fk" FOREIGN KEY ("grant_id") REFERENCES "public"."federation_grants"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "federation_grants" ADD CONSTRAINT "federation_grants_subject_user_id_users_id_fk" FOREIGN KEY ("subject_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "federation_grants" ADD CONSTRAINT "federation_grants_peer_id_federation_peers_id_fk" FOREIGN KEY ("peer_id") REFERENCES "public"."federation_peers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "admin_tokens_user_id_idx" ON "admin_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "admin_tokens_hash_idx" ON "admin_tokens" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "federation_audit_log_peer_created_at_idx" ON "federation_audit_log" USING btree ("peer_id","created_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "federation_audit_log_subject_created_at_idx" ON "federation_audit_log" USING btree ("subject_user_id","created_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "federation_audit_log_created_at_idx" ON "federation_audit_log" USING btree ("created_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "federation_grants_subject_status_idx" ON "federation_grants" USING btree ("subject_user_id","status");--> statement-breakpoint
CREATE INDEX "federation_grants_peer_status_idx" ON "federation_grants" USING btree ("peer_id","status");--> statement-breakpoint
CREATE INDEX "federation_peers_cert_serial_idx" ON "federation_peers" USING btree ("cert_serial");--> statement-breakpoint
CREATE INDEX "federation_peers_state_idx" ON "federation_peers" USING btree ("state");
@@ -0,0 +1,2 @@
ALTER TYPE "public"."grant_status" ADD VALUE 'pending' BEFORE 'active';--> statement-breakpoint
ALTER TABLE "federation_grants" ALTER COLUMN "status" SET DEFAULT 'pending';
@@ -0,0 +1,11 @@
CREATE TABLE "federation_enrollment_tokens" (
"token" text PRIMARY KEY NOT NULL,
"grant_id" uuid NOT NULL,
"peer_id" uuid NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "federation_enrollment_tokens" ADD CONSTRAINT "federation_enrollment_tokens_grant_id_federation_grants_id_fk" FOREIGN KEY ("grant_id") REFERENCES "public"."federation_grants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "federation_enrollment_tokens" ADD CONSTRAINT "federation_enrollment_tokens_peer_id_federation_peers_id_fk" FOREIGN KEY ("peer_id") REFERENCES "public"."federation_peers"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,22 @@
CREATE TYPE "public"."backlog_status" AS ENUM('ready', 'claimed', 'blocked', 'done');--> statement-breakpoint
CREATE TABLE "backlog" (
"id" text PRIMARY KEY NOT NULL,
"title" text NOT NULL,
"body" text,
"phase" text,
"priority" integer DEFAULT 0 NOT NULL,
"status" "backlog_status" DEFAULT 'ready' NOT NULL,
"depends_on" jsonb DEFAULT '[]'::jsonb NOT NULL,
"claim_owner" text,
"claim_ttl_seconds" integer,
"claimed_at" timestamp with time zone,
"attempts" integer DEFAULT 0 NOT NULL,
"idempotency_key" text,
"acceptance" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "backlog_status_priority_idx" ON "backlog" USING btree ("status","priority");--> statement-breakpoint
CREATE INDEX "backlog_status_claimed_at_idx" ON "backlog" USING btree ("status","claimed_at");--> statement-breakpoint
CREATE UNIQUE INDEX "backlog_idempotency_key_idx" ON "backlog" USING btree ("idempotency_key");

Some files were not shown because too many files have changed in this diff Show More