Merge origin/main into docs/tess-ledger-sync-m1
Some checks failed
ci/woodpecker/pr/ci Pipeline was canceled
Some checks failed
ci/woodpecker/pr/ci Pipeline was canceled
Resolve tracking-doc divergence for the M4-sync PR (#741): - TASKS.md: keep the sole-writer ledger (strict superset — every row equal-or-more-advanced than main, plus the M4-W running log). - VERIFICATION-MATRIX.md: take main's newer baseline (evolved AC-TESS-04 native-port handoff / M4-001+M4-V gate) and re-apply the AC-TESS-05 TESS-MEM-001 operator-memory reachability mapping on top. - MISSION-MANIFEST.md: M4 in-progress (auto-merged). M4 remains in-progress/gate-pending — M4-V has NOT passed (2 gate FAILs; wave-2 linchpin #740 in review). No source changes; command-authz untouched.
This commit is contained in:
@@ -0,0 +1,192 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { InMemoryDurableSessionStore } from '@mosaicstack/agent';
|
||||||
|
import {
|
||||||
|
createDiscordIngressEnvelope,
|
||||||
|
DiscordPlugin,
|
||||||
|
type DiscordIngressPayload,
|
||||||
|
} from '@mosaicstack/discord-plugin';
|
||||||
|
import { InteractionController } from '../../agent/interaction.controller.js';
|
||||||
|
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
|
||||||
|
import { TessDurableSessionService } from '../../agent/tess-durable-session.service.js';
|
||||||
|
import { ChatGateway } from '../../chat/chat.gateway.js';
|
||||||
|
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
|
||||||
|
|
||||||
|
const SERVICE_TOKEN = 'test-discord-service-token';
|
||||||
|
const envKeys = [
|
||||||
|
'DISCORD_SERVICE_TOKEN',
|
||||||
|
'DISCORD_SERVICE_TENANT_ID',
|
||||||
|
'DISCORD_INTERACTION_BINDINGS',
|
||||||
|
'DISCORD_ALLOWED_GUILD_IDS',
|
||||||
|
'DISCORD_ALLOWED_CHANNEL_IDS',
|
||||||
|
'DISCORD_ALLOWED_USER_IDS',
|
||||||
|
'MOSAIC_AGENT_NAME',
|
||||||
|
] as const;
|
||||||
|
const priorEnv = new Map<string, string | undefined>();
|
||||||
|
|
||||||
|
function payload(content: string, messageId: string, correlationId: string): DiscordIngressPayload {
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
messageId,
|
||||||
|
correlationId,
|
||||||
|
guildId: 'guild-1',
|
||||||
|
channelId: 'channel-1',
|
||||||
|
userId: 'discord-admin-1',
|
||||||
|
conversationId: 'Nova:discord:channel-1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorization(): CommandAuthorizationService {
|
||||||
|
const entries = new Map<string, string>();
|
||||||
|
return new CommandAuthorizationService(
|
||||||
|
{
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }),
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
get: async (key: string) => entries.get(key) ?? null,
|
||||||
|
set: async (key: string, value: string) => entries.set(key, value),
|
||||||
|
del: async (key: string) => Number(entries.delete(key)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Tess Discord/CLI durable-session integration', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of envKeys) {
|
||||||
|
const value = priorEnv.get(key);
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
priorEnv.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enrolls through the CLI surface then resolves the same durable session from Discord', async () => {
|
||||||
|
for (const key of envKeys) priorEnv.set(key, process.env[key]);
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
|
||||||
|
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-1';
|
||||||
|
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-1';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-1';
|
||||||
|
process.env['DISCORD_ALLOWED_USER_IDS'] = 'discord-admin-1';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
guildId: 'guild-1',
|
||||||
|
channelId: 'channel-1',
|
||||||
|
pairedUsers: {
|
||||||
|
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const durable = new TessDurableSessionService(
|
||||||
|
new InMemoryDurableSessionStore() as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
const enrollmentRuntime = {
|
||||||
|
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
|
||||||
|
};
|
||||||
|
const controller = new InteractionController(enrollmentRuntime as never, durable);
|
||||||
|
await controller.enroll(
|
||||||
|
'Nova',
|
||||||
|
'Nova:discord:channel-1',
|
||||||
|
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
{ id: 'mosaic-admin-1', tenantId: 'tenant-1' },
|
||||||
|
'cli-enrollment-correlation',
|
||||||
|
);
|
||||||
|
|
||||||
|
const authz = authorization();
|
||||||
|
const terminated = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const runtime = new RuntimeProviderService(
|
||||||
|
{
|
||||||
|
require: () => ({
|
||||||
|
capabilities: async () => ({ supported: ['session.terminate'] }),
|
||||||
|
terminate: terminated,
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
{ record: async () => undefined } as never,
|
||||||
|
{
|
||||||
|
consume: (approvalId, action) =>
|
||||||
|
authz.consumeRuntimeTerminationApproval(approvalId, action),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
authz,
|
||||||
|
runtime,
|
||||||
|
durable,
|
||||||
|
);
|
||||||
|
const client = { data: { discordService: true }, emit: vi.fn() };
|
||||||
|
const plugin = new DiscordPlugin({
|
||||||
|
token: 'unused',
|
||||||
|
gatewayUrl: 'http://unused',
|
||||||
|
serviceToken: SERVICE_TOKEN,
|
||||||
|
allowedGuildIds: ['guild-1'],
|
||||||
|
allowedChannelIds: ['channel-1'],
|
||||||
|
allowedUserIds: ['discord-admin-1'],
|
||||||
|
interactionBindings: [
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
guildId: 'guild-1',
|
||||||
|
channelId: 'channel-1',
|
||||||
|
pairedUsers: {
|
||||||
|
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const pluginInternals = plugin as unknown as {
|
||||||
|
client: { user: { id: string } };
|
||||||
|
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
|
||||||
|
handleDiscordMessage(message: unknown): void;
|
||||||
|
};
|
||||||
|
const pluginSocket = { connected: true, emit: vi.fn() };
|
||||||
|
pluginInternals.client = { user: { id: 'bot-1' } };
|
||||||
|
pluginInternals.socket = pluginSocket;
|
||||||
|
pluginInternals.handleDiscordMessage({
|
||||||
|
id: 'approve-1',
|
||||||
|
guildId: 'guild-1',
|
||||||
|
channelId: 'channel-1',
|
||||||
|
author: { id: 'discord-admin-1', bot: false },
|
||||||
|
mentions: { has: () => true },
|
||||||
|
content: '<@bot-1> /approve',
|
||||||
|
channel: { parentId: null },
|
||||||
|
attachments: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pluginSocket.emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
|
||||||
|
const approvalEnvelope = pluginSocket.emit.mock.calls[0]?.[1];
|
||||||
|
await gateway.handleDiscordApproval(client as never, approvalEnvelope);
|
||||||
|
const approval = client.emit.mock.calls.find(
|
||||||
|
([event]) => event === 'discord:approval',
|
||||||
|
)?.[1] as {
|
||||||
|
approvalId: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
expect(approval.success).toBe(true);
|
||||||
|
|
||||||
|
await gateway.handleDiscordStop(
|
||||||
|
client as never,
|
||||||
|
createDiscordIngressEnvelope(
|
||||||
|
payload(`/stop ${approval.approvalId}`, 'stop-1', 'discord-stop-correlation'),
|
||||||
|
SERVICE_TOKEN,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(terminated).toHaveBeenCalledWith(
|
||||||
|
'runtime-1',
|
||||||
|
approval.approvalId,
|
||||||
|
expect.objectContaining({ actorId: 'mosaic-admin-1' }),
|
||||||
|
);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
|
||||||
|
correlationId: 'discord-stop-correlation',
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,18 +12,24 @@ type AgentServiceInternals = {
|
|||||||
creating: Map<string, Promise<AgentSession>>;
|
creating: Map<string, Promise<AgentSession>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeService(): AgentService {
|
function makeService(operatorMemory: unknown = null): AgentService {
|
||||||
return new AgentService(
|
return new AgentService(
|
||||||
{} as never,
|
{
|
||||||
|
getDefaultModel: vi.fn(() => null),
|
||||||
|
getRegistry: vi.fn(() => ({})),
|
||||||
|
findModel: vi.fn(),
|
||||||
|
listAvailableModels: vi.fn(() => []),
|
||||||
|
} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{ available: false } as never,
|
{ available: false } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{ getToolDefinitions: vi.fn(() => []) } as never,
|
||||||
{} as never,
|
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||||
|
operatorMemory as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +126,37 @@ describe('AgentService owner/tenant scope enforcement', () => {
|
|||||||
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
|
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('derives the operator-memory scope on the createSession production path', async () => {
|
||||||
|
const plugin = { capture: vi.fn(), search: vi.fn() };
|
||||||
|
const service = makeService(plugin);
|
||||||
|
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox').mockReturnValue([]);
|
||||||
|
|
||||||
|
// Session construction reaches the real scope derivation before the intentionally incomplete
|
||||||
|
// Pi test double rejects later in createAgentSession.
|
||||||
|
await service.createSession(CONVERSATION_ID, OWNER_SCOPE).catch(() => undefined);
|
||||||
|
|
||||||
|
expect(buildTools).toHaveBeenCalledWith(expect.any(String), OWNER_SCOPE.userId, {
|
||||||
|
tenantId: OWNER_SCOPE.tenantId,
|
||||||
|
ownerId: OWNER_SCOPE.userId,
|
||||||
|
sessionId: CONVERSATION_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a foreign actor before it can obtain another session operator-memory scope', async () => {
|
||||||
|
const plugin = { capture: vi.fn(), search: vi.fn() };
|
||||||
|
const service = makeService(plugin);
|
||||||
|
internals(service).sessions.set(CONVERSATION_ID, makeSession());
|
||||||
|
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox');
|
||||||
|
|
||||||
|
await expect(service.createSession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(buildTools).not.toHaveBeenCalled();
|
||||||
|
expect(plugin.capture).not.toHaveBeenCalled();
|
||||||
|
expect(plugin.search).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
|
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
|
||||||
const service = makeService();
|
const service = makeService();
|
||||||
const session = makeSession();
|
const session = makeSession();
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
type RuntimeApprovalVerifier,
|
type RuntimeApprovalVerifier,
|
||||||
} from '../runtime-provider-registry.service.js';
|
} from '../runtime-provider-registry.service.js';
|
||||||
|
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] ??= 'test-runtime-agent';
|
||||||
|
|
||||||
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' };
|
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' };
|
||||||
const CONTEXT = {
|
const CONTEXT = {
|
||||||
actorScope: OWNER_SCOPE,
|
actorScope: OWNER_SCOPE,
|
||||||
@@ -35,6 +37,7 @@ class RecordingRuntimeProvider implements AgentRuntimeProvider {
|
|||||||
readonly sentMessages: RuntimeMessage[] = [];
|
readonly sentMessages: RuntimeMessage[] = [];
|
||||||
terminateCalls = 0;
|
terminateCalls = 0;
|
||||||
throwAfterSend = false;
|
throwAfterSend = false;
|
||||||
|
throwAuthorization = false;
|
||||||
|
|
||||||
constructor(private readonly supported: RuntimeCapability[]) {}
|
constructor(private readonly supported: RuntimeCapability[]) {}
|
||||||
|
|
||||||
@@ -74,6 +77,9 @@ class RecordingRuntimeProvider implements AgentRuntimeProvider {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.receivedScopes.push(scope);
|
this.receivedScopes.push(scope);
|
||||||
this.sentMessages.push(message);
|
this.sentMessages.push(message);
|
||||||
|
if (this.throwAuthorization) {
|
||||||
|
throw Object.assign(new Error('provider authorization denied'), { code: 'forbidden' });
|
||||||
|
}
|
||||||
if (this.throwAfterSend) {
|
if (this.throwAfterSend) {
|
||||||
throw new Error('provider acknowledgement failed');
|
throw new Error('provider acknowledgement failed');
|
||||||
}
|
}
|
||||||
@@ -243,6 +249,7 @@ describe('RuntimeProviderService security boundary', (): void => {
|
|||||||
tenantId: OWNER_SCOPE.tenantId,
|
tenantId: OWNER_SCOPE.tenantId,
|
||||||
channelId: CONTEXT.channelId,
|
channelId: CONTEXT.channelId,
|
||||||
correlationId: CONTEXT.correlationId,
|
correlationId: CONTEXT.correlationId,
|
||||||
|
agentName: process.env['MOSAIC_AGENT_NAME'],
|
||||||
});
|
});
|
||||||
expect(provider.terminateCalls).toBe(1);
|
expect(provider.terminateCalls).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -292,6 +299,23 @@ describe('RuntimeProviderService security boundary', (): void => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records a provider authorization rejection as denied rather than provider failure', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||||
|
provider.throwAuthorization = true;
|
||||||
|
const audit = new RecordingAuditSink();
|
||||||
|
const service = makeService(provider, audit);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/provider authorization denied/);
|
||||||
|
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
|
||||||
|
});
|
||||||
|
|
||||||
it('persists only metadata-only runtime audit fields', async (): Promise<void> => {
|
it('persists only metadata-only runtime audit fields', async (): Promise<void> => {
|
||||||
let persisted: unknown;
|
let persisted: unknown;
|
||||||
const ingest = async (entry: unknown): Promise<unknown> => {
|
const ingest = async (entry: unknown): Promise<unknown> => {
|
||||||
|
|||||||
@@ -9,15 +9,19 @@ import { SkillLoaderService } from './skill-loader.service.js';
|
|||||||
import { ProvidersController } from './providers.controller.js';
|
import { ProvidersController } from './providers.controller.js';
|
||||||
import { SessionsController } from './sessions.controller.js';
|
import { SessionsController } from './sessions.controller.js';
|
||||||
import { AgentConfigsController } from './agent-configs.controller.js';
|
import { AgentConfigsController } from './agent-configs.controller.js';
|
||||||
|
import { InteractionController } from './interaction.controller.js';
|
||||||
import { RoutingController } from './routing/routing.controller.js';
|
import { RoutingController } from './routing/routing.controller.js';
|
||||||
|
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
||||||
|
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
||||||
import { CoordModule } from '../coord/coord.module.js';
|
import { CoordModule } from '../coord/coord.module.js';
|
||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
import { SkillsModule } from '../skills/skills.module.js';
|
import { SkillsModule } from '../skills/skills.module.js';
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
import { LogModule } from '../log/log.module.js';
|
import { LogModule } from '../log/log.module.js';
|
||||||
|
import { CommandsModule } from '../commands/commands.module.js';
|
||||||
|
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
||||||
import {
|
import {
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
DenyRuntimeApprovalVerifier,
|
|
||||||
RUNTIME_APPROVAL_VERIFIER,
|
RUNTIME_APPROVAL_VERIFIER,
|
||||||
RUNTIME_PROVIDER_AUDIT_SINK,
|
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
RuntimeProviderAuditService,
|
RuntimeProviderAuditService,
|
||||||
@@ -26,13 +30,15 @@ import {
|
|||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule],
|
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
|
||||||
providers: [
|
providers: [
|
||||||
ProviderService,
|
ProviderService,
|
||||||
ProviderCredentialsService,
|
ProviderCredentialsService,
|
||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
|
TessDurableSessionRepository,
|
||||||
|
TessDurableSessionService,
|
||||||
{
|
{
|
||||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(),
|
useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(),
|
||||||
@@ -42,15 +48,20 @@ import {
|
|||||||
provide: RUNTIME_PROVIDER_AUDIT_SINK,
|
provide: RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
useExisting: RuntimeProviderAuditService,
|
useExisting: RuntimeProviderAuditService,
|
||||||
},
|
},
|
||||||
DenyRuntimeApprovalVerifier,
|
|
||||||
{
|
{
|
||||||
provide: RUNTIME_APPROVAL_VERIFIER,
|
provide: RUNTIME_APPROVAL_VERIFIER,
|
||||||
useExisting: DenyRuntimeApprovalVerifier,
|
useExisting: CommandRuntimeApprovalVerifier,
|
||||||
},
|
},
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
AgentService,
|
AgentService,
|
||||||
],
|
],
|
||||||
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
|
controllers: [
|
||||||
|
ProvidersController,
|
||||||
|
SessionsController,
|
||||||
|
AgentConfigsController,
|
||||||
|
InteractionController,
|
||||||
|
RoutingController,
|
||||||
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AgentService,
|
AgentService,
|
||||||
ProviderService,
|
ProviderService,
|
||||||
@@ -58,6 +69,7 @@ import {
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
|
TessDurableSessionService,
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import {
|
|||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
} from '@mariozechner/pi-coding-agent';
|
} from '@mariozechner/pi-coding-agent';
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaicstack/brain';
|
||||||
import type { Memory } from '@mosaicstack/memory';
|
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { MEMORY } from '../memory/memory.tokens.js';
|
import { MEMORY } from '../memory/memory.tokens.js';
|
||||||
|
import { OPERATOR_MEMORY_PLUGIN } from '../memory/memory.module.js';
|
||||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||||
import { CoordService } from '../coord/coord.service.js';
|
import { CoordService } from '../coord/coord.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
@@ -135,6 +136,9 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
@Inject(PreferencesService)
|
@Inject(PreferencesService)
|
||||||
private readonly preferencesService: PreferencesService | null,
|
private readonly preferencesService: PreferencesService | null,
|
||||||
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(OPERATOR_MEMORY_PLUGIN)
|
||||||
|
private readonly operatorMemory: OperatorMemoryPlugin | null = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,6 +150,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
private buildToolsForSandbox(
|
private buildToolsForSandbox(
|
||||||
sandboxDir: string,
|
sandboxDir: string,
|
||||||
sessionUserId: string | undefined,
|
sessionUserId: string | undefined,
|
||||||
|
sessionScope?: { tenantId: string; ownerId: string; sessionId: string },
|
||||||
): ToolDefinition[] {
|
): ToolDefinition[] {
|
||||||
return [
|
return [
|
||||||
...createBrainTools(this.brain),
|
...createBrainTools(this.brain),
|
||||||
@@ -154,6 +159,9 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
this.memory,
|
this.memory,
|
||||||
this.embeddingService.available ? this.embeddingService : null,
|
this.embeddingService.available ? this.embeddingService : null,
|
||||||
sessionUserId,
|
sessionUserId,
|
||||||
|
this.operatorMemory && sessionScope
|
||||||
|
? { plugin: this.operatorMemory, scope: sessionScope }
|
||||||
|
: undefined,
|
||||||
),
|
),
|
||||||
...createFileTools(sandboxDir),
|
...createFileTools(sandboxDir),
|
||||||
...createGitTools(sandboxDir),
|
...createGitTools(sandboxDir),
|
||||||
@@ -228,6 +236,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
isAdmin: options.isAdmin,
|
isAdmin: options.isAdmin,
|
||||||
agentConfigId: options.agentConfigId,
|
agentConfigId: options.agentConfigId,
|
||||||
userId: options.userId,
|
userId: options.userId,
|
||||||
|
tenantId: options.tenantId,
|
||||||
conversationHistory: options.conversationHistory,
|
conversationHistory: options.conversationHistory,
|
||||||
};
|
};
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -267,7 +276,15 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build per-session tools scoped to the sandbox directory and authenticated user
|
// Build per-session tools scoped to the sandbox directory and authenticated user
|
||||||
const sandboxTools = this.buildToolsForSandbox(sandboxDir, mergedOptions?.userId);
|
const sessionUserId = mergedOptions?.userId;
|
||||||
|
const sessionTenantId = this.tenantIdFor(sessionUserId, mergedOptions?.tenantId);
|
||||||
|
const sandboxTools = this.buildToolsForSandbox(
|
||||||
|
sandboxDir,
|
||||||
|
sessionUserId,
|
||||||
|
sessionUserId && sessionTenantId
|
||||||
|
? { tenantId: sessionTenantId, ownerId: sessionUserId, sessionId }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
// Combine static tools with dynamically discovered MCP client tools and skill tools
|
// Combine static tools with dynamically discovered MCP client tools and skill tools
|
||||||
const mcpTools = this.mcpClientService.getToolDefinitions();
|
const mcpTools = this.mcpClientService.getToolDefinitions();
|
||||||
@@ -362,7 +379,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sandboxDir,
|
sandboxDir,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
userId: mergedOptions?.userId,
|
userId: mergedOptions?.userId,
|
||||||
tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId),
|
tenantId: sessionTenantId,
|
||||||
agentConfigId: mergedOptions?.agentConfigId,
|
agentConfigId: mergedOptions?.agentConfigId,
|
||||||
agentName: resolvedAgentName,
|
agentName: resolvedAgentName,
|
||||||
metrics: {
|
metrics: {
|
||||||
|
|||||||
233
apps/gateway/src/agent/interaction.controller.test.ts
Normal file
233
apps/gateway/src/agent/interaction.controller.test.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js';
|
||||||
|
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
||||||
|
import { InteractionController } from './interaction.controller.js';
|
||||||
|
|
||||||
|
describe('InteractionController', (): void => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it('maps a denied runtime approval to Fastify HTTP 403', () => {
|
||||||
|
const send = vi.fn();
|
||||||
|
const status = vi.fn().mockReturnValue({ send });
|
||||||
|
const response = { status };
|
||||||
|
const host = { switchToHttp: () => ({ getResponse: () => response }) };
|
||||||
|
|
||||||
|
new RuntimeApprovalDeniedFilter().catch(new RuntimeApprovalDeniedError(), host as never);
|
||||||
|
|
||||||
|
expect(status).toHaveBeenCalledWith(403);
|
||||||
|
expect(send).toHaveBeenCalledWith({
|
||||||
|
statusCode: 403,
|
||||||
|
message: 'Runtime termination approval denied',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a differently named configured instance without a code change', async () => {
|
||||||
|
const prior = process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const runtime = { listSessions: vi.fn().mockResolvedValue([]) };
|
||||||
|
const controller = new InteractionController(runtime as never, {} as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.sessions('Nova', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
await expect(
|
||||||
|
controller.sessions('Other', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
|
||||||
|
).rejects.toThrow('Interaction agent is not configured');
|
||||||
|
|
||||||
|
if (prior === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
else process.env['MOSAIC_AGENT_NAME'] = prior;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a request without the non-simple correlation header', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
|
||||||
|
|
||||||
|
await expect(controller.sessions('Nova', 'fleet', { id: 'owner' })).rejects.toThrow(
|
||||||
|
'X-Correlation-Id is required',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enrolls a visible runtime session under the cross-surface conversation handle', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const runtime = {
|
||||||
|
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
|
||||||
|
};
|
||||||
|
const durable = { enroll: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const controller = new InteractionController(runtime as never, durable as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.enroll(
|
||||||
|
'Nova',
|
||||||
|
'conversation-1',
|
||||||
|
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
{ id: 'owner', tenantId: 'team' },
|
||||||
|
'corr-1',
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ status: 'enrolled', sessionId: 'conversation-1' });
|
||||||
|
expect(durable.enroll).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
agentName: 'Nova',
|
||||||
|
sessionId: 'conversation-1',
|
||||||
|
tenantId: 'team',
|
||||||
|
ownerId: 'owner',
|
||||||
|
providerId: 'fleet',
|
||||||
|
runtimeSessionId: 'runtime-1',
|
||||||
|
},
|
||||||
|
expect.objectContaining({ correlationId: 'corr-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid attach mode before invoking a provider', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const controller = new InteractionController({ attach: vi.fn() } as never, {} as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.attach('Nova', 'durable-1', { mode: 'write' as never }, { id: 'owner' }, 'corr-1'),
|
||||||
|
).rejects.toThrow('Interaction attach mode is invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes a durable session by attaching and streaming its runtime events', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const runtimeEvent = {
|
||||||
|
type: 'message.delta' as const,
|
||||||
|
sessionId: 'runtime-1',
|
||||||
|
cursor: 'cursor-1',
|
||||||
|
occurredAt: '2026-07-13T00:00:00.000Z',
|
||||||
|
content: 'resumed',
|
||||||
|
};
|
||||||
|
const runtime = {
|
||||||
|
attach: vi.fn().mockResolvedValue({ attachmentId: 'attach-1', sessionId: 'runtime-1' }),
|
||||||
|
streamSession: vi.fn(async function* () {
|
||||||
|
yield runtimeEvent;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const durable = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const controller = new InteractionController(runtime as never, durable as never);
|
||||||
|
|
||||||
|
await controller.attach('Nova', 'conversation-1', { mode: 'read' }, { id: 'owner' }, 'corr-1');
|
||||||
|
await expect(
|
||||||
|
firstValueFrom(
|
||||||
|
controller.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1'),
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ data: runtimeEvent });
|
||||||
|
|
||||||
|
expect(runtime.attach).toHaveBeenCalledWith(
|
||||||
|
'fleet',
|
||||||
|
'runtime-1',
|
||||||
|
'read',
|
||||||
|
expect.objectContaining({ correlationId: 'corr-1' }),
|
||||||
|
);
|
||||||
|
expect(runtime.streamSession).toHaveBeenCalledWith(
|
||||||
|
'fleet',
|
||||||
|
'runtime-1',
|
||||||
|
undefined,
|
||||||
|
expect.objectContaining({ correlationId: 'corr-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not create a runtime stream after the SSE subscriber disconnects during snapshot lookup', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
let resolveSnapshot!: (value: { identity: Record<string, string> }) => void;
|
||||||
|
const snapshot = new Promise<{ identity: Record<string, string> }>((resolve) => {
|
||||||
|
resolveSnapshot = resolve;
|
||||||
|
});
|
||||||
|
const runtime = { streamSession: vi.fn() };
|
||||||
|
const durable = { getSnapshot: vi.fn().mockReturnValue(snapshot) };
|
||||||
|
const controller = new InteractionController(runtime as never, durable as never);
|
||||||
|
|
||||||
|
const subscription = controller
|
||||||
|
.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1')
|
||||||
|
.subscribe();
|
||||||
|
subscription.unsubscribe();
|
||||||
|
resolveSnapshot({
|
||||||
|
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(runtime.streamSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['wrong actor', { getSnapshot: vi.fn().mockRejectedValue(new Error('scope mismatch')) }],
|
||||||
|
[
|
||||||
|
'session-agent mismatch',
|
||||||
|
{
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])('denies a CLI stop for %s', async (_reason, durable) => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const controller = new InteractionController(runtime as never, durable as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.stop(
|
||||||
|
'Nova',
|
||||||
|
'durable-1',
|
||||||
|
{ approvalRef: 'approval-1' },
|
||||||
|
{ id: 'owner' },
|
||||||
|
'corr-1',
|
||||||
|
),
|
||||||
|
).rejects.toBeDefined();
|
||||||
|
expect(runtime.terminate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a denied runtime approval to the CLI interaction surface', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const runtime = {
|
||||||
|
terminate: vi.fn().mockRejectedValue(new Error('Runtime termination approval denied')),
|
||||||
|
};
|
||||||
|
const durable = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const controller = new InteractionController(runtime as never, durable as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.stop(
|
||||||
|
'Nova',
|
||||||
|
'durable-1',
|
||||||
|
{ approvalRef: 'approval-1' },
|
||||||
|
{ id: 'owner' },
|
||||||
|
'corr-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Runtime termination approval denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the durable session identity and runtime registry for an approved stop', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const durable = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const controller = new InteractionController(runtime as never, durable as never);
|
||||||
|
|
||||||
|
await controller.stop(
|
||||||
|
'Nova',
|
||||||
|
'durable-1',
|
||||||
|
{ approvalRef: 'approval-1' },
|
||||||
|
{ id: 'owner' },
|
||||||
|
'corr-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(runtime.terminate).toHaveBeenCalledWith(
|
||||||
|
'fleet',
|
||||||
|
'runtime-1',
|
||||||
|
'approval-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
actorScope: { userId: 'owner', tenantId: 'owner' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
279
apps/gateway/src/agent/interaction.controller.ts
Normal file
279
apps/gateway/src/agent/interaction.controller.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Sse,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
UseFilters,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { RuntimeAttachMode, RuntimeStreamEvent } from '@mosaicstack/types';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
|
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
||||||
|
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
||||||
|
import {
|
||||||
|
RuntimeProviderService,
|
||||||
|
type RuntimeProviderRequestContext,
|
||||||
|
} from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated HTTP boundary for operator interaction clients. Identity is
|
||||||
|
* selected from deployment configuration, never a client-side command name.
|
||||||
|
*/
|
||||||
|
@Controller('api/interaction/:agentName')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@UseFilters(RuntimeApprovalDeniedFilter)
|
||||||
|
export class InteractionController {
|
||||||
|
constructor(
|
||||||
|
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
|
||||||
|
@Inject(TessDurableSessionService) private readonly durable: TessDurableSessionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('sessions')
|
||||||
|
async sessions(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Query('provider') providerId: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
) {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
return this.runtime.listSessions(
|
||||||
|
this.requiredProvider(providerId),
|
||||||
|
this.context(user, correlationId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tree')
|
||||||
|
async tree(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Query('provider') providerId: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
) {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
return this.runtime.getSessionTree(
|
||||||
|
this.requiredProvider(providerId),
|
||||||
|
this.context(user, correlationId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind an existing, authorized runtime session to the stable conversation ID.
|
||||||
|
* This is the lifecycle boundary where both runtime identifiers are known.
|
||||||
|
*/
|
||||||
|
@Post('sessions/:sessionId/enroll')
|
||||||
|
async enroll(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Body() body: { providerId?: string; runtimeSessionId?: string } = {},
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
) {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
const providerId = this.requiredProvider(body.providerId ?? '');
|
||||||
|
const runtimeSessionId = body.runtimeSessionId?.trim();
|
||||||
|
if (!runtimeSessionId) throw new ForbiddenException('Runtime session identity is required');
|
||||||
|
const context = this.context(user, correlationId);
|
||||||
|
const sessions = await this.runtime.listSessions(providerId, context);
|
||||||
|
if (!sessions.some((session): boolean => session.id === runtimeSessionId)) {
|
||||||
|
throw new ForbiddenException('Runtime session is not visible to this actor');
|
||||||
|
}
|
||||||
|
await this.durable.enroll(
|
||||||
|
{
|
||||||
|
agentName,
|
||||||
|
sessionId,
|
||||||
|
tenantId: context.actorScope.tenantId,
|
||||||
|
ownerId: context.actorScope.userId,
|
||||||
|
providerId,
|
||||||
|
runtimeSessionId,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
return { status: 'enrolled', sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sessions/:sessionId/attach')
|
||||||
|
async attach(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Body() body: { mode?: RuntimeAttachMode } = {},
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
) {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
const context = this.context(user, correlationId);
|
||||||
|
const mode = body.mode ?? 'read';
|
||||||
|
if (mode !== 'read' && mode !== 'control') {
|
||||||
|
throw new ForbiddenException('Interaction attach mode is invalid');
|
||||||
|
}
|
||||||
|
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||||
|
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||||
|
return this.runtime.attach(
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
mode,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Sse('sessions/:sessionId/stream')
|
||||||
|
stream(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Query('cursor') cursor: string | undefined,
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
): Observable<{ data: RuntimeStreamEvent }> {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
const context = this.context(user, correlationId);
|
||||||
|
return new Observable((subscriber) => {
|
||||||
|
let iterator: AsyncIterator<RuntimeStreamEvent> | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||||
|
if (cancelled || subscriber.closed) return;
|
||||||
|
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||||
|
iterator = this.runtime
|
||||||
|
.streamSession(
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
cursor?.trim() || undefined,
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
[Symbol.asyncIterator]();
|
||||||
|
if (cancelled || subscriber.closed) {
|
||||||
|
await iterator.return?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (!cancelled && !subscriber.closed) {
|
||||||
|
const next = await iterator.next();
|
||||||
|
if (next.done || cancelled || subscriber.closed) break;
|
||||||
|
subscriber.next({ data: next.value });
|
||||||
|
}
|
||||||
|
if (!subscriber.closed) subscriber.complete();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!subscriber.closed) subscriber.error(error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return (): void => {
|
||||||
|
cancelled = true;
|
||||||
|
void iterator?.return?.();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sessions/:sessionId/send')
|
||||||
|
async send(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Body() body: { content?: string; idempotencyKey?: string } = {},
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
) {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
if (!body.content?.trim() || !body.idempotencyKey?.trim()) {
|
||||||
|
throw new ForbiddenException('Content and idempotency key are required');
|
||||||
|
}
|
||||||
|
const context = this.context(user, correlationId);
|
||||||
|
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||||
|
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||||
|
const input = {
|
||||||
|
sessionId,
|
||||||
|
content: body.content,
|
||||||
|
idempotencyKey: body.idempotencyKey,
|
||||||
|
correlationId: context.correlationId,
|
||||||
|
context,
|
||||||
|
};
|
||||||
|
await this.durable.queueProviderSend(input);
|
||||||
|
await this.durable.dispatchProviderOutbox(sessionId, input);
|
||||||
|
return { status: 'queued', sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sessions/:sessionId/stop')
|
||||||
|
async stop(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Body() body: { approvalRef?: string } = {},
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
) {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
if (!body.approvalRef?.trim())
|
||||||
|
throw new ForbiddenException('Exact-action approval is required');
|
||||||
|
const context = this.context(user, correlationId);
|
||||||
|
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||||
|
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||||
|
await this.runtime.terminate(
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
body.approvalRef,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
return { status: 'stopped', sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sessions/:sessionId/recover')
|
||||||
|
async recover(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
) {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
const context = this.context(user, correlationId);
|
||||||
|
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||||
|
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||||
|
await this.durable.recoverProviderSession(sessionId, {
|
||||||
|
sessionId,
|
||||||
|
content: '',
|
||||||
|
idempotencyKey: `recovery:${context.correlationId}`,
|
||||||
|
correlationId: context.correlationId,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
return { status: 'recovered', sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private context(
|
||||||
|
user: AuthenticatedUserLike,
|
||||||
|
correlationId?: string,
|
||||||
|
): RuntimeProviderRequestContext {
|
||||||
|
const requestCorrelationId = correlationId?.trim();
|
||||||
|
// This non-simple request header is mandatory for mutations. Browser
|
||||||
|
// cross-origin requests cannot set it without a CORS preflight, and the
|
||||||
|
// gateway's allowlist rejects untrusted origins before the handler runs.
|
||||||
|
if (!requestCorrelationId) {
|
||||||
|
throw new ForbiddenException('X-Correlation-Id is required');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
actorScope: scopeFromUser(user),
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: requestCorrelationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertConfiguredAgent(agentName: string): void {
|
||||||
|
const configured = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||||
|
if (!configured || configured !== agentName) {
|
||||||
|
throw new ForbiddenException('Interaction agent is not configured for this request');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSessionAgent(sessionAgentName: string, agentName: string): void {
|
||||||
|
if (sessionAgentName !== agentName)
|
||||||
|
throw new ForbiddenException('Interaction session identity mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
private requiredProvider(providerId: string): string {
|
||||||
|
if (!providerId?.trim()) throw new ForbiddenException('Runtime provider is required');
|
||||||
|
return providerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/gateway/src/agent/runtime-approval-denied.filter.ts
Normal file
13
apps/gateway/src/agent/runtime-approval-denied.filter.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Catch, type ArgumentsHost, type ExceptionFilter } from '@nestjs/common';
|
||||||
|
import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
|
/** Maps a consumed/missing runtime approval to a stable HTTP authorization response. */
|
||||||
|
@Catch(RuntimeApprovalDeniedError)
|
||||||
|
export class RuntimeApprovalDeniedFilter implements ExceptionFilter {
|
||||||
|
catch(_exception: RuntimeApprovalDeniedError, host: ArgumentsHost): void {
|
||||||
|
const response = host.switchToHttp().getResponse<{
|
||||||
|
status(code: number): { send(body: { statusCode: number; message: string }): void };
|
||||||
|
}>();
|
||||||
|
response.status(403).send({ statusCode: 403, message: 'Runtime termination approval denied' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,13 +64,20 @@ export interface RuntimeTerminationAction {
|
|||||||
tenantId: string;
|
tenantId: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
correlationId: string;
|
correlationId: string;
|
||||||
|
agentName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuntimeApprovalVerifier {
|
export interface RuntimeApprovalVerifier {
|
||||||
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
|
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
class RuntimeApprovalDeniedError extends Error {
|
function configuredAgentName(): string {
|
||||||
|
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||||
|
if (!agentName) throw new RuntimeApprovalDeniedError();
|
||||||
|
return agentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RuntimeApprovalDeniedError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('Runtime termination approval denied');
|
super('Runtime termination approval denied');
|
||||||
}
|
}
|
||||||
@@ -261,6 +268,7 @@ export class RuntimeProviderService {
|
|||||||
tenantId: scope.tenantId,
|
tenantId: scope.tenantId,
|
||||||
channelId: scope.channelId,
|
channelId: scope.channelId,
|
||||||
correlationId: scope.correlationId,
|
correlationId: scope.correlationId,
|
||||||
|
agentName: configuredAgentName(),
|
||||||
});
|
});
|
||||||
if (!approved) {
|
if (!approved) {
|
||||||
throw new RuntimeApprovalDeniedError();
|
throw new RuntimeApprovalDeniedError();
|
||||||
@@ -293,7 +301,7 @@ export class RuntimeProviderService {
|
|||||||
return result;
|
return result;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const durationMs = Date.now() - startedAt;
|
const durationMs = Date.now() - startedAt;
|
||||||
if (invocationStarted && !(error instanceof RuntimeApprovalDeniedError)) {
|
if (invocationStarted && !this.isAuthorizationDenied(error)) {
|
||||||
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
|
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
|
||||||
} else {
|
} else {
|
||||||
await this.record(
|
await this.record(
|
||||||
@@ -352,6 +360,17 @@ export class RuntimeProviderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isAuthorizationDenied(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof RuntimeApprovalDeniedError ||
|
||||||
|
error instanceof ForbiddenException ||
|
||||||
|
(typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code?: unknown }).code === 'forbidden')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private provider(providerId: string): AgentRuntimeProvider {
|
private provider(providerId: string): AgentRuntimeProvider {
|
||||||
try {
|
try {
|
||||||
return this.registry.require(providerId);
|
return this.registry.require(providerId);
|
||||||
|
|||||||
10
apps/gateway/src/agent/tess-durable-session.dto.ts
Normal file
10
apps/gateway/src/agent/tess-durable-session.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
|
/** Server-side request for a replay-safe provider message. */
|
||||||
|
export interface TessProviderOutboxDto {
|
||||||
|
sessionId: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
correlationId: string;
|
||||||
|
content: string;
|
||||||
|
context: RuntimeProviderRequestContext;
|
||||||
|
}
|
||||||
418
apps/gateway/src/agent/tess-durable-session.repository.test.ts
Normal file
418
apps/gateway/src/agent/tess-durable-session.repository.test.ts
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/db';
|
||||||
|
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
|
||||||
|
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
||||||
|
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
||||||
|
|
||||||
|
const IDENTITY: DurableSessionIdentity = {
|
||||||
|
agentName: 'Nova',
|
||||||
|
sessionId: 'tess-pglite-session',
|
||||||
|
tenantId: 'tenant-pglite',
|
||||||
|
ownerId: 'tess-owner',
|
||||||
|
providerId: 'fleet',
|
||||||
|
runtimeSessionId: 'nova',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TessDurableSessionRepository', () => {
|
||||||
|
let dataDir: string | undefined;
|
||||||
|
let handle: DbHandle;
|
||||||
|
let previousAuthSecret: string | undefined;
|
||||||
|
|
||||||
|
beforeAll(async (): Promise<void> => {
|
||||||
|
previousAuthSecret = process.env['BETTER_AUTH_SECRET'];
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = 'tess-durable-state-test-sealing-key';
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'tess-durable-state-'));
|
||||||
|
handle = createPgliteDb(dataDir);
|
||||||
|
await runPgliteMigrations(handle);
|
||||||
|
await seedOwner(handle);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
beforeEach(async (): Promise<void> => {
|
||||||
|
await handle.db.execute(sql`DELETE FROM interaction_handoffs`);
|
||||||
|
await handle.db.execute(sql`DELETE FROM interaction_checkpoints`);
|
||||||
|
await handle.db.execute(sql`DELETE FROM interaction_inbox`);
|
||||||
|
await handle.db.execute(sql`DELETE FROM interaction_outbox`);
|
||||||
|
await handle.db.execute(sql`DELETE FROM interaction_sessions`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async (): Promise<void> => {
|
||||||
|
await handle.close();
|
||||||
|
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET'];
|
||||||
|
else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
|
||||||
|
const beforeRestart = new DurableSessionCoordinator(
|
||||||
|
new TessDurableSessionRepository(handle.db),
|
||||||
|
);
|
||||||
|
await beforeRestart.create(IDENTITY);
|
||||||
|
await beforeRestart.receive({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'inbox-before-kill',
|
||||||
|
correlationId: 'correlation-before-kill',
|
||||||
|
content: 'resume after a kill',
|
||||||
|
});
|
||||||
|
await beforeRestart.enqueueOutbox({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'outbox-before-kill',
|
||||||
|
correlationId: 'correlation-before-kill',
|
||||||
|
channelId: 'cli',
|
||||||
|
kind: 'provider.send',
|
||||||
|
content: 'one response only',
|
||||||
|
});
|
||||||
|
await beforeRestart.checkpoint({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
checkpointId: 'checkpoint-before-kill',
|
||||||
|
cursor: 'cursor-before-kill',
|
||||||
|
summary: 'restart-safe state',
|
||||||
|
compactionEpoch: 1,
|
||||||
|
});
|
||||||
|
await beforeRestart.handoff({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
handoffId: 'handoff-before-kill',
|
||||||
|
destination: 'mos',
|
||||||
|
correlationId: 'correlation-before-kill',
|
||||||
|
checkpointId: 'checkpoint-before-kill',
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
await beforeRestart.checkpoint({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
checkpointId: 'checkpoint-after-handoff',
|
||||||
|
cursor: 'cursor-after-handoff',
|
||||||
|
summary: 'newer state cannot strand the portable handoff',
|
||||||
|
compactionEpoch: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handle.close();
|
||||||
|
handle = createPgliteDb(dataDir!);
|
||||||
|
|
||||||
|
const afterRestart = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||||
|
const recovered = await afterRestart.recover(IDENTITY.sessionId);
|
||||||
|
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
|
||||||
|
const handled: string[] = [];
|
||||||
|
const effects: string[] = [];
|
||||||
|
|
||||||
|
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||||
|
handled.push(entry.idempotencyKey);
|
||||||
|
});
|
||||||
|
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||||
|
effects.push(entry.idempotencyKey);
|
||||||
|
});
|
||||||
|
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||||
|
handled.push(entry.idempotencyKey);
|
||||||
|
});
|
||||||
|
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||||
|
effects.push(entry.idempotencyKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recovered.identity).toEqual(IDENTITY);
|
||||||
|
expect(recovered.checkpoint).toMatchObject({ checkpointId: 'checkpoint-after-handoff' });
|
||||||
|
expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-before-kill' }]);
|
||||||
|
expect(resumedHandoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-before-kill' });
|
||||||
|
expect(handled).toEqual(['inbox-before-kill']);
|
||||||
|
expect(effects).toEqual(['outbox-before-kill']);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('redacts sensitive durable payloads before persistence', async () => {
|
||||||
|
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
await coordinator.receive({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'redacted-inbox',
|
||||||
|
correlationId: 'correlation-redaction',
|
||||||
|
content: 'api_key=super-secret-canary',
|
||||||
|
});
|
||||||
|
await coordinator.enqueueOutbox({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'redacted-outbox',
|
||||||
|
correlationId: 'correlation-redaction',
|
||||||
|
channelId: 'cli',
|
||||||
|
kind: 'provider.send',
|
||||||
|
content: 'email operator@example.test api_key=super-secret-canary',
|
||||||
|
});
|
||||||
|
await coordinator.checkpoint({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
checkpointId: 'redacted-checkpoint',
|
||||||
|
cursor: 'bearer super-secret-canary',
|
||||||
|
summary: 'email operator@example.test',
|
||||||
|
compactionEpoch: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await coordinator.snapshot(IDENTITY.sessionId);
|
||||||
|
const [persisted] = await handle.db
|
||||||
|
.select({ content: interactionInbox.content })
|
||||||
|
.from(interactionInbox)
|
||||||
|
.where(eq(interactionInbox.idempotencyKey, 'redacted-inbox'));
|
||||||
|
|
||||||
|
expect(JSON.stringify(snapshot)).not.toContain('super-secret-canary');
|
||||||
|
expect(JSON.stringify(snapshot)).not.toContain('operator@example.test');
|
||||||
|
expect(persisted?.content).not.toContain('super-secret-canary');
|
||||||
|
expect(persisted?.content).not.toContain('[REDACTED]');
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('fails closed when the configured idempotency secret is unavailable', async () => {
|
||||||
|
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||||
|
delete process.env['BETTER_AUTH_SECRET'];
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
coordinator.receive({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'requires-idempotency-secret',
|
||||||
|
correlationId: 'correlation-secret',
|
||||||
|
content: 'sensitive payload',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/required for durable idempotency digests/);
|
||||||
|
} finally {
|
||||||
|
if (secret === undefined) delete process.env['BETTER_AUTH_SECRET'];
|
||||||
|
else process.env['BETTER_AUTH_SECRET'] = secret;
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
|
||||||
|
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
const input = {
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
checkpointId: 'checkpoint-secret-conflict',
|
||||||
|
cursor: 'api_key=secret-one',
|
||||||
|
summary: 'bearer secret-one',
|
||||||
|
compactionEpoch: 1,
|
||||||
|
};
|
||||||
|
await coordinator.checkpoint(input);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordinator.checkpoint({
|
||||||
|
...input,
|
||||||
|
cursor: 'api_key=secret-two',
|
||||||
|
summary: 'bearer secret-two',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/checkpoint identity conflict/);
|
||||||
|
|
||||||
|
const [persisted] = await handle.db
|
||||||
|
.select({
|
||||||
|
digest: interactionCheckpoints.contentDigest,
|
||||||
|
cursor: interactionCheckpoints.cursor,
|
||||||
|
})
|
||||||
|
.from(interactionCheckpoints)
|
||||||
|
.where(eq(interactionCheckpoints.checkpointId, input.checkpointId));
|
||||||
|
expect(persisted?.cursor).not.toContain('secret-one');
|
||||||
|
expect(persisted?.digest).not.toBe(
|
||||||
|
createHash('sha256')
|
||||||
|
.update(JSON.stringify([input.cursor, input.summary]))
|
||||||
|
.digest('hex'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await coordinator.checkpoint({
|
||||||
|
...input,
|
||||||
|
checkpointId: 'checkpoint-delimiter-conflict',
|
||||||
|
cursor: 'a\u0000b',
|
||||||
|
summary: 'c',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
coordinator.checkpoint({
|
||||||
|
...input,
|
||||||
|
checkpointId: 'checkpoint-delimiter-conflict',
|
||||||
|
cursor: 'a',
|
||||||
|
summary: 'b\u0000c',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/checkpoint identity conflict/);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
|
||||||
|
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
const inbox = {
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'inbox-secret-conflict',
|
||||||
|
correlationId: 'correlation-inbox-secret',
|
||||||
|
content: 'api_key=secret-one',
|
||||||
|
};
|
||||||
|
const outbox = {
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'outbox-secret-conflict',
|
||||||
|
correlationId: 'correlation-outbox-secret',
|
||||||
|
channelId: 'cli',
|
||||||
|
kind: 'provider.send',
|
||||||
|
content: 'api_key=secret-one',
|
||||||
|
};
|
||||||
|
await coordinator.receive(inbox);
|
||||||
|
await coordinator.enqueueOutbox(outbox);
|
||||||
|
|
||||||
|
await expect(coordinator.receive({ ...inbox, content: 'api_key=secret-two' })).rejects.toThrow(
|
||||||
|
/idempotency conflict/,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
coordinator.enqueueOutbox({ ...outbox, content: 'api_key=secret-two' }),
|
||||||
|
).rejects.toThrow(/idempotency conflict/);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
|
||||||
|
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
await coordinator.receive({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'inbox-conflict',
|
||||||
|
correlationId: 'correlation-inbox',
|
||||||
|
content: 'original inbox',
|
||||||
|
});
|
||||||
|
await coordinator.enqueueOutbox({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'outbox-conflict',
|
||||||
|
correlationId: 'correlation-outbox',
|
||||||
|
channelId: 'cli',
|
||||||
|
kind: 'provider.send',
|
||||||
|
content: 'original outbox',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordinator.receive({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'inbox-conflict',
|
||||||
|
correlationId: 'forged-correlation',
|
||||||
|
content: 'original inbox',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/idempotency conflict/);
|
||||||
|
await expect(
|
||||||
|
coordinator.enqueueOutbox({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'outbox-conflict',
|
||||||
|
correlationId: 'correlation-outbox',
|
||||||
|
channelId: 'forged-channel',
|
||||||
|
kind: 'provider.send',
|
||||||
|
content: 'original outbox',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/idempotency conflict/);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
|
||||||
|
const repository = new TessDurableSessionRepository(handle.db);
|
||||||
|
const coordinator = new DurableSessionCoordinator(repository);
|
||||||
|
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
||||||
|
const input = {
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'live-effect',
|
||||||
|
correlationId: 'correlation-live',
|
||||||
|
content: 'must not duplicate',
|
||||||
|
context: {
|
||||||
|
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'correlation-live',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
await service.queueProviderSend(input);
|
||||||
|
expect(await repository.claimOutbox(IDENTITY.sessionId)).toMatchObject({
|
||||||
|
status: 'processing',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.dispatchProviderOutbox(IDENTITY.sessionId, input);
|
||||||
|
await expect(
|
||||||
|
service.recoverProviderSession(IDENTITY.sessionId, {
|
||||||
|
...input,
|
||||||
|
context: {
|
||||||
|
...input.context,
|
||||||
|
actorScope: { userId: 'intruder', tenantId: 'tenant-pglite' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/scope or correlation mismatch/);
|
||||||
|
|
||||||
|
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
|
||||||
|
outbox: [{ idempotencyKey: 'live-effect', status: 'processing' }],
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
|
||||||
|
const repository = new TessDurableSessionRepository(handle.db);
|
||||||
|
const coordinator = new DurableSessionCoordinator(repository);
|
||||||
|
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
||||||
|
const input = {
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'mismatch-effect',
|
||||||
|
correlationId: 'correlation-expected',
|
||||||
|
content: 'must remain pending',
|
||||||
|
context: {
|
||||||
|
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'correlation-expected',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
await service.queueProviderSend(input);
|
||||||
|
await expect(
|
||||||
|
service.dispatchProviderOutbox(IDENTITY.sessionId, {
|
||||||
|
...input,
|
||||||
|
correlationId: 'correlation-forged',
|
||||||
|
context: { ...input.context, correlationId: 'correlation-forged' },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/scope or correlation mismatch/);
|
||||||
|
|
||||||
|
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
|
||||||
|
outbox: [{ idempotencyKey: 'mismatch-effect', status: 'pending' }],
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
|
||||||
|
const repository = new TessDurableSessionRepository(handle.db);
|
||||||
|
const coordinator = new DurableSessionCoordinator(repository);
|
||||||
|
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
||||||
|
const first = {
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'scoped-effect-one',
|
||||||
|
correlationId: 'correlation-one',
|
||||||
|
content: 'first result',
|
||||||
|
context: {
|
||||||
|
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'correlation-one',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const second = {
|
||||||
|
...first,
|
||||||
|
idempotencyKey: 'scoped-effect-two',
|
||||||
|
correlationId: 'correlation-two',
|
||||||
|
content: 'second result',
|
||||||
|
context: { ...first.context, correlationId: 'correlation-two' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
await service.queueProviderSend(first);
|
||||||
|
await service.queueProviderSend(second);
|
||||||
|
await service.dispatchProviderOutbox(IDENTITY.sessionId, first);
|
||||||
|
|
||||||
|
expect(runtimeProviders.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(runtimeProviders.sendMessage).toHaveBeenCalledWith(
|
||||||
|
IDENTITY.providerId,
|
||||||
|
IDENTITY.runtimeSessionId,
|
||||||
|
{ content: 'first result', idempotencyKey: 'scoped-effect-one' },
|
||||||
|
first.context,
|
||||||
|
);
|
||||||
|
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
|
||||||
|
outbox: [
|
||||||
|
{ idempotencyKey: 'scoped-effect-one', status: 'delivered' },
|
||||||
|
{ idempotencyKey: 'scoped-effect-two', status: 'pending' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedOwner(handle: DbHandle): Promise<void> {
|
||||||
|
await handle.db.execute(sql`
|
||||||
|
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||||
|
VALUES ('tess-owner', 'Tess Owner', 'tess-owner@example.test', false, now(), now())
|
||||||
|
`);
|
||||||
|
}
|
||||||
530
apps/gateway/src/agent/tess-durable-session.repository.ts
Normal file
530
apps/gateway/src/agent/tess-durable-session.repository.ts
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
import { createHash, createHmac } from 'node:crypto';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
interactionCheckpoints,
|
||||||
|
interactionHandoffs,
|
||||||
|
interactionInbox,
|
||||||
|
interactionOutbox,
|
||||||
|
interactionSessions,
|
||||||
|
type Db,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import { seal, unseal } from '@mosaicstack/auth';
|
||||||
|
import { redactSensitiveContent } from '@mosaicstack/log';
|
||||||
|
import type {
|
||||||
|
DurableCheckpoint,
|
||||||
|
DurableCheckpointInput,
|
||||||
|
DurableEnqueueResult,
|
||||||
|
DurableHandoff,
|
||||||
|
DurableHandoffInput,
|
||||||
|
DurableInboxEntry,
|
||||||
|
DurableInboxInput,
|
||||||
|
DurableInboxStatus,
|
||||||
|
DurableOutboxEntry,
|
||||||
|
DurableOutboxInput,
|
||||||
|
DurableOutboxStatus,
|
||||||
|
DurableSessionIdentity,
|
||||||
|
DurableSessionSnapshot,
|
||||||
|
DurableSessionStore,
|
||||||
|
} from '@mosaicstack/agent';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TessDurableSessionRepository implements DurableSessionStore {
|
||||||
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
|
async create(identity: DurableSessionIdentity): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.insert(interactionSessions)
|
||||||
|
.values({
|
||||||
|
id: identity.sessionId,
|
||||||
|
agentName: identity.agentName,
|
||||||
|
tenantId: identity.tenantId,
|
||||||
|
ownerId: identity.ownerId,
|
||||||
|
providerId: identity.providerId,
|
||||||
|
runtimeSessionId: identity.runtimeSessionId,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
const existing = await this.session(identity.sessionId);
|
||||||
|
if (!existing || !sameEnrollmentScope(existing, identity)) {
|
||||||
|
throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`);
|
||||||
|
}
|
||||||
|
// A recovered/re-enrolled runtime can receive a new provider session ID;
|
||||||
|
// the conversation handle and owner scope remain immutable.
|
||||||
|
if (
|
||||||
|
existing.providerId !== identity.providerId ||
|
||||||
|
existing.runtimeSessionId !== identity.runtimeSessionId
|
||||||
|
) {
|
||||||
|
await this.db
|
||||||
|
.update(interactionSessions)
|
||||||
|
.set({ providerId: identity.providerId, runtimeSessionId: identity.runtimeSessionId })
|
||||||
|
.where(eq(interactionSessions.id, identity.sessionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshot(sessionId: string): Promise<DurableSessionSnapshot | null> {
|
||||||
|
const identity = await this.session(sessionId);
|
||||||
|
if (!identity) return null;
|
||||||
|
|
||||||
|
const [inbox, outbox, checkpoints, handoffs] = await Promise.all([
|
||||||
|
this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionInbox)
|
||||||
|
.where(eq(interactionInbox.sessionId, sessionId))
|
||||||
|
.orderBy(asc(interactionInbox.createdAt)),
|
||||||
|
this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionOutbox)
|
||||||
|
.where(eq(interactionOutbox.sessionId, sessionId))
|
||||||
|
.orderBy(asc(interactionOutbox.createdAt)),
|
||||||
|
this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionCheckpoints)
|
||||||
|
.where(eq(interactionCheckpoints.sessionId, sessionId))
|
||||||
|
.orderBy(
|
||||||
|
desc(interactionCheckpoints.compactionEpoch),
|
||||||
|
desc(interactionCheckpoints.createdAt),
|
||||||
|
)
|
||||||
|
.limit(1),
|
||||||
|
this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionHandoffs)
|
||||||
|
.where(eq(interactionHandoffs.sessionId, sessionId))
|
||||||
|
.orderBy(asc(interactionHandoffs.createdAt)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const checkpoint = checkpoints[0];
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
inbox: inbox.map(toInbox),
|
||||||
|
outbox: outbox.map(toOutbox),
|
||||||
|
...(checkpoint ? { checkpoint: toCheckpoint(checkpoint) } : {}),
|
||||||
|
handoffs: handoffs.map(toHandoff),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
|
||||||
|
const digest = contentDigest(input.content);
|
||||||
|
const record: DurableInboxInput = {
|
||||||
|
...input,
|
||||||
|
content: redactSensitiveContent(input.content).content,
|
||||||
|
};
|
||||||
|
const inserted = await this.db
|
||||||
|
.insert(interactionInbox)
|
||||||
|
.values({
|
||||||
|
...record,
|
||||||
|
content: seal(record.content),
|
||||||
|
contentDigest: digest,
|
||||||
|
status: 'pending',
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ status: interactionInbox.status });
|
||||||
|
if (inserted[0]) return { accepted: true, status: inserted[0].status };
|
||||||
|
|
||||||
|
const existing = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionInbox)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionInbox.sessionId, input.sessionId),
|
||||||
|
eq(interactionInbox.idempotencyKey, input.idempotencyKey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!existing[0]) throw new Error(`Durable Tess inbox enqueue failed: ${input.idempotencyKey}`);
|
||||||
|
const entry = toInbox(existing[0]);
|
||||||
|
if (
|
||||||
|
!sameInbox(entry, record) ||
|
||||||
|
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||||
|
) {
|
||||||
|
throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`);
|
||||||
|
}
|
||||||
|
return { accepted: false, status: entry.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimInbox(sessionId: string): Promise<DurableInboxEntry | null> {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
const candidate = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionInbox)
|
||||||
|
.where(
|
||||||
|
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'pending')),
|
||||||
|
)
|
||||||
|
.orderBy(asc(interactionInbox.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
const entry = candidate[0];
|
||||||
|
if (!entry) return null;
|
||||||
|
const claimed = await this.db
|
||||||
|
.update(interactionInbox)
|
||||||
|
.set({ status: 'processing', updatedAt: new Date() })
|
||||||
|
.where(and(eq(interactionInbox.id, entry.id), eq(interactionInbox.status, 'pending')))
|
||||||
|
.returning();
|
||||||
|
if (claimed[0]) return toInbox(claimed[0]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.update(interactionInbox)
|
||||||
|
.set({ status: 'processed', updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionInbox.sessionId, sessionId),
|
||||||
|
eq(interactionInbox.idempotencyKey, idempotencyKey),
|
||||||
|
eq(interactionInbox.status, 'processing'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.update(interactionInbox)
|
||||||
|
.set({ status: 'pending', updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionInbox.sessionId, sessionId),
|
||||||
|
eq(interactionInbox.idempotencyKey, idempotencyKey),
|
||||||
|
eq(interactionInbox.status, 'processing'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueueOutbox(
|
||||||
|
input: DurableOutboxInput,
|
||||||
|
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
|
||||||
|
const digest = contentDigest(input.content);
|
||||||
|
const record: DurableOutboxInput = {
|
||||||
|
...input,
|
||||||
|
content: redactSensitiveContent(input.content).content,
|
||||||
|
};
|
||||||
|
const inserted = await this.db
|
||||||
|
.insert(interactionOutbox)
|
||||||
|
.values({
|
||||||
|
...record,
|
||||||
|
content: seal(record.content),
|
||||||
|
contentDigest: digest,
|
||||||
|
status: 'pending',
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ status: interactionOutbox.status });
|
||||||
|
if (inserted[0]) return { accepted: true, status: inserted[0].status };
|
||||||
|
|
||||||
|
const existing = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionOutbox)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionOutbox.sessionId, input.sessionId),
|
||||||
|
eq(interactionOutbox.idempotencyKey, input.idempotencyKey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!existing[0])
|
||||||
|
throw new Error(`Durable Tess outbox enqueue failed: ${input.idempotencyKey}`);
|
||||||
|
const entry = toOutbox(existing[0]);
|
||||||
|
if (
|
||||||
|
!sameOutbox(entry, record) ||
|
||||||
|
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||||
|
) {
|
||||||
|
throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`);
|
||||||
|
}
|
||||||
|
return { accepted: false, status: entry.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null> {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
const candidate = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionOutbox)
|
||||||
|
.where(
|
||||||
|
and(eq(interactionOutbox.sessionId, sessionId), eq(interactionOutbox.status, 'pending')),
|
||||||
|
)
|
||||||
|
.orderBy(asc(interactionOutbox.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
const entry = candidate[0];
|
||||||
|
if (!entry) return null;
|
||||||
|
const claimed = await this.db
|
||||||
|
.update(interactionOutbox)
|
||||||
|
.set({ status: 'processing', updatedAt: new Date() })
|
||||||
|
.where(and(eq(interactionOutbox.id, entry.id), eq(interactionOutbox.status, 'pending')))
|
||||||
|
.returning();
|
||||||
|
if (claimed[0]) return toOutbox(claimed[0]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimOutboxByKey(
|
||||||
|
sessionId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
): Promise<DurableOutboxEntry | null> {
|
||||||
|
const claimed = await this.db
|
||||||
|
.update(interactionOutbox)
|
||||||
|
.set({ status: 'processing', updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionOutbox.sessionId, sessionId),
|
||||||
|
eq(interactionOutbox.idempotencyKey, idempotencyKey),
|
||||||
|
eq(interactionOutbox.status, 'pending'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
return claimed[0] ? toOutbox(claimed[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.update(interactionOutbox)
|
||||||
|
.set({ status: 'delivered', updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionOutbox.sessionId, sessionId),
|
||||||
|
eq(interactionOutbox.idempotencyKey, idempotencyKey),
|
||||||
|
eq(interactionOutbox.status, 'processing'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.update(interactionOutbox)
|
||||||
|
.set({ status: 'pending', updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionOutbox.sessionId, sessionId),
|
||||||
|
eq(interactionOutbox.idempotencyKey, idempotencyKey),
|
||||||
|
eq(interactionOutbox.status, 'processing'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkpoint(input: DurableCheckpointInput): Promise<void> {
|
||||||
|
// Compute identity before redaction. The persisted digest is keyed so a database
|
||||||
|
// reader cannot use it as an offline oracle for sensitive cursor/summary values.
|
||||||
|
const digest = contentDigest(JSON.stringify([input.cursor, input.summary]));
|
||||||
|
const checkpoint: DurableCheckpointInput = {
|
||||||
|
...input,
|
||||||
|
cursor: redactSensitiveContent(input.cursor).content,
|
||||||
|
summary: redactSensitiveContent(input.summary).content,
|
||||||
|
};
|
||||||
|
const inserted = await this.db
|
||||||
|
.insert(interactionCheckpoints)
|
||||||
|
.values({
|
||||||
|
...checkpoint,
|
||||||
|
contentDigest: digest,
|
||||||
|
cursor: seal(checkpoint.cursor),
|
||||||
|
summary: seal(checkpoint.summary),
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ checkpointId: interactionCheckpoints.checkpointId });
|
||||||
|
if (inserted[0]) return;
|
||||||
|
|
||||||
|
const existing = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionCheckpoints)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionCheckpoints.sessionId, input.sessionId),
|
||||||
|
eq(interactionCheckpoints.checkpointId, input.checkpointId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (
|
||||||
|
!existing[0] ||
|
||||||
|
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
|
||||||
|
!matchesCheckpointDigest(existing[0].contentDigest, digest)
|
||||||
|
) {
|
||||||
|
throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null> {
|
||||||
|
const checkpoints = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionCheckpoints)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionCheckpoints.sessionId, sessionId),
|
||||||
|
eq(interactionCheckpoints.checkpointId, checkpointId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
const checkpoint = checkpoints[0];
|
||||||
|
return checkpoint ? toCheckpoint(checkpoint) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||||
|
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
|
||||||
|
if (!checkpoint) {
|
||||||
|
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||||
|
}
|
||||||
|
const inserted = await this.db
|
||||||
|
.insert(interactionHandoffs)
|
||||||
|
.values({ ...input })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ handoffId: interactionHandoffs.handoffId });
|
||||||
|
if (inserted[0]) return;
|
||||||
|
|
||||||
|
const existing = await this.findHandoff(input.handoffId);
|
||||||
|
if (!existing || !sameHandoff(existing, input)) {
|
||||||
|
throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findHandoff(handoffId: string): Promise<DurableHandoff | null> {
|
||||||
|
const handoffs = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionHandoffs)
|
||||||
|
.where(eq(interactionHandoffs.handoffId, handoffId))
|
||||||
|
.limit(1);
|
||||||
|
const handoff = handoffs[0];
|
||||||
|
return handoff ? toHandoff(handoff) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async requeueInFlight(sessionId: string): Promise<void> {
|
||||||
|
// Inbox handlers are process-local work. A provider outbox claim may have
|
||||||
|
// reached an external target before a crash, so it is deliberately not
|
||||||
|
// replayed by generic recovery.
|
||||||
|
await this.db
|
||||||
|
.update(interactionInbox)
|
||||||
|
.set({ status: 'pending', updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'processing')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async session(sessionId: string): Promise<DurableSessionIdentity | null> {
|
||||||
|
const sessions = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionSessions)
|
||||||
|
.where(eq(interactionSessions.id, sessionId))
|
||||||
|
.limit(1);
|
||||||
|
const session = sessions[0];
|
||||||
|
return session
|
||||||
|
? {
|
||||||
|
agentName: session.agentName,
|
||||||
|
sessionId: session.id,
|
||||||
|
tenantId: session.tenantId,
|
||||||
|
ownerId: session.ownerId,
|
||||||
|
providerId: session.providerId,
|
||||||
|
runtimeSessionId: session.runtimeSessionId,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentDigest(content: string): string {
|
||||||
|
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('BETTER_AUTH_SECRET is required for durable idempotency digests');
|
||||||
|
}
|
||||||
|
return `hmac:v1:${createHmac('sha256', secret).update(content).digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesContentDigest(stored: string, content: string): boolean {
|
||||||
|
return (
|
||||||
|
stored === contentDigest(content) ||
|
||||||
|
stored === createHash('sha256').update(content).digest('hex')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesCheckpointDigest(stored: string, digest: string): boolean {
|
||||||
|
// Legacy rows predate any pre-redaction identity and cannot safely prove equality.
|
||||||
|
// Reject rather than let redaction collapse distinct sensitive checkpoint payloads.
|
||||||
|
return stored === digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.compactionEpoch === right.compactionEpoch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 toInbox(row: typeof interactionInbox.$inferSelect): DurableInboxEntry {
|
||||||
|
return {
|
||||||
|
sessionId: row.sessionId,
|
||||||
|
idempotencyKey: row.idempotencyKey,
|
||||||
|
correlationId: row.correlationId,
|
||||||
|
content: unseal(row.content),
|
||||||
|
status: row.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOutbox(row: typeof interactionOutbox.$inferSelect): DurableOutboxEntry {
|
||||||
|
return {
|
||||||
|
sessionId: row.sessionId,
|
||||||
|
idempotencyKey: row.idempotencyKey,
|
||||||
|
correlationId: row.correlationId,
|
||||||
|
channelId: row.channelId,
|
||||||
|
kind: row.kind,
|
||||||
|
content: unseal(row.content),
|
||||||
|
status: row.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCheckpoint(row: typeof interactionCheckpoints.$inferSelect): DurableCheckpoint {
|
||||||
|
return {
|
||||||
|
sessionId: row.sessionId,
|
||||||
|
checkpointId: row.checkpointId,
|
||||||
|
cursor: unseal(row.cursor),
|
||||||
|
summary: unseal(row.summary),
|
||||||
|
compactionEpoch: row.compactionEpoch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHandoff(row: typeof interactionHandoffs.$inferSelect): DurableHandoff {
|
||||||
|
return {
|
||||||
|
sessionId: row.sessionId,
|
||||||
|
handoffId: row.handoffId,
|
||||||
|
destination: row.destination,
|
||||||
|
correlationId: row.correlationId,
|
||||||
|
checkpointId: row.checkpointId,
|
||||||
|
status: row.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
123
apps/gateway/src/agent/tess-durable-session.service.ts
Normal file
123
apps/gateway/src/agent/tess-durable-session.service.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||||
|
import type { TessProviderOutboxDto } from './tess-durable-session.dto.js';
|
||||||
|
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
||||||
|
import {
|
||||||
|
RuntimeProviderService,
|
||||||
|
type RuntimeProviderRequestContext,
|
||||||
|
} from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scoped gateway boundary for the canonical Tess state machine. It deliberately
|
||||||
|
* uses composition: raw state methods cannot be injected into channel, CLI, or
|
||||||
|
* MCP adapters without a server-derived actor/tenant/correlation context.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TessDurableSessionService {
|
||||||
|
private readonly coordinator: DurableSessionCoordinator;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(TessDurableSessionRepository) repository: TessDurableSessionRepository,
|
||||||
|
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
|
||||||
|
) {
|
||||||
|
this.coordinator = new DurableSessionCoordinator(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enroll a verified runtime session under the stable cross-surface conversation handle. */
|
||||||
|
async enroll(
|
||||||
|
identity: DurableSessionIdentity,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
identity.ownerId !== context.actorScope.userId ||
|
||||||
|
identity.tenantId !== context.actorScope.tenantId
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('Durable Tess enrollment scope mismatch');
|
||||||
|
}
|
||||||
|
await this.coordinator.create(identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async queueProviderSend(input: TessProviderOutboxDto): Promise<void> {
|
||||||
|
const snapshot = await this.coordinator.snapshot(input.sessionId);
|
||||||
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
|
await this.coordinator.enqueueOutbox({
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
idempotencyKey: input.idempotencyKey,
|
||||||
|
correlationId: input.correlationId,
|
||||||
|
channelId: input.context.channelId,
|
||||||
|
kind: 'provider.send',
|
||||||
|
content: input.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatchProviderOutbox(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
|
||||||
|
if (sessionId !== input.sessionId) {
|
||||||
|
throw new ForbiddenException('Durable Tess outbox session mismatch');
|
||||||
|
}
|
||||||
|
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||||
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
|
const pendingEntry = snapshot.outbox.find(
|
||||||
|
(entry): boolean => entry.idempotencyKey === input.idempotencyKey,
|
||||||
|
);
|
||||||
|
if (!pendingEntry) return;
|
||||||
|
// Validate immutable routing before claiming. A caller with a mismatched
|
||||||
|
// correlation/channel must not strand a pending external side effect.
|
||||||
|
this.assertOutboxScope(pendingEntry, input);
|
||||||
|
await this.coordinator.dispatchOutboxEntry(
|
||||||
|
sessionId,
|
||||||
|
input.idempotencyKey,
|
||||||
|
async (entry): Promise<void> => {
|
||||||
|
this.assertOutboxScope(entry, input);
|
||||||
|
await this.runtimeProviders.sendMessage(
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
{ content: entry.content, idempotencyKey: entry.idempotencyKey },
|
||||||
|
input.context,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read durable identity/state only after deriving and checking the server-side actor scope. */
|
||||||
|
async getSnapshot(sessionId: string, context: RuntimeProviderRequestContext) {
|
||||||
|
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||||
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, {
|
||||||
|
sessionId,
|
||||||
|
content: '',
|
||||||
|
idempotencyKey: 'read-only',
|
||||||
|
correlationId: context.correlationId,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
|
||||||
|
async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
|
||||||
|
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||||
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
|
await this.coordinator.recover(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertOutboxScope(
|
||||||
|
entry: { kind: string; correlationId: string; channelId: string },
|
||||||
|
input: TessProviderOutboxDto,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
entry.kind !== 'provider.send' ||
|
||||||
|
entry.correlationId !== input.correlationId ||
|
||||||
|
entry.channelId !== input.context.channelId
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('Durable Tess outbox scope or correlation mismatch');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertScope(ownerId: string, tenantId: string, input: TessProviderOutboxDto): void {
|
||||||
|
if (
|
||||||
|
input.context.actorScope.userId !== ownerId ||
|
||||||
|
input.context.actorScope.tenantId !== tenantId ||
|
||||||
|
input.context.correlationId !== input.correlationId
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('Durable Tess session scope or correlation mismatch');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/gateway/src/agent/tools/memory-tools.test.ts
Normal file
41
apps/gateway/src/agent/tools/memory-tools.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createMemoryTools } from './memory-tools.js';
|
||||||
|
|
||||||
|
describe('createMemoryTools operator retrieval binding', () => {
|
||||||
|
const memory = {
|
||||||
|
insights: { searchByEmbedding: vi.fn(), create: vi.fn() },
|
||||||
|
preferences: { findByUserAndCategory: vi.fn(), findByUser: vi.fn(), upsert: vi.fn() },
|
||||||
|
};
|
||||||
|
const scope = { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' };
|
||||||
|
|
||||||
|
it('uses the configured plugin with the server-derived scope for retrieval and capture', async () => {
|
||||||
|
const plugin = {
|
||||||
|
search: vi.fn(async () => []),
|
||||||
|
capture: vi.fn(async () => ({ id: 'insight-1' })),
|
||||||
|
};
|
||||||
|
const tools = createMemoryTools(memory as never, null, 'owner-a', {
|
||||||
|
plugin: plugin as never,
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tools
|
||||||
|
.find((tool) => tool.name === 'memory_search')!
|
||||||
|
.execute('call-1', { query: 'plans' }, undefined, undefined, {} as never);
|
||||||
|
await tools
|
||||||
|
.find((tool) => tool.name === 'memory_save_insight')!
|
||||||
|
.execute(
|
||||||
|
'call-2',
|
||||||
|
{ content: 'secret', category: 'decision' },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(plugin.search).toHaveBeenCalledWith(scope, 'plans', 5);
|
||||||
|
expect(plugin.capture).toHaveBeenCalledWith(scope, {
|
||||||
|
content: 'secret',
|
||||||
|
source: 'agent',
|
||||||
|
category: 'decision',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Type } from '@sinclair/typebox';
|
import { Type } from '@sinclair/typebox';
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
import type { Memory } from '@mosaicstack/memory';
|
import type {
|
||||||
import type { EmbeddingProvider } from '@mosaicstack/memory';
|
EmbeddingProvider,
|
||||||
|
Memory,
|
||||||
|
OperatorMemoryPlugin,
|
||||||
|
OperatorMemoryScope,
|
||||||
|
} from '@mosaicstack/memory';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create memory tools bound to the session's authenticated userId.
|
* Create memory tools bound to the session's authenticated userId.
|
||||||
@@ -13,8 +17,10 @@ import type { EmbeddingProvider } from '@mosaicstack/memory';
|
|||||||
export function createMemoryTools(
|
export function createMemoryTools(
|
||||||
memory: Memory,
|
memory: Memory,
|
||||||
embeddingProvider: EmbeddingProvider | null,
|
embeddingProvider: EmbeddingProvider | null,
|
||||||
/** Authenticated user ID from the session. All memory operations are scoped to this user. */
|
/** Authenticated user ID from the session. All preference operations are scoped to this user. */
|
||||||
sessionUserId: string | undefined,
|
sessionUserId: string | undefined,
|
||||||
|
/** Optional configured retrieval plugin, bound to a server-derived session scope. */
|
||||||
|
operatorMemory?: { plugin: OperatorMemoryPlugin; scope: OperatorMemoryScope },
|
||||||
): ToolDefinition[] {
|
): ToolDefinition[] {
|
||||||
/** Return an error result when no session user is bound. */
|
/** Return an error result when no session user is bound. */
|
||||||
function noUserError() {
|
function noUserError() {
|
||||||
@@ -46,6 +52,14 @@ export function createMemoryTools(
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (operatorMemory) {
|
||||||
|
const results = await operatorMemory.plugin.search(operatorMemory.scope, query, limit ?? 5);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!embeddingProvider) {
|
if (!embeddingProvider) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -158,6 +172,18 @@ export function createMemoryTools(
|
|||||||
};
|
};
|
||||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||||
|
|
||||||
|
if (operatorMemory) {
|
||||||
|
const insight = await operatorMemory.plugin.capture(operatorMemory.scope, {
|
||||||
|
content,
|
||||||
|
source: 'agent',
|
||||||
|
category: category ?? 'learning',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let embedding: number[] | null = null;
|
let embedding: number[] | null = null;
|
||||||
if (embeddingProvider) {
|
if (embeddingProvider) {
|
||||||
embedding = await embeddingProvider.embed(content);
|
embedding = await embeddingProvider.embed(content);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Inject, Logger } from '@nestjs/common';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { Inject, Logger, Optional } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
WebSocketGateway,
|
WebSocketGateway,
|
||||||
WebSocketServer,
|
WebSocketServer,
|
||||||
@@ -13,6 +14,9 @@ import { Server, Socket } from 'socket.io';
|
|||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
import {
|
import {
|
||||||
verifyDiscordIngressEnvelope,
|
verifyDiscordIngressEnvelope,
|
||||||
|
parseDiscordInteractionBindings,
|
||||||
|
resolveDiscordInteractionActorId,
|
||||||
|
resolveDiscordInteractionBinding,
|
||||||
type DiscordIngressEnvelope,
|
type DiscordIngressEnvelope,
|
||||||
type DiscordIngressPayload,
|
type DiscordIngressPayload,
|
||||||
} from '@mosaicstack/discord-plugin';
|
} from '@mosaicstack/discord-plugin';
|
||||||
@@ -28,6 +32,12 @@ import type {
|
|||||||
AbortPayload,
|
AbortPayload,
|
||||||
} from '@mosaicstack/types';
|
} from '@mosaicstack/types';
|
||||||
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
||||||
|
import {
|
||||||
|
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
|
RuntimeProviderService,
|
||||||
|
type RuntimeAuditSink,
|
||||||
|
} from '../agent/runtime-provider-registry.service.js';
|
||||||
|
import { TessDurableSessionService } from '../agent/tess-durable-session.service.js';
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
import {
|
import {
|
||||||
scopeFromUser,
|
scopeFromUser,
|
||||||
@@ -37,6 +47,7 @@ import {
|
|||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||||
|
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
||||||
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||||
@@ -122,6 +133,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||||
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
||||||
@Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService,
|
@Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(CommandAuthorizationService)
|
||||||
|
private readonly commandAuthorization: CommandAuthorizationService | null = null,
|
||||||
|
@Optional()
|
||||||
|
@Inject(RuntimeProviderService)
|
||||||
|
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
||||||
|
@Optional()
|
||||||
|
@Inject(TessDurableSessionService)
|
||||||
|
private readonly durableSessions: TessDurableSessionService | null = null,
|
||||||
|
@Optional()
|
||||||
|
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
||||||
|
private readonly runtimeAudit: RuntimeAuditSink | null = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
afterInit(): void {
|
afterInit(): void {
|
||||||
@@ -637,9 +660,185 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
* Creates it if absent — safe to call concurrently since a duplicate insert
|
* Creates it if absent — safe to call concurrently since a duplicate insert
|
||||||
* would fail on the PK constraint and be caught here.
|
* would fail on the PK constraint and be caught here.
|
||||||
*/
|
*/
|
||||||
|
@SubscribeMessage('discord:approve')
|
||||||
|
async handleDiscordApproval(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() envelope: DiscordIngressEnvelope,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!client.data.discordService) return;
|
||||||
|
const ingress = this.resolveDiscordIngress(client, envelope, 'approve');
|
||||||
|
const isApprovalCommand = /^\/approve\s*$/i.test(ingress?.content ?? '');
|
||||||
|
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
||||||
|
if (
|
||||||
|
!ingress ||
|
||||||
|
!isApprovalCommand ||
|
||||||
|
!tenantId ||
|
||||||
|
!this.commandAuthorization ||
|
||||||
|
!this.durableSessions
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||||
|
ingress.guildId,
|
||||||
|
ingress.channelId,
|
||||||
|
ingress.userId,
|
||||||
|
'approve',
|
||||||
|
);
|
||||||
|
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
||||||
|
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||||
|
if (!actorId || !agentName || binding.instanceId !== agentName) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Rejected Discord approval without a matching runtime agent from ${client.id}`,
|
||||||
|
);
|
||||||
|
client.emit('discord:approval', {
|
||||||
|
correlationId: ingress.correlationId,
|
||||||
|
success: false,
|
||||||
|
approvalId: undefined,
|
||||||
|
expiresAt: undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let snapshot;
|
||||||
|
try {
|
||||||
|
snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, {
|
||||||
|
actorScope: { userId: actorId, tenantId },
|
||||||
|
channelId: ingress.channelId,
|
||||||
|
correlationId: ingress.correlationId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
client.emit('discord:approval', {
|
||||||
|
correlationId: ingress.correlationId,
|
||||||
|
success: false,
|
||||||
|
approvalId: undefined,
|
||||||
|
expiresAt: undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (snapshot.identity.agentName !== agentName) {
|
||||||
|
client.emit('discord:approval', {
|
||||||
|
correlationId: ingress.correlationId,
|
||||||
|
success: false,
|
||||||
|
approvalId: undefined,
|
||||||
|
expiresAt: undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const approval = await this.commandAuthorization.createRuntimeTerminationApproval({
|
||||||
|
providerId: snapshot.identity.providerId,
|
||||||
|
sessionId: snapshot.identity.runtimeSessionId,
|
||||||
|
actorId,
|
||||||
|
tenantId,
|
||||||
|
channelId: ingress.channelId,
|
||||||
|
correlationId: this.discordRuntimeActionCorrelation(
|
||||||
|
binding.instanceId,
|
||||||
|
ingress,
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
),
|
||||||
|
agentName,
|
||||||
|
});
|
||||||
|
if (!approval) {
|
||||||
|
await this.runtimeAudit?.record({
|
||||||
|
providerId: snapshot.identity.providerId,
|
||||||
|
operation: 'session.terminate',
|
||||||
|
outcome: 'denied',
|
||||||
|
actorId,
|
||||||
|
tenantId,
|
||||||
|
channelId: ingress.channelId,
|
||||||
|
correlationId: this.discordRuntimeActionCorrelation(
|
||||||
|
binding.instanceId,
|
||||||
|
ingress,
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
),
|
||||||
|
resourceId: snapshot.identity.runtimeSessionId,
|
||||||
|
errorCode: 'policy_denied',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
client.emit('discord:approval', {
|
||||||
|
correlationId: ingress.correlationId,
|
||||||
|
success: approval !== null,
|
||||||
|
approvalId: approval?.approvalId,
|
||||||
|
expiresAt: approval?.expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('discord:stop')
|
||||||
|
async handleDiscordStop(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() envelope: DiscordIngressEnvelope,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!client.data.discordService) return;
|
||||||
|
const ingress = this.resolveDiscordIngress(client, envelope, 'stop');
|
||||||
|
const approvalRef = /^\/stop\s+([^\s]+)$/i.exec(ingress?.content ?? '')?.[1];
|
||||||
|
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
||||||
|
if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions)
|
||||||
|
return;
|
||||||
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||||
|
ingress.guildId,
|
||||||
|
ingress.channelId,
|
||||||
|
ingress.userId,
|
||||||
|
'stop',
|
||||||
|
);
|
||||||
|
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
||||||
|
if (!actorId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = {
|
||||||
|
actorScope: { userId: actorId, tenantId },
|
||||||
|
channelId: ingress.channelId,
|
||||||
|
correlationId: ingress.correlationId,
|
||||||
|
};
|
||||||
|
const snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, context);
|
||||||
|
if (snapshot.identity.agentName !== binding.instanceId) throw new Error('agent mismatch');
|
||||||
|
// RuntimeProviderService consumes the durable approval exactly once using the
|
||||||
|
// provisioned approving-admin identity, never the Discord service account.
|
||||||
|
await this.runtimeRegistry.terminate(
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
approvalRef,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
correlationId: this.discordRuntimeActionCorrelation(
|
||||||
|
binding.instanceId,
|
||||||
|
ingress,
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
client.emit('discord:stop', { correlationId: ingress.correlationId, success: true });
|
||||||
|
} catch {
|
||||||
|
client.emit('discord:stop', { correlationId: ingress.correlationId, success: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correlates the immutable termination target rather than either Discord message.
|
||||||
|
* Approval and stop are distinct ingress events, but must consume the same seven-field action.
|
||||||
|
*/
|
||||||
|
private discordRuntimeActionCorrelation(
|
||||||
|
instanceId: string,
|
||||||
|
ingress: DiscordIngressPayload,
|
||||||
|
providerId: string,
|
||||||
|
sessionId: string,
|
||||||
|
): string {
|
||||||
|
const target = [
|
||||||
|
instanceId,
|
||||||
|
ingress.guildId,
|
||||||
|
ingress.channelId,
|
||||||
|
ingress.conversationId,
|
||||||
|
providerId,
|
||||||
|
sessionId,
|
||||||
|
];
|
||||||
|
return `discord-action:v1:${createHash('sha256').update(JSON.stringify(target)).digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
private resolveDiscordIngress(
|
private resolveDiscordIngress(
|
||||||
client: Socket,
|
client: Socket,
|
||||||
envelope: DiscordIngressEnvelope,
|
envelope: DiscordIngressEnvelope,
|
||||||
|
operation: 'send' | 'approve' | 'stop' = 'send',
|
||||||
): DiscordIngressPayload | null {
|
): DiscordIngressPayload | null {
|
||||||
const payload = verifyDiscordIngressEnvelope(
|
const payload = verifyDiscordIngressEnvelope(
|
||||||
envelope,
|
envelope,
|
||||||
@@ -654,6 +853,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
|
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||||
|
payload.guildId,
|
||||||
|
payload.channelId,
|
||||||
|
payload.userId,
|
||||||
|
operation,
|
||||||
|
);
|
||||||
|
if (!binding) {
|
||||||
|
this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(
|
||||||
|
`Rejected Discord ingress without valid binding configuration from ${client.id}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!this.discordReplayProtector.claim(payload.messageId)) {
|
if (!this.discordReplayProtector.claim(payload.messageId)) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
|
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ const adminCommand: CommandDef = {
|
|||||||
};
|
};
|
||||||
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
||||||
|
|
||||||
function createService(role: string): CommandAuthorizationService {
|
function createService(
|
||||||
const entries = new Map<string, string>();
|
role: string,
|
||||||
|
entries: Map<string, string> = new Map<string, string>(),
|
||||||
|
): CommandAuthorizationService {
|
||||||
const db = {
|
const db = {
|
||||||
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
||||||
};
|
};
|
||||||
@@ -58,4 +60,57 @@ describe('CommandAuthorizationService', () => {
|
|||||||
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
|
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
|
||||||
|
const entries = new Map<string, string>();
|
||||||
|
const action = {
|
||||||
|
providerId: 'fleet',
|
||||||
|
sessionId: 'nova',
|
||||||
|
actorId: 'admin-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
channelId: 'discord:operator',
|
||||||
|
correlationId: 'correlation-malformed-expiry',
|
||||||
|
agentName: 'Nova',
|
||||||
|
};
|
||||||
|
const service = createService('admin', entries);
|
||||||
|
const approval = await service.createRuntimeTerminationApproval(action);
|
||||||
|
expect(approval).not.toBeNull();
|
||||||
|
const key = `agent:Nova:command-approval:${approval!.approvalId}`;
|
||||||
|
const stored = entries.get(key);
|
||||||
|
expect(stored).toBeDefined();
|
||||||
|
entries.set(key, JSON.stringify({ ...JSON.parse(stored!), expiresAt: 'not-a-date' }));
|
||||||
|
|
||||||
|
expect(await service.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists and consumes one exact runtime termination approval across a service restart', async (): Promise<void> => {
|
||||||
|
const entries = new Map<string, string>();
|
||||||
|
const action = {
|
||||||
|
providerId: 'fleet',
|
||||||
|
sessionId: 'nova',
|
||||||
|
actorId: 'admin-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
channelId: 'discord:operator',
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
agentName: 'Nova',
|
||||||
|
};
|
||||||
|
const beforeRestart = createService('admin', entries);
|
||||||
|
const approval = await beforeRestart.createRuntimeTerminationApproval(action);
|
||||||
|
|
||||||
|
const afterRestart = createService('admin', entries);
|
||||||
|
expect(
|
||||||
|
await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, {
|
||||||
|
...action,
|
||||||
|
sessionId: 'forged-session',
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,24 @@ export interface CommandApproval {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Exact immutable binding for a privileged runtime termination. */
|
||||||
|
export interface RuntimeTerminationApprovalAction {
|
||||||
|
providerId: string;
|
||||||
|
sessionId: string;
|
||||||
|
actorId: string;
|
||||||
|
tenantId: string;
|
||||||
|
channelId: string;
|
||||||
|
correlationId: string;
|
||||||
|
/** Provisioned roster identity; isolates approvals between interaction agents. */
|
||||||
|
agentName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeTerminationApproval extends RuntimeTerminationApprovalAction {
|
||||||
|
approvalId: string;
|
||||||
|
actionDigest: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CommandAuthorizationResult {
|
export interface CommandAuthorizationResult {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
@@ -75,6 +93,57 @@ export class CommandAuthorizationService {
|
|||||||
return approval;
|
return approval;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses the same `interaction:command-approval:*` store and one-time deletion rule as
|
||||||
|
* command approvals. This deliberately avoids a parallel approval database.
|
||||||
|
*/
|
||||||
|
async createRuntimeTerminationApproval(
|
||||||
|
action: RuntimeTerminationApprovalAction,
|
||||||
|
): Promise<RuntimeTerminationApproval | null> {
|
||||||
|
if (!this.hasRuntimeTerminationAction(action)) return null;
|
||||||
|
const role = await this.resolveRole(action.actorId);
|
||||||
|
if (role !== 'admin') return null;
|
||||||
|
|
||||||
|
const approval: RuntimeTerminationApproval = {
|
||||||
|
approvalId: randomUUID(),
|
||||||
|
actionDigest: this.runtimeActionDigest(action),
|
||||||
|
...action,
|
||||||
|
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
|
||||||
|
};
|
||||||
|
await this.redis.set(
|
||||||
|
this.runtimeKey(action.agentName, approval.approvalId),
|
||||||
|
JSON.stringify(approval),
|
||||||
|
'EX',
|
||||||
|
'300',
|
||||||
|
);
|
||||||
|
return approval;
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumeRuntimeTerminationApproval(
|
||||||
|
approvalId: string,
|
||||||
|
action: RuntimeTerminationApprovalAction,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const encoded = await this.redis.get(this.runtimeKey(action.agentName, approvalId));
|
||||||
|
if (!encoded) return false;
|
||||||
|
let approval: unknown;
|
||||||
|
try {
|
||||||
|
approval = JSON.parse(encoded);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!this.isRuntimeTerminationApproval(approval) ||
|
||||||
|
approval.actionDigest !== this.runtimeActionDigest(action) ||
|
||||||
|
approval.actorId !== action.actorId ||
|
||||||
|
approval.tenantId !== action.tenantId ||
|
||||||
|
!this.isUnexpired(approval.expiresAt)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ((await this.resolveRole(approval.actorId)) !== 'admin') return false;
|
||||||
|
return (await this.redis.del(this.runtimeKey(action.agentName, approvalId))) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveRole(actorId: string): Promise<CommandRole | null> {
|
private async resolveRole(actorId: string): Promise<CommandRole | null> {
|
||||||
const [user] = await this.db
|
const [user] = await this.db
|
||||||
.select({ role: usersTable.role })
|
.select({ role: usersTable.role })
|
||||||
@@ -98,12 +167,17 @@ export class CommandAuthorizationService {
|
|||||||
const key = this.key(approvalId);
|
const key = this.key(approvalId);
|
||||||
const encoded = await this.redis.get(key);
|
const encoded = await this.redis.get(key);
|
||||||
if (!encoded) return false;
|
if (!encoded) return false;
|
||||||
const parsed: unknown = JSON.parse(encoded);
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(encoded);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!this.isApproval(parsed) ||
|
!this.isCommandApproval(parsed) ||
|
||||||
parsed.actorId !== actorId ||
|
parsed.actorId !== actorId ||
|
||||||
parsed.actionDigest !== actionDigest ||
|
parsed.actionDigest !== actionDigest ||
|
||||||
Date.parse(parsed.expiresAt) <= Date.now()
|
!this.isUnexpired(parsed.expiresAt)
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
return (await this.redis.del(key)) === 1;
|
return (await this.redis.del(key)) === 1;
|
||||||
@@ -121,18 +195,74 @@ export class CommandAuthorizationService {
|
|||||||
.digest('hex');
|
.digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
private isApproval(value: unknown): value is CommandApproval {
|
private hasRuntimeTerminationAction(action: RuntimeTerminationApprovalAction): boolean {
|
||||||
|
return [
|
||||||
|
action.providerId,
|
||||||
|
action.sessionId,
|
||||||
|
action.actorId,
|
||||||
|
action.tenantId,
|
||||||
|
action.channelId,
|
||||||
|
action.correlationId,
|
||||||
|
action.agentName,
|
||||||
|
].every((value: string): boolean => value.trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private runtimeActionDigest(action: RuntimeTerminationApprovalAction): string {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(
|
||||||
|
JSON.stringify({
|
||||||
|
providerId: action.providerId,
|
||||||
|
sessionId: action.sessionId,
|
||||||
|
actorId: action.actorId,
|
||||||
|
tenantId: action.tenantId,
|
||||||
|
channelId: action.channelId,
|
||||||
|
correlationId: action.correlationId,
|
||||||
|
agentName: action.agentName,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUnexpired(expiresAt: unknown): expiresAt is string {
|
||||||
|
if (typeof expiresAt !== 'string') return false;
|
||||||
|
const expiresAtMs = Date.parse(expiresAt);
|
||||||
|
return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCommandApproval(value: unknown): value is CommandApproval {
|
||||||
return (
|
return (
|
||||||
typeof value === 'object' &&
|
typeof value === 'object' &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
'approvalId' in value &&
|
'approvalId' in value &&
|
||||||
'actionDigest' in value &&
|
'actionDigest' in value &&
|
||||||
'actorId' in value &&
|
'actorId' in value &&
|
||||||
|
'expiresAt' in value &&
|
||||||
|
'command' in value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRuntimeTerminationApproval(value: unknown): value is RuntimeTerminationApproval {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'approvalId' in value &&
|
||||||
|
'actionDigest' in value &&
|
||||||
|
'actorId' in value &&
|
||||||
|
'tenantId' in value &&
|
||||||
|
'providerId' in value &&
|
||||||
|
'sessionId' in value &&
|
||||||
|
'channelId' in value &&
|
||||||
|
'correlationId' in value &&
|
||||||
|
'agentName' in value &&
|
||||||
'expiresAt' in value
|
'expiresAt' in value
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private key(approvalId: string): string {
|
private key(approvalId: string): string {
|
||||||
return `tess:command-approval:${approvalId}`;
|
return `interaction:command-approval:${approvalId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private runtimeKey(agentName: string, approvalId: string): string {
|
||||||
|
return `agent:${encodeURIComponent(agentName)}:command-approval:${approvalId}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ReloadModule } from '../reload/reload.module.js';
|
|||||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
|
import { CommandRuntimeApprovalVerifier } from './runtime-approval-verifier.js';
|
||||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
|
|
||||||
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||||
@@ -26,9 +27,15 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
},
|
},
|
||||||
CommandRegistryService,
|
CommandRegistryService,
|
||||||
CommandAuthorizationService,
|
CommandAuthorizationService,
|
||||||
|
CommandRuntimeApprovalVerifier,
|
||||||
|
CommandExecutorService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
CommandRegistryService,
|
||||||
|
CommandAuthorizationService,
|
||||||
|
CommandRuntimeApprovalVerifier,
|
||||||
CommandExecutorService,
|
CommandExecutorService,
|
||||||
],
|
],
|
||||||
exports: [CommandRegistryService, CommandExecutorService],
|
|
||||||
})
|
})
|
||||||
export class CommandsModule implements OnApplicationShutdown {
|
export class CommandsModule implements OnApplicationShutdown {
|
||||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
|
|||||||
23
apps/gateway/src/commands/runtime-approval-verifier.ts
Normal file
23
apps/gateway/src/commands/runtime-approval-verifier.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
RuntimeApprovalVerifier,
|
||||||
|
RuntimeTerminationAction,
|
||||||
|
} from '../agent/runtime-provider-registry.service.js';
|
||||||
|
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter from the provider registry's exact termination action to the shared,
|
||||||
|
* Redis-backed `interaction:command-approval:*` store. It has no separate approval
|
||||||
|
* persistence or replay semantics.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CommandRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
|
||||||
|
constructor(
|
||||||
|
@Inject(CommandAuthorizationService)
|
||||||
|
private readonly authorization: CommandAuthorizationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean> {
|
||||||
|
return this.authorization.consumeRuntimeTerminationApproval(approvalRef, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,31 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { InMemoryMosCoordinationPort } from '@mosaicstack/coord';
|
||||||
import { CoordService } from './coord.service.js';
|
import { CoordService } from './coord.service.js';
|
||||||
import { CoordController } from './coord.controller.js';
|
import { CoordController } from './coord.controller.js';
|
||||||
|
import { MosCoordinationController } from './mos-coordination.controller.js';
|
||||||
|
import {
|
||||||
|
MOS_COORDINATION_CONFIG,
|
||||||
|
MOS_COORDINATION_PORT,
|
||||||
|
MosCoordinationService,
|
||||||
|
} from './mos-coordination.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [CoordService],
|
providers: [
|
||||||
controllers: [CoordController],
|
CoordService,
|
||||||
exports: [CoordService],
|
{
|
||||||
|
provide: MOS_COORDINATION_PORT,
|
||||||
|
useFactory: (): InMemoryMosCoordinationPort => new InMemoryMosCoordinationPort(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MOS_COORDINATION_CONFIG,
|
||||||
|
useFactory: () => ({
|
||||||
|
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
|
||||||
|
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
MosCoordinationService,
|
||||||
|
],
|
||||||
|
controllers: [CoordController, MosCoordinationController],
|
||||||
|
exports: [CoordService, MosCoordinationService],
|
||||||
})
|
})
|
||||||
export class CoordModule {}
|
export class CoordModule {}
|
||||||
|
|||||||
36
apps/gateway/src/coord/mos-coordination.controller.test.ts
Normal file
36
apps/gateway/src/coord/mos-coordination.controller.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { MosCoordinationController } from './mos-coordination.controller.js';
|
||||||
|
|
||||||
|
const user = { id: 'operator-1', tenantId: 'tenant-a' };
|
||||||
|
|
||||||
|
describe('MosCoordinationController', () => {
|
||||||
|
it('derives actor and tenant from the authenticated user rather than handoff input', async () => {
|
||||||
|
const coordination = {
|
||||||
|
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
||||||
|
observe: vi.fn(),
|
||||||
|
result: vi.fn(),
|
||||||
|
};
|
||||||
|
const controller = new MosCoordinationController(coordination as never);
|
||||||
|
|
||||||
|
await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1');
|
||||||
|
|
||||||
|
expect(coordination.handoff).toHaveBeenCalledWith(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement' },
|
||||||
|
expect.objectContaining({
|
||||||
|
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a correlation header before invoking the coordination service', async () => {
|
||||||
|
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
|
||||||
|
const controller = new MosCoordinationController(coordination as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),
|
||||||
|
).rejects.toThrow('X-Correlation-Id is required');
|
||||||
|
expect(coordination.handoff).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
73
apps/gateway/src/coord/mos-coordination.controller.ts
Normal file
73
apps/gateway/src/coord/mos-coordination.controller.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
|
import type {
|
||||||
|
MosCoordinationObservationDto,
|
||||||
|
MosCoordinationResponseDto,
|
||||||
|
MosCoordinationResultDto,
|
||||||
|
CreateMosHandoffDto,
|
||||||
|
} from './mos-coordination.dto.js';
|
||||||
|
import { MosCoordinationService } from './mos-coordination.service.js';
|
||||||
|
|
||||||
|
/** Authenticated interaction-plane boundary for the handoff/observe/result-only Mos contract. */
|
||||||
|
@Controller('api/coord/mos')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class MosCoordinationController {
|
||||||
|
constructor(
|
||||||
|
@Inject(MosCoordinationService) private readonly coordination: MosCoordinationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('handoff')
|
||||||
|
async handoff(
|
||||||
|
@Body() request: CreateMosHandoffDto,
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
): Promise<MosCoordinationResponseDto> {
|
||||||
|
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':handoffId/observe')
|
||||||
|
async observe(
|
||||||
|
@Param('handoffId') handoffId: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
): Promise<MosCoordinationObservationDto> {
|
||||||
|
return {
|
||||||
|
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':handoffId/result')
|
||||||
|
async result(
|
||||||
|
@Param('handoffId') handoffId: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
): Promise<MosCoordinationResultDto> {
|
||||||
|
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private context(
|
||||||
|
user: AuthenticatedUserLike,
|
||||||
|
correlationId?: string,
|
||||||
|
): RuntimeProviderRequestContext {
|
||||||
|
const requestCorrelationId = correlationId?.trim();
|
||||||
|
if (!requestCorrelationId) throw new ForbiddenException('X-Correlation-Id is required');
|
||||||
|
return {
|
||||||
|
actorScope: scopeFromUser(user),
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: requestCorrelationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/gateway/src/coord/mos-coordination.dto.ts
Normal file
25
apps/gateway/src/coord/mos-coordination.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type {
|
||||||
|
CoordinationObservation,
|
||||||
|
CoordinationResult,
|
||||||
|
MosHandoffReceipt,
|
||||||
|
} from '@mosaicstack/coord';
|
||||||
|
|
||||||
|
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
|
||||||
|
export interface CreateMosHandoffDto {
|
||||||
|
idempotencyKey: string;
|
||||||
|
summary: string;
|
||||||
|
context?: string;
|
||||||
|
missionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosCoordinationResponseDto {
|
||||||
|
receipt: MosHandoffReceipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosCoordinationObservationDto {
|
||||||
|
observation: CoordinationObservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosCoordinationResultDto {
|
||||||
|
result: CoordinationResult;
|
||||||
|
}
|
||||||
217
apps/gateway/src/coord/mos-coordination.service.test.ts
Normal file
217
apps/gateway/src/coord/mos-coordination.service.test.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
InMemoryMosCoordinationPort,
|
||||||
|
type MosCoordinationPort,
|
||||||
|
type MosHandoff,
|
||||||
|
} from '@mosaicstack/coord';
|
||||||
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
|
import {
|
||||||
|
MosCoordinationService,
|
||||||
|
type MosCoordinationConfig,
|
||||||
|
type MosCoordinationGatewayError,
|
||||||
|
} from './mos-coordination.service.js';
|
||||||
|
|
||||||
|
const context: RuntimeProviderRequestContext = {
|
||||||
|
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: MosCoordinationConfig = {
|
||||||
|
interactionAgentId: 'Nova',
|
||||||
|
orchestrationAgentId: 'Conductor',
|
||||||
|
};
|
||||||
|
|
||||||
|
function service(
|
||||||
|
port: MosCoordinationPort = new InMemoryMosCoordinationPort(),
|
||||||
|
options: {
|
||||||
|
config?: MosCoordinationConfig;
|
||||||
|
handoffIdFactory?: () => string;
|
||||||
|
} = {},
|
||||||
|
): MosCoordinationService {
|
||||||
|
return new MosCoordinationService(
|
||||||
|
port,
|
||||||
|
options.config ?? config,
|
||||||
|
options.handoffIdFactory ?? (() => 'handoff-1'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MosCoordinationService authority boundary', (): void => {
|
||||||
|
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
const coordination = service(adapter);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).resolves.toEqual({
|
||||||
|
handoffId: 'handoff-1',
|
||||||
|
targetAgentId: 'Conductor',
|
||||||
|
status: 'queued',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request');
|
||||||
|
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
|
||||||
|
|
||||||
|
const followUpContext = { ...context, correlationId: 'corr-2' };
|
||||||
|
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||||
|
targetAgentId: 'Conductor',
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||||
|
targetAgentId: 'Conductor',
|
||||||
|
status: 'completed',
|
||||||
|
summary: 'Merged by Mos',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
const handoff = vi.spyOn(adapter, 'handoff');
|
||||||
|
const coordination = service(adapter, {
|
||||||
|
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'unconfigured_requester',
|
||||||
|
} satisfies Partial<MosCoordinationGatewayError>);
|
||||||
|
expect(handoff).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
const handoff = vi.spyOn(adapter, 'handoff');
|
||||||
|
const coordination = service(adapter, {
|
||||||
|
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Interaction and orchestration identities must differ');
|
||||||
|
expect(handoff).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
const observe = vi.spyOn(adapter, 'observe');
|
||||||
|
const result = vi.spyOn(adapter, 'result');
|
||||||
|
const coordination = service(adapter);
|
||||||
|
await coordination.handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
const otherTenant = {
|
||||||
|
...context,
|
||||||
|
actorScope: { ...context.actorScope, tenantId: 'tenant-b' },
|
||||||
|
};
|
||||||
|
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||||
|
code: 'cross_tenant_forbidden',
|
||||||
|
} satisfies Partial<MosCoordinationGatewayError>);
|
||||||
|
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||||
|
code: 'cross_tenant_forbidden',
|
||||||
|
} satisfies Partial<MosCoordinationGatewayError>);
|
||||||
|
expect(observe).not.toHaveBeenCalled();
|
||||||
|
expect(result).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes idempotency by actor and joins concurrent retries without duplicate delivery', async (): Promise<void> => {
|
||||||
|
let handoffSequence = 0;
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
const delivered = new Promise<void>((resolve: () => void): void => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const adapter: MosCoordinationPort = {
|
||||||
|
handoff: vi.fn(async (handoff: MosHandoff) => {
|
||||||
|
await delivered;
|
||||||
|
return {
|
||||||
|
handoffId: handoff.handoffId,
|
||||||
|
targetAgentId: handoff.targetAgentId,
|
||||||
|
status: 'queued' as const,
|
||||||
|
correlationId: handoff.scope.correlationId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
observe: vi.fn(),
|
||||||
|
result: vi.fn(),
|
||||||
|
};
|
||||||
|
const coordination = service(adapter, {
|
||||||
|
handoffIdFactory: (): string => `handoff-${++handoffSequence}`,
|
||||||
|
});
|
||||||
|
const request = { idempotencyKey: 'request-1', summary: 'Implement the requested feature' };
|
||||||
|
|
||||||
|
const first = coordination.handoff(request, context);
|
||||||
|
const retry = coordination.handoff(request, context);
|
||||||
|
expect(adapter.handoff).toHaveBeenCalledTimes(1);
|
||||||
|
release?.();
|
||||||
|
await expect(Promise.all([first, retry])).resolves.toEqual([
|
||||||
|
expect.objectContaining({ handoffId: 'handoff-1' }),
|
||||||
|
expect.objectContaining({ handoffId: 'handoff-1' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff(request, {
|
||||||
|
...context,
|
||||||
|
actorScope: { ...context.actorScope, userId: 'operator-2' },
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ handoffId: 'handoff-2' });
|
||||||
|
expect(adapter.handoff).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
const handoff = vi.spyOn(adapter, 'handoff');
|
||||||
|
const coordination = service(adapter);
|
||||||
|
await coordination.handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'handoff_conflict',
|
||||||
|
} satisfies Partial<MosCoordinationGatewayError>);
|
||||||
|
await expect(
|
||||||
|
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'invalid_request',
|
||||||
|
} satisfies Partial<MosCoordinationGatewayError>);
|
||||||
|
await expect(
|
||||||
|
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'invalid_request',
|
||||||
|
} satisfies Partial<MosCoordinationGatewayError>);
|
||||||
|
expect(handoff).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
|
||||||
|
const adapter: MosCoordinationPort = {
|
||||||
|
handoff: vi.fn(async (handoff: MosHandoff) => ({
|
||||||
|
handoffId: handoff.handoffId,
|
||||||
|
targetAgentId: 'Unexpected',
|
||||||
|
status: 'accepted' as const,
|
||||||
|
correlationId: handoff.scope.correlationId,
|
||||||
|
})),
|
||||||
|
observe: vi.fn(),
|
||||||
|
result: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service(adapter).handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: 'target_drift' });
|
||||||
|
});
|
||||||
|
});
|
||||||
300
apps/gateway/src/coord/mos-coordination.service.ts
Normal file
300
apps/gateway/src/coord/mos-coordination.service.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
MosCoordinationClient,
|
||||||
|
type CoordinationObservation,
|
||||||
|
type CoordinationResult,
|
||||||
|
type CoordinationScope,
|
||||||
|
type MosCoordinationIdentity,
|
||||||
|
type MosCoordinationPort,
|
||||||
|
type MosHandoffReceipt,
|
||||||
|
} from '@mosaicstack/coord';
|
||||||
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
|
import type { CreateMosHandoffDto } from './mos-coordination.dto.js';
|
||||||
|
|
||||||
|
export const MOS_COORDINATION_PORT = Symbol('MOS_COORDINATION_PORT');
|
||||||
|
export const MOS_COORDINATION_CONFIG = Symbol('MOS_COORDINATION_CONFIG');
|
||||||
|
|
||||||
|
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||||
|
const MAX_TRACKED_HANDOFFS = 1_000;
|
||||||
|
const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
|
||||||
|
const MAX_SUMMARY_LENGTH = 2_048;
|
||||||
|
const MAX_CONTEXT_LENGTH = 8_192;
|
||||||
|
const MAX_MISSION_ID_LENGTH = 128;
|
||||||
|
|
||||||
|
export interface MosCoordinationConfig {
|
||||||
|
interactionAgentId?: string;
|
||||||
|
orchestrationAgentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HandoffOwner {
|
||||||
|
actorId: string;
|
||||||
|
tenantId: string;
|
||||||
|
requesterAgentId: string;
|
||||||
|
correlationId: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NormalizedMosHandoffRequest {
|
||||||
|
idempotencyKey: string;
|
||||||
|
summary: string;
|
||||||
|
context?: string;
|
||||||
|
missionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrackedHandoff {
|
||||||
|
request: NormalizedMosHandoffRequest;
|
||||||
|
receipt: Promise<MosHandoffReceipt>;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway authority boundary for the interaction agent. It derives requester,
|
||||||
|
* actor, and tenant from trusted server configuration and authentication; no
|
||||||
|
* channel request can name a target or gain Mos-owned orchestration verbs.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MosCoordinationService {
|
||||||
|
private readonly owners = new Map<string, HandoffOwner>();
|
||||||
|
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(MOS_COORDINATION_PORT) private readonly port: MosCoordinationPort,
|
||||||
|
@Inject(MOS_COORDINATION_CONFIG) private readonly config: MosCoordinationConfig,
|
||||||
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handoff(
|
||||||
|
request: CreateMosHandoffDto,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<MosHandoffReceipt> {
|
||||||
|
this.pruneExpiredTracking();
|
||||||
|
const normalized = this.normalizeRequest(request);
|
||||||
|
const scope = this.scope(context);
|
||||||
|
const idempotencyKey = this.idempotencyKey(normalized.idempotencyKey, scope);
|
||||||
|
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
if (!sameRequest(existing.request, normalized)) {
|
||||||
|
throw new MosCoordinationGatewayError(
|
||||||
|
'handoff_conflict',
|
||||||
|
'Mos handoff idempotency key is already bound to different immutable input',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return existing.receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = this.deliverHandoff(this.handoffIdFactory(), normalized, scope);
|
||||||
|
const tracked: TrackedHandoff = {
|
||||||
|
request: normalized,
|
||||||
|
receipt: pending,
|
||||||
|
expiresAt: this.expiresAt(),
|
||||||
|
};
|
||||||
|
this.handoffsByIdempotencyKey.set(idempotencyKey, tracked);
|
||||||
|
this.enforceTrackingLimit(this.handoffsByIdempotencyKey);
|
||||||
|
try {
|
||||||
|
return await pending;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (this.handoffsByIdempotencyKey.get(idempotencyKey) === tracked) {
|
||||||
|
this.handoffsByIdempotencyKey.delete(idempotencyKey);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async observe(
|
||||||
|
handoffId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<CoordinationObservation> {
|
||||||
|
this.pruneExpiredTracking();
|
||||||
|
const scope = this.scope(context);
|
||||||
|
const owner = this.ownerFor(handoffId, scope);
|
||||||
|
return this.client().observe(handoffId, { ...scope, correlationId: owner.correlationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async result(
|
||||||
|
handoffId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<CoordinationResult> {
|
||||||
|
this.pruneExpiredTracking();
|
||||||
|
const scope = this.scope(context);
|
||||||
|
const owner = this.ownerFor(handoffId, scope);
|
||||||
|
return this.client().result(handoffId, { ...scope, correlationId: owner.correlationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverHandoff(
|
||||||
|
handoffId: string,
|
||||||
|
request: NormalizedMosHandoffRequest,
|
||||||
|
scope: CoordinationScope,
|
||||||
|
): Promise<MosHandoffReceipt> {
|
||||||
|
const receipt = await this.client((): string => handoffId).handoff(request, scope);
|
||||||
|
const owner: HandoffOwner = {
|
||||||
|
actorId: scope.actorId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
requesterAgentId: scope.requesterAgentId,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
expiresAt: this.expiresAt(),
|
||||||
|
};
|
||||||
|
const existing = this.owners.get(receipt.handoffId);
|
||||||
|
if (existing !== undefined && !sameOwner(existing, owner)) {
|
||||||
|
throw new MosCoordinationGatewayError(
|
||||||
|
'handoff_conflict',
|
||||||
|
'Mos handoff ID is already bound to a different authenticated scope',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.owners.set(receipt.handoffId, owner);
|
||||||
|
this.enforceTrackingLimit(this.owners);
|
||||||
|
return receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private client(handoffIdFactory?: () => string): MosCoordinationClient {
|
||||||
|
return new MosCoordinationClient(this.identity(), this.port, handoffIdFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private identity(): MosCoordinationIdentity {
|
||||||
|
const interactionAgentId = this.config.interactionAgentId?.trim();
|
||||||
|
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
|
||||||
|
if (!interactionAgentId) {
|
||||||
|
throw new MosCoordinationGatewayError(
|
||||||
|
'unconfigured_requester',
|
||||||
|
'Interaction agent identity is not configured',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!orchestrationAgentId) {
|
||||||
|
throw new MosCoordinationGatewayError(
|
||||||
|
'unconfigured_target',
|
||||||
|
'Orchestration agent identity is not configured',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { interactionAgentId, orchestrationAgentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private scope(context: RuntimeProviderRequestContext): CoordinationScope {
|
||||||
|
const identity = this.identity();
|
||||||
|
return Object.freeze({
|
||||||
|
actorId: context.actorScope.userId,
|
||||||
|
tenantId: context.actorScope.tenantId,
|
||||||
|
correlationId: context.correlationId,
|
||||||
|
requesterAgentId: identity.interactionAgentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRequest(request: CreateMosHandoffDto): NormalizedMosHandoffRequest {
|
||||||
|
if (typeof request !== 'object' || request === null) {
|
||||||
|
throw new MosCoordinationGatewayError('invalid_request', 'Mos handoff request is invalid');
|
||||||
|
}
|
||||||
|
const idempotencyKey = this.requiredString(
|
||||||
|
request.idempotencyKey,
|
||||||
|
'idempotency key',
|
||||||
|
MAX_IDEMPOTENCY_KEY_LENGTH,
|
||||||
|
);
|
||||||
|
const summary = this.requiredString(request.summary, 'summary', MAX_SUMMARY_LENGTH);
|
||||||
|
const context = this.optionalString(request.context, 'context', MAX_CONTEXT_LENGTH);
|
||||||
|
const missionId = this.optionalString(request.missionId, 'mission ID', MAX_MISSION_ID_LENGTH);
|
||||||
|
return Object.freeze({
|
||||||
|
idempotencyKey,
|
||||||
|
summary,
|
||||||
|
...(context === undefined ? {} : { context }),
|
||||||
|
...(missionId === undefined ? {} : { missionId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private idempotencyKey(requestKey: string, scope: CoordinationScope): string {
|
||||||
|
return `${scope.tenantId}\u0000${scope.actorId}\u0000${scope.requesterAgentId}\u0000${requestKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requiredString(value: unknown, field: string, maximumLength: number): string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new MosCoordinationGatewayError(
|
||||||
|
'invalid_request',
|
||||||
|
`Mos handoff ${field} must be a string`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (normalized.length === 0 || normalized.length > maximumLength) {
|
||||||
|
throw new MosCoordinationGatewayError('invalid_request', `Mos handoff ${field} is invalid`);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private optionalString(value: unknown, field: string, maximumLength: number): string | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
return this.requiredString(value, field, maximumLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
private expiresAt(): number {
|
||||||
|
return Date.now() + HANDOFF_TRACKING_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneExpiredTracking(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, tracked] of this.handoffsByIdempotencyKey) {
|
||||||
|
if (tracked.expiresAt <= now) this.handoffsByIdempotencyKey.delete(key);
|
||||||
|
}
|
||||||
|
for (const [key, owner] of this.owners) {
|
||||||
|
if (owner.expiresAt <= now) this.owners.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enforceTrackingLimit<T>(entries: Map<string, T>): void {
|
||||||
|
while (entries.size > MAX_TRACKED_HANDOFFS) {
|
||||||
|
const oldest = entries.keys().next().value;
|
||||||
|
if (typeof oldest !== 'string') return;
|
||||||
|
entries.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
|
||||||
|
const owner = this.owners.get(handoffId);
|
||||||
|
if (owner === undefined) {
|
||||||
|
throw new MosCoordinationGatewayError('not_found', 'Mos handoff was not found');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
owner.tenantId !== scope.tenantId ||
|
||||||
|
owner.actorId !== scope.actorId ||
|
||||||
|
owner.requesterAgentId !== scope.requesterAgentId
|
||||||
|
) {
|
||||||
|
throw new MosCoordinationGatewayError(
|
||||||
|
'cross_tenant_forbidden',
|
||||||
|
'Mos handoff is outside the authenticated scope',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MosCoordinationGatewayErrorCode =
|
||||||
|
| 'cross_tenant_forbidden'
|
||||||
|
| 'handoff_conflict'
|
||||||
|
| 'invalid_request'
|
||||||
|
| 'not_found'
|
||||||
|
| 'unconfigured_requester'
|
||||||
|
| 'unconfigured_target';
|
||||||
|
|
||||||
|
function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean {
|
||||||
|
return (
|
||||||
|
left.actorId === right.actorId &&
|
||||||
|
left.tenantId === right.tenantId &&
|
||||||
|
left.requesterAgentId === right.requesterAgentId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameRequest(
|
||||||
|
left: NormalizedMosHandoffRequest,
|
||||||
|
right: NormalizedMosHandoffRequest,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
left.idempotencyKey === right.idempotencyKey &&
|
||||||
|
left.summary === right.summary &&
|
||||||
|
left.context === right.context &&
|
||||||
|
left.missionId === right.missionId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MosCoordinationGatewayError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: MosCoordinationGatewayErrorCode,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = MosCoordinationGatewayError.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,7 +108,7 @@ describe('SessionGCService', () => {
|
|||||||
} as never;
|
} as never;
|
||||||
const payload = { command: 'gc', conversationId: 'owned' };
|
const payload = { command: 'gc', conversationId: 'owned' };
|
||||||
const approval = await authorization.createApproval(command, payload, 'admin-1');
|
const approval = await authorization.createApproval(command, payload, 'admin-1');
|
||||||
const approvalKey = `tess:command-approval:${approval!.approvalId}`;
|
const approvalKey = `interaction:command-approval:${approval!.approvalId}`;
|
||||||
const gc = new SessionGCService(redis as never, mockLogService as unknown as LogService);
|
const gc = new SessionGCService(redis as never, mockLogService as unknown as LogService);
|
||||||
|
|
||||||
await gc.collect('owned');
|
await gc.collect('owned');
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import {
|
|||||||
createMemory,
|
createMemory,
|
||||||
type Memory,
|
type Memory,
|
||||||
createMemoryAdapter,
|
createMemoryAdapter,
|
||||||
|
createOperatorMemoryPlugin,
|
||||||
type MemoryAdapter,
|
type MemoryAdapter,
|
||||||
type MemoryConfig,
|
type MemoryConfig,
|
||||||
|
type OperatorMemoryPlugin,
|
||||||
} from '@mosaicstack/memory';
|
} from '@mosaicstack/memory';
|
||||||
import type { Db } from '@mosaicstack/db';
|
import type { Db } from '@mosaicstack/db';
|
||||||
import type { StorageAdapter } from '@mosaicstack/storage';
|
import type { StorageAdapter } from '@mosaicstack/storage';
|
||||||
@@ -14,6 +16,9 @@ import { DB, STORAGE_ADAPTER } from '../database/database.module.js';
|
|||||||
import { MEMORY } from './memory.tokens.js';
|
import { MEMORY } from './memory.tokens.js';
|
||||||
import { MemoryController } from './memory.controller.js';
|
import { MemoryController } from './memory.controller.js';
|
||||||
import { EmbeddingService } from './embedding.service.js';
|
import { EmbeddingService } from './embedding.service.js';
|
||||||
|
import { redactSensitiveContent } from '@mosaicstack/log';
|
||||||
|
|
||||||
|
export const OPERATOR_MEMORY_PLUGIN = 'OPERATOR_MEMORY_PLUGIN';
|
||||||
|
|
||||||
export const MEMORY_ADAPTER = 'MEMORY_ADAPTER';
|
export const MEMORY_ADAPTER = 'MEMORY_ADAPTER';
|
||||||
|
|
||||||
@@ -38,9 +43,24 @@ function buildMemoryConfig(config: MosaicConfig, storageAdapter: StorageAdapter)
|
|||||||
createMemoryAdapter(buildMemoryConfig(config, storageAdapter)),
|
createMemoryAdapter(buildMemoryConfig(config, storageAdapter)),
|
||||||
inject: [MOSAIC_CONFIG, STORAGE_ADAPTER],
|
inject: [MOSAIC_CONFIG, STORAGE_ADAPTER],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: OPERATOR_MEMORY_PLUGIN,
|
||||||
|
useFactory: (adapter: MemoryAdapter): OperatorMemoryPlugin | null => {
|
||||||
|
const instanceId = process.env['MOSAIC_OPERATOR_MEMORY_INSTANCE_ID']?.trim();
|
||||||
|
const namespace = process.env['MOSAIC_OPERATOR_MEMORY_NAMESPACE']?.trim();
|
||||||
|
if (!instanceId || !namespace) return null;
|
||||||
|
return createOperatorMemoryPlugin({
|
||||||
|
adapter,
|
||||||
|
instanceId,
|
||||||
|
namespace,
|
||||||
|
redact: (content) => redactSensitiveContent(content).content,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
inject: [MEMORY_ADAPTER],
|
||||||
|
},
|
||||||
EmbeddingService,
|
EmbeddingService,
|
||||||
],
|
],
|
||||||
controllers: [MemoryController],
|
controllers: [MemoryController],
|
||||||
exports: [MEMORY, MEMORY_ADAPTER, EmbeddingService],
|
exports: [MEMORY, MEMORY_ADAPTER, OPERATOR_MEMORY_PLUGIN, EmbeddingService],
|
||||||
})
|
})
|
||||||
export class MemoryModule {}
|
export class MemoryModule {}
|
||||||
|
|||||||
@@ -1,13 +1,138 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
createDiscordIngressEnvelope,
|
createDiscordIngressEnvelope,
|
||||||
verifyDiscordIngressEnvelope,
|
verifyDiscordIngressEnvelope,
|
||||||
|
DiscordPlugin,
|
||||||
type DiscordIngressPayload,
|
type DiscordIngressPayload,
|
||||||
|
parseDiscordInteractionBindings,
|
||||||
|
resolveDiscordInteractionActorId,
|
||||||
|
resolveDiscordInteractionBinding,
|
||||||
} from '@mosaicstack/discord-plugin';
|
} from '@mosaicstack/discord-plugin';
|
||||||
|
import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js';
|
||||||
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||||
|
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
||||||
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
||||||
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
||||||
|
|
||||||
const SERVICE_TOKEN = 'test-service-token';
|
const SERVICE_TOKEN = 'test-service-token';
|
||||||
|
const ENV_KEYS = [
|
||||||
|
'DISCORD_SERVICE_TOKEN',
|
||||||
|
'DISCORD_SERVICE_USER_ID',
|
||||||
|
'DISCORD_SERVICE_TENANT_ID',
|
||||||
|
'DISCORD_INTERACTION_BINDINGS',
|
||||||
|
'DISCORD_ALLOWED_GUILD_IDS',
|
||||||
|
'DISCORD_ALLOWED_CHANNEL_IDS',
|
||||||
|
'DISCORD_ALLOWED_USER_IDS',
|
||||||
|
'MOSAIC_AGENT_NAME',
|
||||||
|
] as const;
|
||||||
|
const savedEnv = new Map<string, string | undefined>();
|
||||||
|
|
||||||
|
function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
|
||||||
|
for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]);
|
||||||
|
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
|
||||||
|
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
|
||||||
|
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': {
|
||||||
|
role: role === 'admin' ? 'admin' : 'operator',
|
||||||
|
mosaicUserId: 'mosaic-admin-001',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
for (const key of ENV_KEYS) {
|
||||||
|
const value = savedEnv.get(key);
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
savedEnv.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function commandAuthorization(role: 'admin' | 'member'): CommandAuthorizationService {
|
||||||
|
const entries = new Map<string, string>();
|
||||||
|
const db = {
|
||||||
|
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
||||||
|
};
|
||||||
|
const redis = {
|
||||||
|
get: async (key: string) => entries.get(key) ?? null,
|
||||||
|
set: async (key: string, value: string) => entries.set(key, value),
|
||||||
|
del: async (key: string) => Number(entries.delete(key)),
|
||||||
|
};
|
||||||
|
return new CommandAuthorizationService(db as never, redis);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discordGateway(role: 'admin' | 'member'): {
|
||||||
|
gateway: ChatGateway;
|
||||||
|
client: { data: { discordService: boolean }; emit: ReturnType<typeof vi.fn> };
|
||||||
|
consumedActions: Array<{ actorId: string; correlationId: string }>;
|
||||||
|
durable: { getSnapshot: ReturnType<typeof vi.fn> };
|
||||||
|
audit: { record: ReturnType<typeof vi.fn> };
|
||||||
|
} {
|
||||||
|
const authorization = commandAuthorization(role);
|
||||||
|
const consumedActions: Array<{ actorId: string; correlationId: string }> = [];
|
||||||
|
const durable = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const audit = { record: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const runtimeRegistry = new RuntimeProviderService(
|
||||||
|
{
|
||||||
|
require: () => ({
|
||||||
|
capabilities: async () => ({ supported: ['session.terminate'] }),
|
||||||
|
terminate: async () => undefined,
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
{ record: async () => undefined } as never,
|
||||||
|
{
|
||||||
|
consume: async (approvalId, action) => {
|
||||||
|
consumedActions.push({ actorId: action.actorId, correlationId: action.correlationId });
|
||||||
|
return authorization.consumeRuntimeTerminationApproval(approvalId, action);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
gateway: new ChatGateway(
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
authorization,
|
||||||
|
runtimeRegistry,
|
||||||
|
durable as never,
|
||||||
|
audit as never,
|
||||||
|
),
|
||||||
|
client: { data: { discordService: true }, emit: vi.fn() },
|
||||||
|
consumedActions,
|
||||||
|
durable,
|
||||||
|
audit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingressEnvelope(
|
||||||
|
content: string,
|
||||||
|
messageId: string,
|
||||||
|
overrides: Partial<DiscordIngressPayload> = {},
|
||||||
|
): ReturnType<typeof createDiscordIngressEnvelope> {
|
||||||
|
return createDiscordIngressEnvelope(
|
||||||
|
createPayload({ content, messageId, ...overrides }),
|
||||||
|
SERVICE_TOKEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
|
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
|
||||||
return {
|
return {
|
||||||
@@ -23,6 +148,42 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Discord ingress security', () => {
|
describe('Discord ingress security', () => {
|
||||||
|
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
|
||||||
|
const [binding] = parseDiscordInteractionBindings(
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: { 'user-001': 'admin' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
resolveDiscordInteractionBinding([binding!], 'guild-001', 'channel-001', 'user-001', 'send'),
|
||||||
|
).toEqual(binding);
|
||||||
|
expect(resolveDiscordInteractionActorId(binding!, 'user-001')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds a differently named configured interaction instance without code changes', () => {
|
||||||
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'guild-001',
|
||||||
|
'channel-001',
|
||||||
|
'user-001',
|
||||||
|
'send',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(binding?.instanceId).toBe('Nova');
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts only the configured Discord service identity', () => {
|
it('accepts only the configured Discord service identity', () => {
|
||||||
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
|
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
|
||||||
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
|
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
|
||||||
@@ -86,4 +247,197 @@ describe('Discord ingress security', () => {
|
|||||||
expect(replayProtector.claim('discord-message-003')).toBe(true);
|
expect(replayProtector.claim('discord-message-003')).toBe(true);
|
||||||
expect(replayProtector.size).toBe(2);
|
expect(replayProtector.size).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('consumes the exact target once when approval and stop are separate Discord messages', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client, consumedActions } = discordGateway('admin');
|
||||||
|
await gateway.handleDiscordApproval(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/approve', 'approve-message', {
|
||||||
|
correlationId: 'approval-ingress-correlation',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const approval = client.emit.mock.calls.find(
|
||||||
|
([event]) => event === 'discord:approval',
|
||||||
|
)?.[1] as {
|
||||||
|
approvalId: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
expect(approval.success).toBe(true);
|
||||||
|
|
||||||
|
await gateway.handleDiscordStop(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope(`/stop ${approval.approvalId}`, 'stop-message', {
|
||||||
|
correlationId: 'stop-ingress-correlation',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
|
||||||
|
correlationId: 'stop-ingress-correlation',
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
expect(consumedActions).toEqual([
|
||||||
|
{
|
||||||
|
actorId: 'mosaic-admin-001',
|
||||||
|
correlationId: expect.stringMatching(/^discord-action:v1:/),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('audits a Discord mint-side authorization denial', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client, audit } = discordGateway('member');
|
||||||
|
|
||||||
|
await gateway.handleDiscordApproval(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/approve', 'denied-approve'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
||||||
|
correlationId: 'correlation-001',
|
||||||
|
success: false,
|
||||||
|
approvalId: undefined,
|
||||||
|
expiresAt: undefined,
|
||||||
|
});
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
outcome: 'denied',
|
||||||
|
operation: 'session.terminate',
|
||||||
|
errorCode: 'policy_denied',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'binding',
|
||||||
|
() => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Other';
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'durable session',
|
||||||
|
(durable: { getSnapshot: ReturnType<typeof vi.fn> }) => {
|
||||||
|
durable.getSnapshot.mockResolvedValueOnce({
|
||||||
|
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
'rejects approval when the %s targets a different runtime agent',
|
||||||
|
async (_source, configure) => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client, durable } = discordGateway('admin');
|
||||||
|
configure(durable);
|
||||||
|
|
||||||
|
await gateway.handleDiscordApproval(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/approve', 'mismatched-agent-approve'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
||||||
|
correlationId: 'correlation-001',
|
||||||
|
success: false,
|
||||||
|
approvalId: undefined,
|
||||||
|
expiresAt: undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client } = discordGateway('member');
|
||||||
|
await gateway.handleDiscordApproval(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/approve', 'member-approve'),
|
||||||
|
);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
||||||
|
correlationId: 'correlation-001',
|
||||||
|
success: false,
|
||||||
|
approvalId: undefined,
|
||||||
|
expiresAt: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([]);
|
||||||
|
await gateway.handleDiscordStop(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/stop forged', 'unpaired-stop'),
|
||||||
|
);
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects replaying a Discord-created termination approval', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client } = discordGateway('admin');
|
||||||
|
await gateway.handleDiscordApproval(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/approve', 'replay-approve', {
|
||||||
|
correlationId: 'replay-approval-correlation',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const approval = client.emit.mock.calls.find(
|
||||||
|
([event]) => event === 'discord:approval',
|
||||||
|
)?.[1] as {
|
||||||
|
approvalId: string;
|
||||||
|
};
|
||||||
|
await gateway.handleDiscordStop(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-one', {
|
||||||
|
correlationId: 'replay-stop-correlation-one',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await gateway.handleDiscordStop(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-two', {
|
||||||
|
correlationId: 'replay-stop-correlation-two',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const stopResults = client.emit.mock.calls.filter(([event]) => event === 'discord:stop');
|
||||||
|
expect(stopResults.map(([, result]) => (result as { success: boolean }).success)).toEqual([
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a thread message through its allowed bound parent channel', () => {
|
||||||
|
const emitted = vi.fn();
|
||||||
|
const plugin = new DiscordPlugin({
|
||||||
|
token: 'unused',
|
||||||
|
gatewayUrl: 'http://unused',
|
||||||
|
serviceToken: SERVICE_TOKEN,
|
||||||
|
allowedGuildIds: ['guild-001'],
|
||||||
|
allowedChannelIds: ['channel-001'],
|
||||||
|
allowedUserIds: ['user-001'],
|
||||||
|
interactionBindings: [
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const internals = plugin as unknown as {
|
||||||
|
client: { user: { id: string } };
|
||||||
|
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
|
||||||
|
handleDiscordMessage(message: unknown): void;
|
||||||
|
};
|
||||||
|
internals.client = { user: { id: 'bot-001' } };
|
||||||
|
internals.socket = { connected: true, emit: emitted };
|
||||||
|
internals.handleDiscordMessage({
|
||||||
|
id: 'thread-message',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'thread-001',
|
||||||
|
author: { id: 'user-001', bot: false },
|
||||||
|
mentions: { has: () => true },
|
||||||
|
content: '<@bot-001> hello from thread',
|
||||||
|
channel: { parentId: 'channel-001' },
|
||||||
|
attachments: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, envelope] = emitted.mock.calls[0] as [
|
||||||
|
string,
|
||||||
|
ReturnType<typeof createDiscordIngressEnvelope>,
|
||||||
|
];
|
||||||
|
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)?.channelId).toBe('channel-001');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type OnModuleDestroy,
|
type OnModuleDestroy,
|
||||||
type OnModuleInit,
|
type OnModuleInit,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DiscordPlugin } from '@mosaicstack/discord-plugin';
|
import { DiscordPlugin, parseDiscordInteractionBindings } from '@mosaicstack/discord-plugin';
|
||||||
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
|
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
|
||||||
import { PluginService } from './plugin.service.js';
|
import { PluginService } from './plugin.service.js';
|
||||||
import type { IChannelPlugin } from './plugin.interface.js';
|
import type { IChannelPlugin } from './plugin.interface.js';
|
||||||
@@ -85,6 +85,9 @@ function createPluginRegistry(): IChannelPlugin[] {
|
|||||||
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
||||||
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
||||||
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
||||||
|
interactionBindings: parseDiscordInteractionBindings(
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'],
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -56,3 +56,11 @@
|
|||||||
**Final focused review:** PASS. Deterministic audit validated all task repository roots with zero missing paths; no planning placeholders remained; security prerequisites still gate Tess exposure; observability traceability is explicit.
|
**Final focused review:** PASS. Deterministic audit validated all task repository roots with zero missing paths; no planning placeholders remained; security prerequisites still gate Tess exposure; observability traceability is explicit.
|
||||||
|
|
||||||
**Current gate:** planning PR must merge to `main` with terminal-green CI before any source-code worker starts.
|
**Current gate:** planning PR must merge to `main` with terminal-green CI before any source-code worker starts.
|
||||||
|
|
||||||
|
## 2026-07-13 — M3 cross-surface delivery
|
||||||
|
|
||||||
|
**Branch:** `feat/tess-m3-integration` from `main` at `84d884b9`.
|
||||||
|
|
||||||
|
**Delivered:** Stable Discord `conversationId` enrollment after a visible provider/runtime session is known; idempotent provider-session rebinding that preserves agent/tenant/owner scope; Discord approval/stop target resolution through the durable snapshot; SSE runtime streaming after CLI attach; denial/audit parity including provider authorization denials and HTTP 403 approval-denial mapping.
|
||||||
|
|
||||||
|
**Evidence:** Gateway targeted suite: 37 tests passed; Mosaic CLI interaction test passed; agent durable-session test passed; gateway and CLI typechecks passed; changed-file format and whitespace checks passed. Codex security review found no confident vulnerability. Code review identified a Fastify exception-response mismatch and two UX/acknowledgement issues; all were corrected before the final validation run.
|
||||||
|
|||||||
40
docs/scratchpads/tess-m2-001-pi-service.md
Normal file
40
docs/scratchpads/tess-m2-001-pi-service.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# TESS-M2-001 — Pi Interaction Service
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add a generic rostered/systemd Pi operator-interaction service in
|
||||||
|
`packages/mosaic/framework`.
|
||||||
|
- Pin the service to `openai/gpt-5.6-sol`, high reasoning, and the
|
||||||
|
`operator-interaction` tool policy.
|
||||||
|
- Keep identity as provisioning data; the product name appears only in the
|
||||||
|
committed example roster.
|
||||||
|
|
||||||
|
## Security and Configuration Invariants
|
||||||
|
|
||||||
|
1. The chosen display/roster name is supplied as data and must exactly match the
|
||||||
|
generic systemd instance.
|
||||||
|
2. The service fails before launch if runtime, model, reasoning, or tool policy
|
||||||
|
differs from the pinned policy.
|
||||||
|
3. Effective-policy output includes only name, runtime, model, reasoning, and
|
||||||
|
tool policy; it does not inspect or output credential variables.
|
||||||
|
4. The default example is replaceable without a source change; the TDD suite
|
||||||
|
provisions `Nova` from the same profile.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- `src/fleet/tess-service-profile.test.ts` proves a `Nova` provisioning path,
|
||||||
|
roster parser/env serialization, effective-policy output, fail-fast drift
|
||||||
|
rejection, and absence of the product name from generic source/profile.
|
||||||
|
- `test-fleet-units.sh` validates the generic interaction systemd unit requires
|
||||||
|
per-agent config and invokes fail-fast startup validation.
|
||||||
|
- `test-start-agent-session.sh` proves the tool-policy value is exported into
|
||||||
|
the Pi pane; `compose-contract.spec.ts` proves it becomes an explicit
|
||||||
|
runtime contract block.
|
||||||
|
- Fresh-worktree dependency install plus root `pnpm typecheck`, `pnpm lint`,
|
||||||
|
`pnpm format:check`, and `pnpm test` passed; package/full fleet suites passed.
|
||||||
|
- Independent Codex code and security reviews passed with no remaining findings.
|
||||||
|
|
||||||
|
## Delivery Notes
|
||||||
|
|
||||||
|
- Branch starts from fresh `origin/main` at `86a50138`.
|
||||||
|
- PR targets `main` and references issue `#708`.
|
||||||
63
docs/scratchpads/tess-m2-002-durable-state.md
Normal file
63
docs/scratchpads/tess-m2-002-durable-state.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# TESS-M2-002 — Durable Tess State
|
||||||
|
|
||||||
|
- **Issue:** #708
|
||||||
|
- **Task:** `TESS-M2-002` / `TESS-STA-001`, `TESS-SEC-007..008`
|
||||||
|
- **Branch:** `feat/tess-durable-state`
|
||||||
|
- **Base:** fresh `origin/main` at `e3b5113be21e51d015fa1ae54572929b2a4acd9f`
|
||||||
|
- **Budget assumption:** 38K task estimate; no explicit cap. Use focused TDD plus workspace validation.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Persist a Tess session's immutable identity, inbox/outbox idempotency state, checkpoints,
|
||||||
|
handoffs, and approval bindings so a new service instance can recover it after a process
|
||||||
|
restart or context compaction without replaying a completed message or applied side effect.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Write recovery/idempotency tests first in `packages/agent/src/tess-durable-session.test.ts`.
|
||||||
|
2. Add transport-neutral durable-state contracts/state machine in `packages/agent`.
|
||||||
|
3. Add canonical PostgreSQL schema/migration and a gateway Drizzle repository adapter.
|
||||||
|
4. Wire gateway service/module and reuse `tess:command-approval:*` durable approval semantics
|
||||||
|
for exact, actor/tenant/action-bound approval consumption.
|
||||||
|
5. Test PGlite restart recovery with separate service instances sharing the same durable DB.
|
||||||
|
6. Document the recovery/compaction operation and update Tess architecture evidence.
|
||||||
|
7. Run focused, cold-cache, workspace, migration, review, commit, push, and open PR to `main`.
|
||||||
|
|
||||||
|
## Required Evidence
|
||||||
|
|
||||||
|
| Requirement | Primary evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| Restart recovery | Test creates a second coordinator over unchanged durable store after simulated process death. |
|
||||||
|
| No duplicate side effects | Duplicate ingress and post-restart dispatch assert one handler/effect invocation. |
|
||||||
|
| Compaction survival | Checkpoint/handoff/reconstructed state preserve the same session identity and pending records. |
|
||||||
|
| Durable approvals | Existing `tess:command-approval` record is consumed only once and survives a new authorization service instance. |
|
||||||
|
| Handoff | Stored handoff is portable and reconstructed without live process state. |
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- Intake complete: PRD AC-TESS-06, threat TM-07/TM-08, and verification matrix reviewed.
|
||||||
|
- Affected surfaces: `packages/agent`, `apps/gateway`, `packages/db`; auth/authorization and DB migration tests required.
|
||||||
|
- TDD is required (security authorization and critical state mutation).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- An external provider action cannot be atomically committed with the database. The outbox
|
||||||
|
gives the receiver a stable idempotency key; generic recovery never replays an ambiguous
|
||||||
|
`processing` effect, and completed effects are never redispatched.
|
||||||
|
- PostgreSQL is canonical; PGlite is the local/restart test implementation.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- TDD red: `pnpm --filter @mosaicstack/agent test src/tess-durable-session.test.ts`
|
||||||
|
initially failed because the durable-state module did not exist.
|
||||||
|
- Focused green: 7 agent state-machine tests; 6 PGlite repository tests (including
|
||||||
|
close/reopen recovery and encrypted-at-rest redaction); 5 durable-approval tests; DB migration tests.
|
||||||
|
- Full cold-cache green: `pnpm turbo run typecheck lint test --force` completed
|
||||||
|
88 tasks with zero cache hits; `pnpm format:check` and `git diff --check` passed.
|
||||||
|
- Fresh worktree dependency install passed with
|
||||||
|
`pnpm install --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store`.
|
||||||
|
The default pnpm store path was inaccessible to this harness, so the explicit
|
||||||
|
user-owned store path was required.
|
||||||
|
- Codex review identified plaintext durable payload risk; resolved by AES-256-GCM sealing
|
||||||
|
after redaction, with an at-rest ciphertext assertion in the PGlite suite.
|
||||||
|
- Pending final clean review, commit, and PR.
|
||||||
46
docs/scratchpads/tess-m4-001-mos-coordination.md
Normal file
46
docs/scratchpads/tess-m4-001-mos-coordination.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# TESS-M4-001 — Mos Coordination
|
||||||
|
|
||||||
|
- **Issue/task:** #710 / TESS-M4-001
|
||||||
|
- **Branch/base:** `feat/tess-mos-coordination` rebased onto `origin/main` `f1c6b37b`
|
||||||
|
- **Budget assumption:** task estimate 25K; design-first and TDD, with package contract plus gateway boundary only.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Implement a transport-neutral coordination contract allowing a configured interaction agent to hand off Mos-owned work, observe activity, and receive results while preventing it from gaining coding/general orchestration authority.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Document the contract and enforcement-point sketch; request Mos's decision on the initial concrete transport.
|
||||||
|
2. Add `@mosaicstack/coord` typed handoff/observe/result contracts and denial errors.
|
||||||
|
3. Add a gateway service which derives actor/tenant/requester identity from trusted context/configuration and validates authority.
|
||||||
|
4. Add contract and gateway boundary tests for configurable identities, self-delegation, target drift, and cross-tenant read denial.
|
||||||
|
5. Run focused, cold-cache, baseline tests; independent review; PR lifecycle.
|
||||||
|
|
||||||
|
## Design checkpoint — 2026-07-12
|
||||||
|
|
||||||
|
Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `InMemoryMosCoordinationPort` for M4. Fleet/tmux remains a documented M5 adapter seam; no Mos-side consumer is built in this task.
|
||||||
|
|
||||||
|
## Progress checkpoint — 2026-07-13
|
||||||
|
|
||||||
|
- Implemented `MosCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`.
|
||||||
|
- Implemented the gateway `MosCoordinationService`, deriving requester identity from trusted configuration and actor/tenant/correlation from authenticated context.
|
||||||
|
- Added contract and gateway boundary tests for configurable identities, native round-trip, unconfigured requester, self-delegation, target drift, and cross-tenant observe/result denial before adapter invocation.
|
||||||
|
- Did not modify `apps/gateway/src/commands/command-authorization.service.ts`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm --filter @mosaicstack/coord test` — PASS (16 tests after authority/idempotency remediation).
|
||||||
|
- `pnpm --filter @mosaicstack/coord build` — PASS.
|
||||||
|
- `pnpm --filter @mosaicstack/gateway test -- mos-coordination.service.test.ts` — PASS (7 tests after authority/idempotency remediation).
|
||||||
|
- Standalone gateway typecheck initially reported missing built workspace packages after fresh worktree setup; root validation builds the workspace graph and passed.
|
||||||
|
- `TURBO_FORCE=true pnpm typecheck` — PASS (42 tasks, 0 cached).
|
||||||
|
- `TURBO_FORCE=true pnpm lint` — PASS (23 tasks, 0 cached after one import-type remediation).
|
||||||
|
- `TURBO_FORCE=true pnpm format:check` — PASS.
|
||||||
|
- `TURBO_FORCE=true pnpm test` — PASS (42 tasks, 0 cached; expected existing integration skips only).
|
||||||
|
|
||||||
|
## Review checkpoint
|
||||||
|
|
||||||
|
- Codex code review found idempotency keys needed actor scope and concurrent retries needed an in-flight reservation; both were remediated with regression coverage.
|
||||||
|
- Codex security review found whitespace-equivalent self-delegation was accepted by the exported client; identities are now normalized before invariant checks, with regression coverage.
|
||||||
|
- Re-review added immutable payload comparison for idempotency reuse, runtime string/size validation, bounded TTL/capacity tracking for gateway and native adapter state, and fresh-correlation follow-up reads; targeted tests pass (16 coord / 7 gateway).
|
||||||
|
- Final Codex security review found no issues. PR #735 was opened from commit `7936e15d`; Woodpecker pipeline #1752 is green.
|
||||||
27
docs/scratchpads/tess-m4-003-operator-plugins.md
Normal file
27
docs/scratchpads/tess-m4-003-operator-plugins.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# TESS-M4-003 — Operator Plugin Foundations
|
||||||
|
|
||||||
|
- **Task:** TESS-M4-003 / TESS-MEM-001
|
||||||
|
- **Branch/base:** `feat/tess-operator-plugins` rebased on `origin/main` `76325ca3`
|
||||||
|
- **Scope:** first leaf-package memory/retrieval slice only; no durable inbox ownership, gateway integration, or Mosaic catalog implementation.
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
Coder4's uncommitted implementation was preserved first in commit `5b99c821` before review. The completion pass corrected the contract so namespace is injected configuration rather than caller-selected scope data, storage keys include tenant/owner/session via collision-safe tuple encoding, and malformed runtime scope values fail closed.
|
||||||
|
|
||||||
|
## Delivered boundary
|
||||||
|
|
||||||
|
- `OperatorMemoryPlugin` exposes `capture`, `search`, `recent`, `stats`, and `startupContext` through `MemoryAdapter` only.
|
||||||
|
- Scope is server-derived `{tenantId, ownerId, sessionId}`; adapter and namespace are configuration, not operation input.
|
||||||
|
- Capture redacts before persistence and records configured instance/namespace/source provenance.
|
||||||
|
- Wildcard retrieval is documented at the `MemoryAdapter` boundary and implemented by the keyword adapter.
|
||||||
|
- Startup context uses a bounded 64-result candidate window, then prioritizes project and flat-file provenance before slicing the configured output limit.
|
||||||
|
- No `Tess` identity is hardcoded in storage keys or defaults; tests use configured `Nova`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm --filter @mosaicstack/memory test` — PASS (32 tests)
|
||||||
|
- `pnpm --filter @mosaicstack/memory typecheck` — PASS
|
||||||
|
- `pnpm --filter @mosaicstack/memory lint` — PASS
|
||||||
|
- `pnpm --filter @mosaicstack/memory build` — PASS
|
||||||
|
- Codex code review — APPROVE after remediation
|
||||||
|
- Codex security review — no findings after runtime scope-validation remediation
|
||||||
@@ -41,7 +41,7 @@ Every call receives an immutable, server-derived actor/tenant/channel scope and
|
|||||||
|
|
||||||
`@mosaicstack/agent` owns the explicit `AgentRuntimeProviderRegistry`; duplicate provider IDs are rejected rather than replaced. Gateway owns `RuntimeProviderService`, which creates a frozen `RuntimeScope` from authenticated `ActorTenantScope` and trusted ingress channel/correlation metadata before every provider call. The service checks the declared provider capability before invoking a side effect and records metadata-only audit events (`providerId`, operation, outcome, actor/tenant/channel, correlation, and resource ID). It never records message bodies, idempotency keys, or approval references.
|
`@mosaicstack/agent` owns the explicit `AgentRuntimeProviderRegistry`; duplicate provider IDs are rejected rather than replaced. Gateway owns `RuntimeProviderService`, which creates a frozen `RuntimeScope` from authenticated `ActorTenantScope` and trusted ingress channel/correlation metadata before every provider call. The service checks the declared provider capability before invoking a side effect and records metadata-only audit events (`providerId`, operation, outcome, actor/tenant/channel, correlation, and resource ID). It never records message bodies, idempotency keys, or approval references.
|
||||||
|
|
||||||
Termination is fail-closed: a runtime approval verifier must consume a one-time, exact action binding for the provider, session, actor, tenant, channel, and correlation ID before `terminate` reaches a provider. Until the durable verifier is wired, the default verifier denies termination. This internal service introduces no HTTP endpoint; later Discord, CLI, MCP, and provider adapters consume the same gateway boundary.
|
Termination is fail-closed: a runtime approval verifier consumes a one-time, exact action binding for the provider, session, actor, tenant, channel, and correlation ID before `terminate` reaches a provider. The verifier reuses the Redis-backed `interaction:command-approval:*` store and its expiry/delete-on-consume semantics; it has no parallel approval store. This internal service introduces no HTTP endpoint; later Discord, CLI, MCP, and provider adapters consume the same gateway boundary.
|
||||||
|
|
||||||
## Authority Model
|
## Authority Model
|
||||||
|
|
||||||
@@ -52,11 +52,49 @@ Termination is fail-closed: a runtime approval verifier must consume a one-time,
|
|||||||
| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently |
|
| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently |
|
||||||
| Provider-specific unsupported action | None | Fail closed; never emulate silently |
|
| Provider-specific unsupported action | None | Fail closed; never emulate silently |
|
||||||
|
|
||||||
|
### Mos Coordination Boundary
|
||||||
|
|
||||||
|
`@mosaicstack/coord` exposes only the transport-neutral `MosCoordinationPort`
|
||||||
|
verbs `handoff`, `observe`, and `result`. Gateway derives the actor, tenant,
|
||||||
|
correlation, and interaction-agent identity from authenticated context plus
|
||||||
|
trusted configuration; callers never provide an orchestration target. It
|
||||||
|
rejects unconfigured identities, self-delegation, target/correlation drift, and
|
||||||
|
cross-tenant handoff reads before an adapter call. No dispatch, assignment,
|
||||||
|
review, merge, or cancellation API exists at this boundary.
|
||||||
|
|
||||||
|
M4 uses a deterministic native in-process queue adapter to prove the handoff →
|
||||||
|
observe → result flow without coupling the contract to tmux. A fleet/tmux
|
||||||
|
adapter is deferred to the M5 live-deployment seam and must implement the same
|
||||||
|
port.
|
||||||
|
|
||||||
## Session and State Model
|
## Session and State Model
|
||||||
|
|
||||||
A Tess session has stable `sessionId`, `tenantId`, `ownerId`, provider/runtime identity, ingress bindings, cursor, checkpoint, inbox/outbox, and idempotency records. Discord and CLI bind to the same authorized session. Ownership is verified server-side on every list/read/attach/send/terminate operation.
|
A Tess session has stable `sessionId`, `tenantId`, `ownerId`, provider/runtime identity, ingress bindings, cursor, checkpoint, inbox/outbox, and idempotency records. Discord and CLI bind to the same authorized session. Ownership is verified server-side on every list/read/attach/send/terminate operation.
|
||||||
|
|
||||||
Valkey may hold ephemeral coordination state; PostgreSQL is canonical for durable session bindings, approvals, audit, checkpoints, inbox/outbox, and idempotency. Pi session files are replay sources, not cross-agent truth.
|
Valkey holds the existing short-lived, one-time command-approval records; PostgreSQL is canonical for durable session bindings, checkpoints, inbox/outbox, and idempotency. Pi session files are replay sources, not cross-agent truth.
|
||||||
|
|
||||||
|
### M2 Durable Recovery
|
||||||
|
|
||||||
|
`@mosaicstack/agent` owns a transport-neutral state machine and `apps/gateway` provides its
|
||||||
|
PostgreSQL adapter. `interaction_sessions` holds immutable identity; inbox/outbox records use a
|
||||||
|
per-session unique idempotency key and transition `pending → processing → processed|delivered`.
|
||||||
|
Checkpoints are immutable history scoped by session and checkpoint ID: the latest checkpoint
|
||||||
|
supports compaction recovery, while a handoff always resolves the exact checkpoint it references.
|
||||||
|
Recovery requeues only interrupted inbox work; an ambiguous `processing` outbox record is preserved
|
||||||
|
until separately authorized reconciliation can establish its external delivery state.
|
||||||
|
|
||||||
|
Provider sends travel through the existing `RuntimeProviderService` with the persisted outbox
|
||||||
|
idempotency key. A normal dispatch claims exactly one outbox record and verifies its stored
|
||||||
|
correlation and channel against the server-derived request scope; it never requeues or drains
|
||||||
|
another live record. Inbox/outbox payloads and checkpoint cursor/summary pass through the existing
|
||||||
|
secret/PII redactor and AES-256-GCM sealing before persistence; decryption occurs only in the
|
||||||
|
scoped gateway repository path, and runtime audit remains metadata-only.
|
||||||
|
|
||||||
|
An external effect cannot share a database transaction. If a process dies after an effect begins
|
||||||
|
but before its terminal outbox transition, automatic recovery does not replay that ambiguous claim.
|
||||||
|
It remains `processing` until separately authorized reconciliation can establish delivery state;
|
||||||
|
completed effects are never redispatched. Operators can therefore restart the gateway/Pi service,
|
||||||
|
reconstruct the session, and resume pending inbox work without relying on process-local state.
|
||||||
|
|
||||||
## Transport Strategy
|
## Transport Strategy
|
||||||
|
|
||||||
@@ -81,3 +119,5 @@ The provider advertises list, tree, read-only attach, send, and terminate. List/
|
|||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Tess runs as a rostered, systemd-supervised Pi agent using GPT-5.6 Sol and high reasoning. Secrets are supplied through approved runtime secret mechanisms. Startup fails when required model, gateway identity, Discord binding, or durable-state dependencies are missing. Health reports effective model/reasoning/tool policy without credential material.
|
Tess runs as a rostered, systemd-supervised Pi agent using GPT-5.6 Sol and high reasoning. Secrets are supplied through approved runtime secret mechanisms. Startup fails when required model, gateway identity, Discord binding, or durable-state dependencies are missing. Health reports effective model/reasoning/tool policy without credential material.
|
||||||
|
|
||||||
|
The interaction-service identity is provisioning data, not a source identifier: the roster and per-agent environment carry the chosen display/roster name into a generic systemd instance. The service rejects a name mismatch or any drift from its pinned Pi/GPT-5.6 Sol/high/operator-interaction effective policy before launch. Its policy printer exposes only those resolved safe fields.
|
||||||
|
|||||||
17
docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md
Normal file
17
docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# TESS-M4-003 Operator Plugin Sketch
|
||||||
|
|
||||||
|
## Memory/retrieval slice — TESS-MEM-001
|
||||||
|
|
||||||
|
Introduce a transport-neutral `OperatorMemoryPlugin` in `packages/memory`. The plugin receives a server-derived `{tenantId, ownerId, sessionId}` scope and delegates to a registered `MemoryAdapter`; adapter and namespace are injected configuration, never caller input. Its operations are `capture`, `search`, `recent`, `stats`, and `startupContext`. Results carry configured instance, provenance, and namespace metadata. Capture/redaction occurs before adapter persistence; startup context uses a bounded candidate window ordered so project/flat-file truth takes precedence within returned material.
|
||||||
|
|
||||||
|
Registration remains replaceable-adapter based: the existing `registerMemoryAdapter(kind, factory)` / `createMemoryAdapter(config)` seam supplies the injected adapter to `createOperatorMemoryPlugin(config)`. Identity and namespace are configuration data; no interaction-agent name is embedded in keys or defaults.
|
||||||
|
|
||||||
|
## Remaining plugin foundations — TESS-PLG-001
|
||||||
|
|
||||||
|
- `packages/agent`: capability descriptors for runtime bootstrap, durable inbox/state hooks, and read-only fleet diagnostics. Each capability advertises supported operations and fails closed when absent.
|
||||||
|
- `packages/mosaic`: a catalog/registration surface for GitOps, fleet diagnostics, runtime bootstrap, Discord, and MCP/skill discovery. Catalog entries describe authority, input schema, and safe/read-only status; they do not invoke provider transports directly.
|
||||||
|
- Gateway/channel adapters consume these contracts through server-derived actor/tenant context and durable session state, preserving the replaceable-adapter boundary.
|
||||||
|
|
||||||
|
## First implementation boundary
|
||||||
|
|
||||||
|
The first PR slice should add the operator-memory plugin contract, configuration-injected adapter seam, scope isolation, provenance-bearing retrieval, and tests for namespace isolation plus a differently named configured instance. Durable inbox/outbox remains owned by the existing `DurableSessionCoordinator`; this plugin only supplies bounded context/capture at lifecycle boundaries.
|
||||||
83
docs/tess/MOS-COORDINATION.md
Normal file
83
docs/tess/MOS-COORDINATION.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Tess–Mos Coordination Contract Sketch
|
||||||
|
|
||||||
|
**Task:** TESS-M4-001 · **PRD:** TESS-MOS-001 / AC-TESS-04
|
||||||
|
|
||||||
|
## Boundary
|
||||||
|
|
||||||
|
Agent identities are deployment data. A configured interaction agent may request
|
||||||
|
Mos-owned work; the configured orchestration agent owns decomposition, worker
|
||||||
|
assignment, reviews, and merge decisions. The interaction agent receives a
|
||||||
|
correlated receipt, read-only activity projection, and terminal result. It has
|
||||||
|
no dispatch, assignment, review, merge, or cancellation operation.
|
||||||
|
|
||||||
|
## `@mosaicstack/coord` interface
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CoordinationScope {
|
||||||
|
readonly actorId: string;
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly requesterAgentId: string; // trusted gateway/configuration data
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MosHandoffRequest {
|
||||||
|
readonly idempotencyKey: string;
|
||||||
|
readonly summary: string;
|
||||||
|
readonly context?: string;
|
||||||
|
readonly missionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MosHandoffReceipt {
|
||||||
|
readonly handoffId: string;
|
||||||
|
readonly targetAgentId: string;
|
||||||
|
readonly status: 'accepted' | 'queued';
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MosHandoff {
|
||||||
|
readonly handoffId: string;
|
||||||
|
readonly targetAgentId: string;
|
||||||
|
readonly request: MosHandoffRequest;
|
||||||
|
readonly scope: CoordinationScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MosCoordinationPort {
|
||||||
|
handoff(handoff: MosHandoff): Promise<MosHandoffReceipt>;
|
||||||
|
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
||||||
|
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The port deliberately omits generic orchestrator verbs. It is tenant- and
|
||||||
|
correlation-scoped; its gateway implementation obtains `actorId`, `tenantId`,
|
||||||
|
and the requester agent from trusted authentication/configuration only.
|
||||||
|
|
||||||
|
## Enforcement point
|
||||||
|
|
||||||
|
`apps/gateway` owns a `MosCoordinationService` boundary that compares the
|
||||||
|
trusted configured requester/target identities and rejects all of the following
|
||||||
|
before calling a transport: unconfigured requester, self-delegation, target
|
||||||
|
identity drift, cross-tenant observe/result lookup, and attempts to observe or
|
||||||
|
receive a result for a handoff outside the originating tenant. The service exposes handoff, observe,
|
||||||
|
and result only, and delegates delivery to an injected adapter.
|
||||||
|
|
||||||
|
M4 ships a native in-process `InMemoryMosCoordinationPort` as the concrete,
|
||||||
|
deterministic adapter. It preserves the immutable handoff ID, tenant, requester
|
||||||
|
identity, and correlation ID while demonstrating the handoff → observe → result
|
||||||
|
round trip. It is a queue/port adapter, not a Mos-side consumer.
|
||||||
|
|
||||||
|
A future fleet/tmux adapter is a documented M5 deployment seam and must
|
||||||
|
implement the same `MosCoordinationPort`; no channel client or interaction
|
||||||
|
runtime calls a transport directly.
|
||||||
|
|
||||||
|
## Required tests
|
||||||
|
|
||||||
|
1. A configured non-default interaction identity can hand off work to a
|
||||||
|
configured non-default orchestration identity and receive its result.
|
||||||
|
2. The gateway passes only server-derived scope/identity to the adapter.
|
||||||
|
3. Self-targeting, target drift, and cross-tenant observe/result all fail closed
|
||||||
|
without invoking the adapter.
|
||||||
|
4. The exported public contract has no worker-dispatch, assignment, review,
|
||||||
|
merge, or cancellation capability.
|
||||||
|
5. The native adapter round-trips queued work, activity, and a host-recorded
|
||||||
|
terminal result without a live fleet dependency.
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V |
|
| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V |
|
||||||
| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V |
|
| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V |
|
||||||
| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V |
|
| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V |
|
||||||
| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V |
|
| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | M4 contract/gateway native-port handoff → observe → result round trip; configurable identity, target-drift and tenant-denial tests; M4-V fleet authority qualification | M4-001, M4-V |
|
||||||
| AC-TESS-05 | TESS-HRM-001, TESS-MEM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix; operator-memory plugin (TESS-MEM-001) reachable end-to-end — env-configured plugin registered + AgentService session-bound server-derived {tenantId,ownerId,sessionId} scoped search/capture, cross-tenant reuse denied before plugin call (M4-W-001 spine: #736 plugin + #739 consumer) | M4-V |
|
| AC-TESS-05 | TESS-HRM-001, TESS-MEM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix; operator-memory plugin (TESS-MEM-001) reachable end-to-end — env-configured plugin registered + AgentService session-bound server-derived {tenantId,ownerId,sessionId} scoped search/capture, cross-tenant reuse denied before plugin call (M4-W-001 spine: #736 plugin + #739 consumer) | M4-V |
|
||||||
| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V |
|
| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V |
|
||||||
| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V |
|
| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V |
|
||||||
|
|||||||
19
docs/tess/hermes-runtime-adapter-design.md
Normal file
19
docs/tess/hermes-runtime-adapter-design.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# TESS-HRM-001 — Hermes runtime adapter boundary
|
||||||
|
|
||||||
|
## Normalized provider surface
|
||||||
|
|
||||||
|
`HermesRuntimeProvider` implements the existing Mosaic-owned `AgentRuntimeProvider` unchanged. Its public surface is therefore `capabilities`, `health`, session list/tree, stream, send, attach/detach, and terminate, accepting only `RuntimeScope`, `RuntimeMessage`, `RuntimeSession`, `RuntimeStreamEvent`, and other types from `@mosaicstack/types`. Provider id is `runtime.hermes`.
|
||||||
|
|
||||||
|
The provider receives a narrow injected `HermesRuntimeTransport`, whose method names and inputs may represent Hermes API operations but whose return values are explicitly private `HermesLegacy*` types defined only in `packages/agent/src/hermes-runtime-provider.ts`. Mapping functions convert those private values to Mosaic sessions, state, hierarchy, and stream events. Capability negotiation maps a supplied Hermes feature inventory onto the fixed Mosaic runtime capability vocabulary; no unknown/ambiguous legacy feature is advertised. Unsupported Mosaic operations throw the typed fail-closed `capability_unsupported` provider error before a transport call.
|
||||||
|
|
||||||
|
## Boundary line
|
||||||
|
|
||||||
|
**Hermes legacy schema ends at `HermesRuntimeTransport` and its private adapter-local `HermesLegacy*` definitions in `packages/agent`.** `packages/types` is never changed to contain a Hermes field, enum, identifier, session shape, status, or capability. `apps/gateway` registers/resolves the provider only through `AgentRuntimeProvider` and receives normalized values only. Identity remains server-derived `RuntimeScope` data and is passed to the injected transport as context, never reconstructed from a legacy response.
|
||||||
|
|
||||||
|
## Initial mapping and safety posture
|
||||||
|
|
||||||
|
- Hermes conversation/thread identifiers map to opaque Mosaic `RuntimeSession.id`; parent linkage maps only when a known parent exists.
|
||||||
|
- Hermes status strings map through a closed lookup to `RuntimeSessionState`; unknown statuses become `failed`, never a permissive active state.
|
||||||
|
- Legacy stream chunks map to `message.delta` / `message.complete`; malformed or unsupported events become a normalized `runtime.error` event.
|
||||||
|
- Send, attach, and terminate require the normalized capability first. `terminate` continues to be approval-bound by the gateway service; the adapter does not weaken gateway authority.
|
||||||
|
- Kanban, skills, memory, tools, and cron are capability-inventory entries for this transitional adapter, not additions to the core runtime contract. They are reported as explicitly unsupported until a Mosaic-owned capability contract exists.
|
||||||
77
packages/agent/src/hermes-runtime-provider.test.ts
Normal file
77
packages/agent/src/hermes-runtime-provider.test.ts
Normal file
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
217
packages/agent/src/hermes-runtime-provider.ts
Normal file
217
packages/agent/src/hermes-runtime-provider.ts
Normal file
@@ -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'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,3 +2,5 @@ export const VERSION = '0.0.0';
|
|||||||
|
|
||||||
export * from './runtime-provider-registry.js';
|
export * from './runtime-provider-registry.js';
|
||||||
export * from './tmux-fleet-runtime-provider.js';
|
export * from './tmux-fleet-runtime-provider.js';
|
||||||
|
export * from './hermes-runtime-provider.js';
|
||||||
|
export * from './tess-durable-session.js';
|
||||||
|
|||||||
305
packages/agent/src/tess-durable-session.test.ts
Normal file
305
packages/agent/src/tess-durable-session.test.ts
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
DurableSessionCoordinator,
|
||||||
|
InMemoryDurableSessionStore,
|
||||||
|
type DurableSessionIdentity,
|
||||||
|
} from './tess-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
packages/agent/src/tess-durable-session.ts
Normal file
498
packages/agent/src/tess-durable-session.ts
Normal 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 Tess 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 Tess 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 Tess 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 Tess 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 Tess 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 Tess 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 Tess 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 Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||||
|
}
|
||||||
|
const existing = state.handoffs.get(input.handoffId);
|
||||||
|
if (existing && !sameHandoff(existing, input)) {
|
||||||
|
throw new Error(`Durable Tess 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 Tess ${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 };
|
||||||
|
}
|
||||||
154
packages/coord/src/__tests__/mos-coordination.test.ts
Normal file
154
packages/coord/src/__tests__/mos-coordination.test.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
InMemoryMosCoordinationPort,
|
||||||
|
MosCoordinationClient,
|
||||||
|
type CoordinationScope,
|
||||||
|
type MosCoordinationAuthorityError,
|
||||||
|
type MosCoordinationPort,
|
||||||
|
} from '../index.js';
|
||||||
|
|
||||||
|
const scope: CoordinationScope = {
|
||||||
|
actorId: 'operator-1',
|
||||||
|
tenantId: 'tenant-a',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
requesterAgentId: 'Nova',
|
||||||
|
};
|
||||||
|
|
||||||
|
function client(
|
||||||
|
port: MosCoordinationPort,
|
||||||
|
handoffIdFactory: () => string = (): string => 'handoff-1',
|
||||||
|
): MosCoordinationClient {
|
||||||
|
return new MosCoordinationClient(
|
||||||
|
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' },
|
||||||
|
port,
|
||||||
|
handoffIdFactory,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MosCoordinationClient', (): void => {
|
||||||
|
it('round-trips handoff, observation, and result through the native port with identities as data', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
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', 'Mos accepted the request');
|
||||||
|
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
|
||||||
|
|
||||||
|
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 Mos',
|
||||||
|
});
|
||||||
|
|
||||||
|
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 Mos work', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client(adapter).handoff(
|
||||||
|
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
|
||||||
|
{ ...scope, requesterAgentId: 'Untrusted' },
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'requester_forbidden',
|
||||||
|
} satisfies Partial<MosCoordinationAuthorityError>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects self-delegation configuration before constructing a client', (): void => {
|
||||||
|
expect(
|
||||||
|
(): MosCoordinationClient =>
|
||||||
|
new MosCoordinationClient(
|
||||||
|
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
||||||
|
new InMemoryMosCoordinationPort(),
|
||||||
|
),
|
||||||
|
).toThrow('Interaction and orchestration identities must differ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects whitespace-equivalent self-delegation identities', (): void => {
|
||||||
|
expect(
|
||||||
|
(): MosCoordinationClient =>
|
||||||
|
new MosCoordinationClient(
|
||||||
|
{ interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' },
|
||||||
|
new InMemoryMosCoordinationPort(),
|
||||||
|
),
|
||||||
|
).toThrow('Interaction and orchestration identities must differ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose another tenant handoff to observe or result', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
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 InMemoryMosCoordinationPort({ 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: MosCoordinationPort = {
|
||||||
|
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<MosCoordinationAuthorityError>);
|
||||||
|
});
|
||||||
|
});
|
||||||
208
packages/coord/src/in-memory-mos-coordination-port.ts
Normal file
208
packages/coord/src/in-memory-mos-coordination-port.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import {
|
||||||
|
type CoordinationObservation,
|
||||||
|
type CoordinationResult,
|
||||||
|
type CoordinationScope,
|
||||||
|
type MosCoordinationActivity,
|
||||||
|
type MosCoordinationPort,
|
||||||
|
type MosHandoff,
|
||||||
|
type MosHandoffReceipt,
|
||||||
|
type MosHandoffStatus,
|
||||||
|
} from './mos-coordination.js';
|
||||||
|
|
||||||
|
const DEFAULT_HANDOFF_TTL_MS = 60 * 60 * 1_000;
|
||||||
|
const DEFAULT_MAX_HANDOFFS = 1_000;
|
||||||
|
|
||||||
|
interface StoredHandoff {
|
||||||
|
readonly handoff: MosHandoff;
|
||||||
|
status: MosHandoffStatus;
|
||||||
|
readonly activity: MosCoordinationActivity[];
|
||||||
|
readonly expiresAt: number;
|
||||||
|
result?: CoordinationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InMemoryMosCoordinationPortOptions {
|
||||||
|
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 MosCoordinationPort without changing interaction-plane callers.
|
||||||
|
*/
|
||||||
|
export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
||||||
|
private readonly handoffs = new Map<string, StoredHandoff>();
|
||||||
|
private readonly now: () => Date;
|
||||||
|
private readonly handoffTtlMs: number;
|
||||||
|
private readonly maxHandoffs: number;
|
||||||
|
|
||||||
|
constructor(options: InMemoryMosCoordinationPortOptions = {}) {
|
||||||
|
this.now = options.now ?? (() => new Date());
|
||||||
|
this.handoffTtlMs = options.handoffTtlMs ?? DEFAULT_HANDOFF_TTL_MS;
|
||||||
|
this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handoff(handoff: MosHandoff): Promise<MosHandoffReceipt> {
|
||||||
|
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: MosHandoffStatus, 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 a Mos 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: MosHandoff, status: MosHandoffStatus): MosHandoffReceipt {
|
||||||
|
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 InMemoryMosCoordinationError('forbidden', 'Handoff scope does not match');
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireHandoff(handoffId: string): StoredHandoff {
|
||||||
|
const stored = this.handoffs.get(handoffId);
|
||||||
|
if (stored === undefined) {
|
||||||
|
throw new InMemoryMosCoordinationError('not_found', 'Handoff was not found');
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSameHandoff(existing: MosHandoff, incoming: MosHandoff): 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 InMemoryMosCoordinationError(
|
||||||
|
'conflict',
|
||||||
|
'Handoff ID is already bound to different immutable input',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InMemoryMosCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found';
|
||||||
|
|
||||||
|
export class InMemoryMosCoordinationError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: InMemoryMosCoordinationErrorCode,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = InMemoryMosCoordinationError.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activity(
|
||||||
|
status: MosHandoffStatus,
|
||||||
|
summary: string,
|
||||||
|
now: () => Date,
|
||||||
|
): MosCoordinationActivity {
|
||||||
|
return { occurredAt: now().toISOString(), status, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyActivity(entry: MosCoordinationActivity): MosCoordinationActivity {
|
||||||
|
return { ...entry };
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotHandoff(handoff: MosHandoff): MosHandoff {
|
||||||
|
return Object.freeze({
|
||||||
|
handoffId: handoff.handoffId,
|
||||||
|
targetAgentId: handoff.targetAgentId,
|
||||||
|
request: Object.freeze({ ...handoff.request }),
|
||||||
|
scope: Object.freeze({ ...handoff.scope }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,23 @@ export { createMission, loadMission, missionFilePath, saveMission } from './miss
|
|||||||
export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.js';
|
export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.js';
|
||||||
export { runTask, resumeTask } from './runner.js';
|
export { runTask, resumeTask } from './runner.js';
|
||||||
export { getMissionStatus, getTaskStatus } from './status.js';
|
export { getMissionStatus, getTaskStatus } from './status.js';
|
||||||
|
export {
|
||||||
|
InMemoryMosCoordinationError,
|
||||||
|
InMemoryMosCoordinationPort,
|
||||||
|
} from './in-memory-mos-coordination-port.js';
|
||||||
|
export { MosCoordinationAuthorityError, MosCoordinationClient } from './mos-coordination.js';
|
||||||
|
export type {
|
||||||
|
CoordinationObservation,
|
||||||
|
CoordinationResult,
|
||||||
|
CoordinationScope,
|
||||||
|
MosCoordinationActivity,
|
||||||
|
MosCoordinationIdentity,
|
||||||
|
MosCoordinationPort,
|
||||||
|
MosHandoff,
|
||||||
|
MosHandoffReceipt,
|
||||||
|
MosHandoffRequest,
|
||||||
|
MosHandoffStatus,
|
||||||
|
} from './mos-coordination.js';
|
||||||
export type {
|
export type {
|
||||||
CreateMissionOptions,
|
CreateMissionOptions,
|
||||||
Mission,
|
Mission,
|
||||||
|
|||||||
207
packages/coord/src/mos-coordination.ts
Normal file
207
packages/coord/src/mos-coordination.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
export type MosHandoffStatus = '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 MosCoordinationIdentity {
|
||||||
|
readonly interactionAgentId: string;
|
||||||
|
readonly orchestrationAgentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosHandoffRequest {
|
||||||
|
readonly idempotencyKey: string;
|
||||||
|
readonly summary: string;
|
||||||
|
readonly context?: string;
|
||||||
|
readonly missionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosHandoff {
|
||||||
|
readonly handoffId: string;
|
||||||
|
readonly targetAgentId: string;
|
||||||
|
readonly request: MosHandoffRequest;
|
||||||
|
readonly scope: CoordinationScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosHandoffReceipt {
|
||||||
|
readonly handoffId: string;
|
||||||
|
readonly targetAgentId: string;
|
||||||
|
readonly status: 'queued' | 'accepted';
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosCoordinationActivity {
|
||||||
|
readonly occurredAt: string;
|
||||||
|
readonly status: MosHandoffStatus;
|
||||||
|
readonly summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoordinationObservation {
|
||||||
|
readonly handoffId: string;
|
||||||
|
readonly targetAgentId: string;
|
||||||
|
readonly status: MosHandoffStatus;
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly activity: readonly MosCoordinationActivity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 MosCoordinationPort {
|
||||||
|
handoff(handoff: MosHandoff): Promise<MosHandoffReceipt>;
|
||||||
|
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
||||||
|
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MosCoordinationAuthorityErrorCode =
|
||||||
|
| 'invalid_identity'
|
||||||
|
| 'requester_forbidden'
|
||||||
|
| 'target_drift'
|
||||||
|
| 'correlation_drift';
|
||||||
|
|
||||||
|
export class MosCoordinationAuthorityError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: MosCoordinationAuthorityErrorCode,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = MosCoordinationAuthorityError.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforces the interaction-to-orchestration authority boundary before a
|
||||||
|
* transport is reached. Identity names remain configuration data.
|
||||||
|
*/
|
||||||
|
export class MosCoordinationClient {
|
||||||
|
private readonly identity: MosCoordinationIdentity;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
identity: MosCoordinationIdentity,
|
||||||
|
private readonly port: MosCoordinationPort,
|
||||||
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||||
|
) {
|
||||||
|
this.identity = normalizeIdentity(identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handoff(request: MosHandoffRequest, scope: CoordinationScope): Promise<MosHandoffReceipt> {
|
||||||
|
this.assertRequester(scope);
|
||||||
|
const handoff: MosHandoff = {
|
||||||
|
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 MosCoordinationAuthorityError(
|
||||||
|
'target_drift',
|
||||||
|
'Mos coordination transport returned a mismatched handoff target',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (receipt.correlationId !== handoff.scope.correlationId) {
|
||||||
|
throw new MosCoordinationAuthorityError(
|
||||||
|
'correlation_drift',
|
||||||
|
'Mos 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 MosCoordinationAuthorityError(
|
||||||
|
'requester_forbidden',
|
||||||
|
'Requester is not the configured interaction agent',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertObservation(
|
||||||
|
observation: CoordinationObservation,
|
||||||
|
scope: CoordinationScope,
|
||||||
|
): CoordinationObservation {
|
||||||
|
if (observation.targetAgentId !== this.identity.orchestrationAgentId) {
|
||||||
|
throw new MosCoordinationAuthorityError(
|
||||||
|
'target_drift',
|
||||||
|
'Mos coordination transport returned an unexpected observation target',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (observation.correlationId !== scope.correlationId) {
|
||||||
|
throw new MosCoordinationAuthorityError(
|
||||||
|
'correlation_drift',
|
||||||
|
'Mos 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 MosCoordinationAuthorityError(
|
||||||
|
'target_drift',
|
||||||
|
'Mos coordination transport returned an unexpected result target',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result.correlationId !== scope.correlationId) {
|
||||||
|
throw new MosCoordinationAuthorityError(
|
||||||
|
'correlation_drift',
|
||||||
|
'Mos coordination transport returned a mismatched result correlation ID',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIdentity(identity: MosCoordinationIdentity): MosCoordinationIdentity {
|
||||||
|
const interactionAgentId = identity.interactionAgentId.trim();
|
||||||
|
const orchestrationAgentId = identity.orchestrationAgentId.trim();
|
||||||
|
if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) {
|
||||||
|
throw new MosCoordinationAuthorityError(
|
||||||
|
'invalid_identity',
|
||||||
|
'Interaction and orchestration identities are required',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (interactionAgentId === orchestrationAgentId) {
|
||||||
|
throw new MosCoordinationAuthorityError(
|
||||||
|
'invalid_identity',
|
||||||
|
'Interaction and orchestration identities must differ',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze({ interactionAgentId, orchestrationAgentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotRequest(request: MosHandoffRequest): MosHandoffRequest {
|
||||||
|
return Object.freeze({ ...request });
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotScope(scope: CoordinationScope): CoordinationScope {
|
||||||
|
return Object.freeze({ ...scope });
|
||||||
|
}
|
||||||
71
packages/db/drizzle/0012_interaction_durable_state.sql
Normal file
71
packages/db/drizzle/0012_interaction_durable_state.sql
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
CREATE TYPE "public"."interaction_handoff_status" AS ENUM('pending', 'accepted');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."interaction_inbox_status" AS ENUM('pending', 'processing', 'processed');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."interaction_outbox_status" AS ENUM('pending', 'processing', 'delivered');--> statement-breakpoint
|
||||||
|
CREATE TABLE "interaction_checkpoints" (
|
||||||
|
"session_id" text PRIMARY KEY NOT NULL,
|
||||||
|
"checkpoint_id" text NOT NULL,
|
||||||
|
"cursor" text NOT NULL,
|
||||||
|
"summary" text NOT NULL,
|
||||||
|
"compaction_epoch" integer NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "interaction_checkpoints_checkpoint_id_unique" UNIQUE("checkpoint_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "interaction_handoffs" (
|
||||||
|
"handoff_id" text PRIMARY KEY NOT NULL,
|
||||||
|
"session_id" text NOT NULL,
|
||||||
|
"destination" text NOT NULL,
|
||||||
|
"correlation_id" text NOT NULL,
|
||||||
|
"checkpoint_id" text NOT NULL,
|
||||||
|
"status" "interaction_handoff_status" DEFAULT 'pending' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "interaction_inbox" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"session_id" text NOT NULL,
|
||||||
|
"idempotency_key" text NOT NULL,
|
||||||
|
"correlation_id" text NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"content_digest" text NOT NULL,
|
||||||
|
"status" "interaction_inbox_status" DEFAULT 'pending' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "interaction_outbox" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"session_id" text NOT NULL,
|
||||||
|
"idempotency_key" text NOT NULL,
|
||||||
|
"correlation_id" text NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"content_digest" text NOT NULL,
|
||||||
|
"status" "interaction_outbox_status" DEFAULT 'pending' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "interaction_sessions" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"agent_name" text NOT NULL,
|
||||||
|
"tenant_id" text NOT NULL,
|
||||||
|
"owner_id" text NOT NULL,
|
||||||
|
"provider_id" text NOT NULL,
|
||||||
|
"runtime_session_id" text 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 "interaction_checkpoints" ADD CONSTRAINT "interaction_checkpoints_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "interaction_handoffs" ADD CONSTRAINT "interaction_handoffs_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "interaction_inbox" ADD CONSTRAINT "interaction_inbox_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "interaction_outbox" ADD CONSTRAINT "interaction_outbox_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "interaction_sessions" ADD CONSTRAINT "interaction_sessions_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "interaction_handoffs_session_status_idx" ON "interaction_handoffs" USING btree ("session_id","status");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "interaction_inbox_session_idempotency_idx" ON "interaction_inbox" USING btree ("session_id","idempotency_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "interaction_inbox_session_status_created_idx" ON "interaction_inbox" USING btree ("session_id","status","created_at");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "interaction_outbox_session_idempotency_idx" ON "interaction_outbox" USING btree ("session_id","idempotency_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "interaction_outbox_session_status_created_idx" ON "interaction_outbox" USING btree ("session_id","status","created_at");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "interaction_checkpoints" DROP CONSTRAINT "interaction_checkpoints_checkpoint_id_unique";--> statement-breakpoint
|
||||||
|
ALTER TABLE "interaction_checkpoints" DROP CONSTRAINT "interaction_checkpoints_pkey";--> statement-breakpoint
|
||||||
|
ALTER TABLE "interaction_checkpoints" ADD COLUMN "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "interaction_checkpoints_session_idempotency_idx" ON "interaction_checkpoints" USING btree ("session_id","checkpoint_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "interaction_checkpoints_session_epoch_idx" ON "interaction_checkpoints" USING btree ("session_id","compaction_epoch");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "interaction_outbox" ADD COLUMN "channel_id" text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "interaction_outbox" SET "channel_id" = 'legacy:unknown' WHERE "channel_id" IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "interaction_outbox" ALTER COLUMN "channel_id" SET NOT NULL;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "interaction_checkpoints" ADD COLUMN "content_digest" text;--> statement-breakpoint
|
||||||
|
UPDATE "interaction_checkpoints" SET "content_digest" = 'legacy' WHERE "content_digest" IS NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "interaction_checkpoints" ALTER COLUMN "content_digest" SET NOT NULL;
|
||||||
4172
packages/db/drizzle/meta/0012_snapshot.json
Normal file
4172
packages/db/drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4214
packages/db/drizzle/meta/0013_snapshot.json
Normal file
4214
packages/db/drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4220
packages/db/drizzle/meta/0014_snapshot.json
Normal file
4220
packages/db/drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4244
packages/db/drizzle/meta/0015_snapshot.json
Normal file
4244
packages/db/drizzle/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,34 @@
|
|||||||
"when": 1782310438919,
|
"when": 1782310438919,
|
||||||
"tag": "0011_bitter_gateway",
|
"tag": "0011_bitter_gateway",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783911983447,
|
||||||
|
"tag": "0012_interaction_durable_state",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783913232578,
|
||||||
|
"tag": "0013_interaction_checkpoint_history",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783913398006,
|
||||||
|
"tag": "0014_interaction_outbox_channel_scope",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783942610000,
|
||||||
|
"tag": "0015_interaction_checkpoint_payload_digest",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -487,6 +487,120 @@ export const agentLogs = pgTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Tess durable session state ─────────────────────────────────────────────
|
||||||
|
// PostgreSQL is canonical for restart-safe Tess session recovery. The state
|
||||||
|
// machine lives in @mosaicstack/agent; these records are its durable adapter.
|
||||||
|
|
||||||
|
export const interactionInboxStatusEnum = pgEnum('interaction_inbox_status', [
|
||||||
|
'pending',
|
||||||
|
'processing',
|
||||||
|
'processed',
|
||||||
|
]);
|
||||||
|
export const interactionOutboxStatusEnum = pgEnum('interaction_outbox_status', [
|
||||||
|
'pending',
|
||||||
|
'processing',
|
||||||
|
'delivered',
|
||||||
|
]);
|
||||||
|
export const interactionHandoffStatusEnum = pgEnum('interaction_handoff_status', [
|
||||||
|
'pending',
|
||||||
|
'accepted',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const interactionSessions = pgTable('interaction_sessions', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
agentName: text('agent_name').notNull(),
|
||||||
|
tenantId: text('tenant_id').notNull(),
|
||||||
|
ownerId: text('owner_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
providerId: text('provider_id').notNull(),
|
||||||
|
runtimeSessionId: text('runtime_session_id').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const interactionInbox = pgTable(
|
||||||
|
'interaction_inbox',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
sessionId: text('session_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
|
||||||
|
idempotencyKey: text('idempotency_key').notNull(),
|
||||||
|
correlationId: text('correlation_id').notNull(),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
contentDigest: text('content_digest').notNull(),
|
||||||
|
status: interactionInboxStatusEnum('status').notNull().default('pending'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('interaction_inbox_session_idempotency_idx').on(t.sessionId, t.idempotencyKey),
|
||||||
|
index('interaction_inbox_session_status_created_idx').on(t.sessionId, t.status, t.createdAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const interactionOutbox = pgTable(
|
||||||
|
'interaction_outbox',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
sessionId: text('session_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
|
||||||
|
idempotencyKey: text('idempotency_key').notNull(),
|
||||||
|
correlationId: text('correlation_id').notNull(),
|
||||||
|
channelId: text('channel_id').notNull(),
|
||||||
|
kind: text('kind').notNull(),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
contentDigest: text('content_digest').notNull(),
|
||||||
|
status: interactionOutboxStatusEnum('status').notNull().default('pending'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('interaction_outbox_session_idempotency_idx').on(t.sessionId, t.idempotencyKey),
|
||||||
|
index('interaction_outbox_session_status_created_idx').on(t.sessionId, t.status, t.createdAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const interactionCheckpoints = pgTable(
|
||||||
|
'interaction_checkpoints',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
sessionId: text('session_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
|
||||||
|
checkpointId: text('checkpoint_id').notNull(),
|
||||||
|
contentDigest: text('content_digest').notNull(),
|
||||||
|
cursor: text('cursor').notNull(),
|
||||||
|
summary: text('summary').notNull(),
|
||||||
|
compactionEpoch: integer('compaction_epoch').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('interaction_checkpoints_session_idempotency_idx').on(t.sessionId, t.checkpointId),
|
||||||
|
index('interaction_checkpoints_session_epoch_idx').on(t.sessionId, t.compactionEpoch),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const interactionHandoffs = pgTable(
|
||||||
|
'interaction_handoffs',
|
||||||
|
{
|
||||||
|
handoffId: text('handoff_id').primaryKey(),
|
||||||
|
sessionId: text('session_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
|
||||||
|
destination: text('destination').notNull(),
|
||||||
|
correlationId: text('correlation_id').notNull(),
|
||||||
|
checkpointId: text('checkpoint_id').notNull(),
|
||||||
|
status: interactionHandoffStatusEnum('status').notNull().default('pending'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [index('interaction_handoffs_session_status_idx').on(t.sessionId, t.status)],
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Skills ─────────────────────────────────────────────────────────────────
|
// ─── Skills ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const skills = pgTable(
|
export const skills = pgTable(
|
||||||
|
|||||||
@@ -274,6 +274,20 @@ describe('KeywordAdapter', () => {
|
|||||||
expect(results).toHaveLength(1);
|
expect(results).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return all scoped insights for the explicit wildcard query', async () => {
|
||||||
|
await adapter.storeInsight({
|
||||||
|
userId: 'u1',
|
||||||
|
content: 'A literal * marker is still ordinary content',
|
||||||
|
source: 'chat',
|
||||||
|
category: 'technical',
|
||||||
|
relevanceScore: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await adapter.searchInsights('u1', '*');
|
||||||
|
expect(results).toHaveLength(4);
|
||||||
|
expect(results.every((result) => result.score === 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return empty for empty query', async () => {
|
it('should return empty for empty query', async () => {
|
||||||
const results = await adapter.searchInsights('u1', ' ');
|
const results = await adapter.searchInsights('u1', ' ');
|
||||||
expect(results).toHaveLength(0);
|
expect(results).toHaveLength(0);
|
||||||
|
|||||||
@@ -132,19 +132,23 @@ export class KeywordAdapter implements MemoryAdapter {
|
|||||||
opts?: { limit?: number; embedding?: number[] },
|
opts?: { limit?: number; embedding?: number[] },
|
||||||
): Promise<InsightSearchResult[]> {
|
): Promise<InsightSearchResult[]> {
|
||||||
const limit = opts?.limit ?? 10;
|
const limit = opts?.limit ?? 10;
|
||||||
const words = query
|
const normalizedQuery = query.trim();
|
||||||
.toLowerCase()
|
const matchAll = normalizedQuery === '*';
|
||||||
.split(/\s+/)
|
const words = matchAll
|
||||||
.filter((w) => w.length > 0);
|
? []
|
||||||
|
: normalizedQuery
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((word) => word.length > 0);
|
||||||
|
|
||||||
if (words.length === 0) return [];
|
if (words.length === 0 && !matchAll) return [];
|
||||||
|
|
||||||
const rows = await this.storage.find<InsightRecord>(INSIGHTS, { userId });
|
const rows = await this.storage.find<InsightRecord>(INSIGHTS, { userId });
|
||||||
|
|
||||||
const scored: InsightSearchResult[] = [];
|
const scored: InsightSearchResult[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const content = row.content.toLowerCase();
|
const content = row.content.toLowerCase();
|
||||||
let score = 0;
|
let score = matchAll ? 1 : 0;
|
||||||
for (const word of words) {
|
for (const word of words) {
|
||||||
if (content.includes(word)) score++;
|
if (content.includes(word)) score++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ export type {
|
|||||||
InsightSearchResult,
|
InsightSearchResult,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
export { createMemoryAdapter, registerMemoryAdapter } from './factory.js';
|
export { createMemoryAdapter, registerMemoryAdapter } from './factory.js';
|
||||||
|
export {
|
||||||
|
createOperatorMemoryPlugin,
|
||||||
|
type OperatorMemoryPlugin,
|
||||||
|
type OperatorMemoryScope,
|
||||||
|
type OperatorMemoryConfig,
|
||||||
|
type OperatorMemoryResult,
|
||||||
|
} from './operator-memory-plugin.js';
|
||||||
export { PgVectorAdapter } from './adapters/pgvector.js';
|
export { PgVectorAdapter } from './adapters/pgvector.js';
|
||||||
export { KeywordAdapter } from './adapters/keyword.js';
|
export { KeywordAdapter } from './adapters/keyword.js';
|
||||||
|
|
||||||
|
|||||||
147
packages/memory/src/operator-memory-plugin.test.ts
Normal file
147
packages/memory/src/operator-memory-plugin.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createOperatorMemoryPlugin } from './operator-memory-plugin.js';
|
||||||
|
import type { Insight, InsightSearchResult, NewInsight } from './types.js';
|
||||||
|
|
||||||
|
function adapter() {
|
||||||
|
return {
|
||||||
|
name: 'test',
|
||||||
|
embedder: null,
|
||||||
|
storeInsight: vi.fn(
|
||||||
|
async (value: NewInsight): Promise<Insight> => ({
|
||||||
|
...value,
|
||||||
|
id: '1',
|
||||||
|
createdAt: new Date(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
searchInsights: vi.fn(async (): Promise<InsightSearchResult[]> => []),
|
||||||
|
getInsight: vi.fn(),
|
||||||
|
deleteInsight: vi.fn(),
|
||||||
|
getPreference: vi.fn(),
|
||||||
|
setPreference: vi.fn(),
|
||||||
|
deletePreference: vi.fn(),
|
||||||
|
listPreferences: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OperatorMemoryPlugin', () => {
|
||||||
|
it('isolates configured namespace storage across server-derived tenant, owner, and session scopes', async () => {
|
||||||
|
const memory = adapter();
|
||||||
|
const plugin = createOperatorMemoryPlugin({
|
||||||
|
adapter: memory,
|
||||||
|
instanceId: 'Nova',
|
||||||
|
namespace: 'operator-memory',
|
||||||
|
redact: (value) => value,
|
||||||
|
});
|
||||||
|
await plugin.capture(
|
||||||
|
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' },
|
||||||
|
{ content: 'one', source: 'test', category: 'note' },
|
||||||
|
);
|
||||||
|
await plugin.capture(
|
||||||
|
{ tenantId: 'tenant-b', ownerId: 'owner-a', sessionId: 'session-a' },
|
||||||
|
{ content: 'two', source: 'test', category: 'note' },
|
||||||
|
);
|
||||||
|
await plugin.capture(
|
||||||
|
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-b' },
|
||||||
|
{ content: 'three', source: 'test', category: 'note' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
memory.storeInsight.mock.calls.map((call: unknown[]) => (call[0] as NewInsight).userId),
|
||||||
|
).toEqual([
|
||||||
|
'["operator-memory","tenant-a","owner-a","session-a"]',
|
||||||
|
'["operator-memory","tenant-b","owner-a","session-a"]',
|
||||||
|
'["operator-memory","tenant-a","owner-a","session-b"]',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an incomplete runtime scope before it can produce a shared storage key', async () => {
|
||||||
|
const memory = adapter();
|
||||||
|
const plugin = createOperatorMemoryPlugin({
|
||||||
|
adapter: memory,
|
||||||
|
instanceId: 'Nova',
|
||||||
|
namespace: 'operator-memory',
|
||||||
|
redact: (value) => value,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
plugin.capture(
|
||||||
|
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: ' ' },
|
||||||
|
{ content: 'note', source: 'test', category: 'note' },
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Operator memory session ID is required');
|
||||||
|
expect(memory.storeInsight).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a differently named configured instance in retrieval provenance', async () => {
|
||||||
|
const memory = adapter();
|
||||||
|
memory.searchInsights.mockResolvedValue([
|
||||||
|
{ id: '1', content: 'x', score: 1, metadata: { source: 'project' } },
|
||||||
|
]);
|
||||||
|
const plugin = createOperatorMemoryPlugin({
|
||||||
|
adapter: memory,
|
||||||
|
instanceId: 'Nova',
|
||||||
|
namespace: 'operator-memory',
|
||||||
|
redact: (value) => value,
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await plugin.search({ tenantId: 't', ownerId: 'o', sessionId: 's' }, 'x');
|
||||||
|
|
||||||
|
expect(results[0]?.provenance).toEqual({
|
||||||
|
instanceId: 'Nova',
|
||||||
|
namespace: 'operator-memory',
|
||||||
|
source: 'project',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders startup context with project and flat-file truth before retrieved material', async () => {
|
||||||
|
const memory = adapter();
|
||||||
|
memory.searchInsights.mockResolvedValue([
|
||||||
|
{ id: 'retrieval', content: 'retrieval', score: 1 },
|
||||||
|
{ id: 'flat-file', content: 'flat-file', score: 1, metadata: { source: 'flat-file' } },
|
||||||
|
{ id: 'project', content: 'project', score: 1, metadata: { source: 'project' } },
|
||||||
|
]);
|
||||||
|
const plugin = createOperatorMemoryPlugin({
|
||||||
|
adapter: memory,
|
||||||
|
instanceId: 'Nova',
|
||||||
|
namespace: 'operator-memory',
|
||||||
|
maxStartupContext: 2,
|
||||||
|
redact: (value) => value,
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = await plugin.startupContext({
|
||||||
|
tenantId: 'tenant-a',
|
||||||
|
ownerId: 'owner-a',
|
||||||
|
sessionId: 'session-a',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.map((result) => result.id)).toEqual(['project', 'flat-file']);
|
||||||
|
expect(memory.searchInsights).toHaveBeenCalledWith(expect.any(String), '*', { limit: 64 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts content before adapter persistence and records configured provenance metadata', async () => {
|
||||||
|
const memory = adapter();
|
||||||
|
const plugin = createOperatorMemoryPlugin({
|
||||||
|
adapter: memory,
|
||||||
|
instanceId: 'Nova',
|
||||||
|
namespace: 'operator-memory',
|
||||||
|
redact: (value) => value.replace('secret', '[REDACTED]'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await plugin.capture(
|
||||||
|
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' },
|
||||||
|
{ content: 'secret note', source: 'project', category: 'note' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(memory.storeInsight).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
content: '[REDACTED] note',
|
||||||
|
metadata: {
|
||||||
|
instanceId: 'Nova',
|
||||||
|
namespace: 'operator-memory',
|
||||||
|
source: 'project',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
154
packages/memory/src/operator-memory-plugin.ts
Normal file
154
packages/memory/src/operator-memory-plugin.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import type { Insight, InsightSearchResult, MemoryAdapter } from './types.js';
|
||||||
|
|
||||||
|
const STARTUP_CONTEXT_CANDIDATE_LIMIT = 64;
|
||||||
|
|
||||||
|
/** Immutable server-derived boundary; callers never choose an adapter namespace. */
|
||||||
|
export interface OperatorMemoryScope {
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly ownerId: string;
|
||||||
|
readonly sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperatorMemoryConfig {
|
||||||
|
/** Adapter injection is deployment/lifecycle configuration, never caller input. */
|
||||||
|
readonly adapter: MemoryAdapter;
|
||||||
|
/** Configured agent identity; it is metadata rather than a storage key default. */
|
||||||
|
readonly instanceId: string;
|
||||||
|
/** Configured storage partition; callers cannot select a namespace. */
|
||||||
|
readonly namespace: string;
|
||||||
|
readonly maxStartupContext?: number;
|
||||||
|
redact(content: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperatorMemoryResult extends InsightSearchResult {
|
||||||
|
provenance: { instanceId: string; namespace: string; source: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperatorMemoryPlugin {
|
||||||
|
capture(
|
||||||
|
scope: OperatorMemoryScope,
|
||||||
|
input: { content: string; source: string; category: string },
|
||||||
|
): Promise<Insight>;
|
||||||
|
search(
|
||||||
|
scope: OperatorMemoryScope,
|
||||||
|
query: string,
|
||||||
|
limit?: number,
|
||||||
|
): Promise<OperatorMemoryResult[]>;
|
||||||
|
recent(scope: OperatorMemoryScope, limit?: number): Promise<OperatorMemoryResult[]>;
|
||||||
|
stats(scope: OperatorMemoryScope): Promise<{ namespace: string; resultCount: number }>;
|
||||||
|
startupContext(scope: OperatorMemoryScope): Promise<OperatorMemoryResult[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopedUserId(scope: OperatorMemoryScope, namespace: string): string {
|
||||||
|
const normalizedScope = normalizeScope(scope);
|
||||||
|
// JSON tuple encoding avoids delimiter collisions between independently scoped IDs.
|
||||||
|
return JSON.stringify([
|
||||||
|
namespace,
|
||||||
|
normalizedScope.tenantId,
|
||||||
|
normalizedScope.ownerId,
|
||||||
|
normalizedScope.sessionId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScope(scope: OperatorMemoryScope): OperatorMemoryScope {
|
||||||
|
if (typeof scope !== 'object' || scope === null) {
|
||||||
|
throw new Error('Operator memory scope is required');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
tenantId: requiredScopeId(scope.tenantId, 'tenant ID'),
|
||||||
|
ownerId: requiredScopeId(scope.ownerId, 'owner ID'),
|
||||||
|
sessionId: requiredScopeId(scope.sessionId, 'session ID'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredScopeId(value: unknown, field: string): string {
|
||||||
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||||
|
throw new Error(`Operator memory ${field} is required`);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareStartupContext(left: OperatorMemoryResult, right: OperatorMemoryResult): number {
|
||||||
|
return (
|
||||||
|
startupSourcePriority(left.provenance.source) - startupSourcePriority(right.provenance.source)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startupSourcePriority(source: string): number {
|
||||||
|
if (source === 'project') return 0;
|
||||||
|
if (source === 'flat-file') return 1;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfig(config: OperatorMemoryConfig): OperatorMemoryConfig {
|
||||||
|
const instanceId = config.instanceId.trim();
|
||||||
|
const namespace = config.namespace.trim();
|
||||||
|
const maxStartupContext = config.maxStartupContext ?? 8;
|
||||||
|
if (instanceId.length === 0 || namespace.length === 0) {
|
||||||
|
throw new Error('Operator memory instance ID and namespace must be configured');
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(maxStartupContext) || maxStartupContext < 1) {
|
||||||
|
throw new Error('Operator memory startup context limit must be a positive integer');
|
||||||
|
}
|
||||||
|
return Object.freeze({ ...config, instanceId, namespace, maxStartupContext });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a leaf-package, replaceable memory adapter facade. */
|
||||||
|
export function createOperatorMemoryPlugin(config: OperatorMemoryConfig): OperatorMemoryPlugin {
|
||||||
|
const pluginConfig = normalizeConfig(config);
|
||||||
|
const mapResult = (result: InsightSearchResult): OperatorMemoryResult => ({
|
||||||
|
...result,
|
||||||
|
provenance: {
|
||||||
|
instanceId: pluginConfig.instanceId,
|
||||||
|
namespace: pluginConfig.namespace,
|
||||||
|
source: String(result.metadata?.['source'] ?? 'retrieval'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const search = async (
|
||||||
|
scope: OperatorMemoryScope,
|
||||||
|
query: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<OperatorMemoryResult[]> =>
|
||||||
|
(
|
||||||
|
await pluginConfig.adapter.searchInsights(
|
||||||
|
scopedUserId(scope, pluginConfig.namespace),
|
||||||
|
query,
|
||||||
|
{
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).map(mapResult);
|
||||||
|
return {
|
||||||
|
async capture(scope, input) {
|
||||||
|
return pluginConfig.adapter.storeInsight({
|
||||||
|
userId: scopedUserId(scope, pluginConfig.namespace),
|
||||||
|
content: pluginConfig.redact(input.content),
|
||||||
|
source: input.source,
|
||||||
|
category: input.category,
|
||||||
|
relevanceScore: 1,
|
||||||
|
metadata: {
|
||||||
|
namespace: pluginConfig.namespace,
|
||||||
|
instanceId: pluginConfig.instanceId,
|
||||||
|
source: input.source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
search,
|
||||||
|
async recent(scope, limit = 10) {
|
||||||
|
return search(scope, '*', limit);
|
||||||
|
},
|
||||||
|
async stats(scope) {
|
||||||
|
return {
|
||||||
|
namespace: pluginConfig.namespace,
|
||||||
|
resultCount: (await search(scope, '*', 100)).length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async startupContext(scope) {
|
||||||
|
const maxStartupContext = pluginConfig.maxStartupContext ?? 8;
|
||||||
|
// Prioritize authoritative sources within a bounded candidate window.
|
||||||
|
const candidateLimit = Math.max(maxStartupContext, STARTUP_CONTEXT_CANDIDATE_LIMIT);
|
||||||
|
const context = await search(scope, '*', candidateLimit);
|
||||||
|
return [...context].sort(compareStartupContext).slice(0, maxStartupContext);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -49,6 +49,10 @@ export interface MemoryAdapter {
|
|||||||
// Insights
|
// Insights
|
||||||
storeInsight(insight: NewInsight): Promise<Insight>;
|
storeInsight(insight: NewInsight): Promise<Insight>;
|
||||||
getInsight(id: string): Promise<Insight | null>;
|
getInsight(id: string): Promise<Insight | null>;
|
||||||
|
/**
|
||||||
|
* Searches within one scoped user ID. The reserved `*` query returns scoped
|
||||||
|
* recent/all results rather than performing backend-specific wildcard parsing.
|
||||||
|
*/
|
||||||
searchInsights(
|
searchInsights(
|
||||||
userId: string,
|
userId: string,
|
||||||
query: string,
|
query: string,
|
||||||
|
|||||||
@@ -15,6 +15,21 @@ default tmux server.
|
|||||||
|
|
||||||
- `examples/minimal.yaml` starts one local canary slot.
|
- `examples/minimal.yaml` starts one local canary slot.
|
||||||
- `examples/local-canary.yaml` starts a small generic dogfood fleet.
|
- `examples/local-canary.yaml` starts a small generic dogfood fleet.
|
||||||
|
- `examples/operator-interaction.yaml` is an example Pi operator-interaction
|
||||||
|
service; replace its example agent name before provisioning.
|
||||||
|
|
||||||
|
## Operator interaction service
|
||||||
|
|
||||||
|
`services/operator-interaction.yaml` pins the Pi runtime, GPT-5.6 Sol model,
|
||||||
|
high reasoning, and the `operator-interaction` tool policy. The agent identity
|
||||||
|
is provisioning data: choose a roster name, generate its per-agent environment
|
||||||
|
file, then start the matching generic systemd instance. The service fails before
|
||||||
|
launch if the configured identity does not match the instance or any pinned
|
||||||
|
policy field drifts.
|
||||||
|
|
||||||
|
The installed `tools/fleet/print-interaction-effective-policy.sh` prints only
|
||||||
|
the resolved name, runtime, model, reasoning, and tool policy. It never reads
|
||||||
|
or prints credential variables.
|
||||||
|
|
||||||
Initialize a roster:
|
Initialize a roster:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Example instance only. Replace `Tess` with the chosen provisioned identity.
|
||||||
|
version: 1
|
||||||
|
transport: tmux
|
||||||
|
tmux:
|
||||||
|
socket_name: mosaic-fleet
|
||||||
|
holder_session: _holder
|
||||||
|
defaults:
|
||||||
|
working_directory: ~/src
|
||||||
|
runtimes:
|
||||||
|
pi:
|
||||||
|
reset_command: /new
|
||||||
|
agents:
|
||||||
|
- name: Tess
|
||||||
|
runtime: pi
|
||||||
|
class: operator-interaction
|
||||||
|
model_hint: openai/gpt-5.6-sol
|
||||||
|
reasoning_level: high
|
||||||
|
tool_policy: operator-interaction
|
||||||
|
persistent_persona: true
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Operator Interaction — fleet role definition
|
||||||
|
|
||||||
|
The **operator-interaction** role is the authorized human interaction plane for
|
||||||
|
Mosaic. It presents runtime and fleet state, mediates approved actions, and
|
||||||
|
hands coding or general orchestration work to Mos.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- It does not claim Mos-owned coding or general orchestration work.
|
||||||
|
- It exposes only the configured, observable tool policy.
|
||||||
|
- It does not receive or surface credentials in its effective policy.
|
||||||
@@ -105,6 +105,18 @@
|
|||||||
"modelHint": {
|
"modelHint": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"reasoning_level": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"reasoningLevel": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tool_policy": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"toolPolicy": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"persistent_persona": {
|
"persistent_persona": {
|
||||||
"oneOf": [{ "type": "boolean" }, { "type": "string" }]
|
"oneOf": [{ "type": "boolean" }, { "type": "string" }]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Generic service policy. Provisioning supplies the agent name as data.
|
||||||
|
runtime: pi
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
reasoning: high
|
||||||
|
tool_policy: operator-interaction
|
||||||
@@ -12,6 +12,8 @@ exact-match session.
|
|||||||
|
|
||||||
- `mosaic-tmux-holder.service` — user-mode holder that owns the named tmux server.
|
- `mosaic-tmux-holder.service` — user-mode holder that owns the named tmux server.
|
||||||
- `mosaic-agent@.service` — user-mode template for one reusable agent session.
|
- `mosaic-agent@.service` — user-mode template for one reusable agent session.
|
||||||
|
- `mosaic-interaction-agent@.service` — generic Pi operator-interaction template
|
||||||
|
that fails fast when its pinned runtime policy is incomplete or changed.
|
||||||
- `test-fleet-units.sh` — validates unit syntax and required relationships.
|
- `test-fleet-units.sh` — validates unit syntax and required relationships.
|
||||||
|
|
||||||
The agent template calls:
|
The agent template calls:
|
||||||
@@ -45,12 +47,22 @@ MOSAIC_AGENT_WORKDIR=$HOME/src/your-project
|
|||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents
|
mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents
|
||||||
cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/
|
cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/
|
||||||
cp packages/mosaic/framework/tools/fleet/start-agent-session.sh ~/.config/mosaic/tools/fleet/
|
cp packages/mosaic/framework/tools/fleet/*.sh ~/.config/mosaic/tools/fleet/
|
||||||
chmod +x ~/.config/mosaic/tools/fleet/start-agent-session.sh
|
chmod +x ~/.config/mosaic/tools/fleet/*.sh
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user start mosaic-tmux-holder.service
|
systemctl --user start mosaic-tmux-holder.service
|
||||||
systemctl --user start mosaic-agent@canary.service
|
systemctl --user start mosaic-agent@canary.service
|
||||||
tmux -L mosaic-fleet ls
|
tmux -L mosaic-fleet ls
|
||||||
|
|
||||||
|
# For an operator-interaction service, the roster/env identity selects the
|
||||||
|
# generic unit instance; no service source is renamed for an instance.
|
||||||
|
systemctl --user start mosaic-interaction-agent@<agent-name>.service
|
||||||
|
MOSAIC_AGENT_NAME=<agent-name> \
|
||||||
|
MOSAIC_AGENT_RUNTIME=pi \
|
||||||
|
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol \
|
||||||
|
MOSAIC_AGENT_REASONING=high \
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY=operator-interaction \
|
||||||
|
~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant
|
Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Mosaic operator interaction agent %i
|
||||||
|
Documentation=https://git.mosaicstack.dev/mosaicstack/stack
|
||||||
|
Requires=mosaic-tmux-holder.service
|
||||||
|
After=mosaic-tmux-holder.service
|
||||||
|
PartOf=mosaic-tmux-holder.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
Environment=MOSAIC_AGENT_NAME=%i
|
||||||
|
EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env
|
||||||
|
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i
|
||||||
|
ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi'
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||||
HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service"
|
HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service"
|
||||||
AGENT="$SCRIPT_DIR/mosaic-agent@.service"
|
AGENT="$SCRIPT_DIR/mosaic-agent@.service"
|
||||||
|
INTERACTION="$SCRIPT_DIR/mosaic-interaction-agent@.service"
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
echo "FAIL: $*" >&2
|
echo "FAIL: $*" >&2
|
||||||
@@ -12,6 +13,7 @@ fail() {
|
|||||||
|
|
||||||
[ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service"
|
[ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service"
|
||||||
[ -f "$AGENT" ] || fail "missing mosaic-agent@.service"
|
[ -f "$AGENT" ] || fail "missing mosaic-agent@.service"
|
||||||
|
[ -f "$INTERACTION" ] || fail "missing mosaic-interaction-agent@.service"
|
||||||
|
|
||||||
grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart"
|
grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart"
|
||||||
grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket"
|
grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket"
|
||||||
@@ -19,9 +21,12 @@ grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit"
|
|||||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
|
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
|
||||||
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
|
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
|
||||||
grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session"
|
grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session"
|
||||||
|
grep -qF 'Requires=mosaic-tmux-holder.service' "$INTERACTION" || fail "interaction service does not require holder"
|
||||||
|
grep -qF 'EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env' "$INTERACTION" || fail "interaction service does not require per-agent config"
|
||||||
|
grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not validate before startup"
|
||||||
|
|
||||||
if command -v systemd-analyze >/dev/null 2>&1; then
|
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||||
systemd-analyze verify --user "$HOLDER" "$AGENT" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
|
systemd-analyze verify --user "$HOLDER" "$AGENT" "$INTERACTION" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
|
||||||
cat /tmp/mosaic-fleet-systemd-verify.log >&2
|
cat /tmp/mosaic-fleet-systemd-verify.log >&2
|
||||||
fail "systemd-analyze verify failed"
|
fail "systemd-analyze verify failed"
|
||||||
}
|
}
|
||||||
|
|||||||
18
packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh
Executable file
18
packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
AGENT_NAME=${MOSAIC_AGENT_NAME:-}
|
||||||
|
RUNTIME=${MOSAIC_AGENT_RUNTIME:-}
|
||||||
|
MODEL=${MOSAIC_AGENT_MODEL:-}
|
||||||
|
REASONING=${MOSAIC_AGENT_REASONING:-}
|
||||||
|
TOOL_POLICY=${MOSAIC_AGENT_TOOL_POLICY:-}
|
||||||
|
|
||||||
|
[ -n "$AGENT_NAME" ] || { echo 'ERROR: MOSAIC_AGENT_NAME is required' >&2; exit 64; }
|
||||||
|
[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo 'ERROR: invalid agent name' >&2; exit 64; }
|
||||||
|
[ "$RUNTIME" = 'pi' ] || { echo 'ERROR: invalid runtime policy' >&2; exit 64; }
|
||||||
|
[ "$MODEL" = 'openai/gpt-5.6-sol' ] || { echo 'ERROR: invalid model policy' >&2; exit 64; }
|
||||||
|
[ "$REASONING" = 'high' ] || { echo 'ERROR: invalid reasoning policy' >&2; exit 64; }
|
||||||
|
[ "$TOOL_POLICY" = 'operator-interaction' ] || { echo 'ERROR: invalid tool policy' >&2; exit 64; }
|
||||||
|
|
||||||
|
printf '{"agentName":"%s","runtime":"%s","model":"%s","reasoning":"%s","toolPolicy":"%s"}\n' \
|
||||||
|
"$AGENT_NAME" "$RUNTIME" "$MODEL" "$REASONING" "$TOOL_POLICY"
|
||||||
@@ -8,6 +8,7 @@ AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}}
|
|||||||
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-}
|
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-}
|
||||||
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
||||||
MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-}
|
MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-}
|
||||||
|
MOSAIC_AGENT_REASONING=${MOSAIC_AGENT_REASONING:-}
|
||||||
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
||||||
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
|
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
|
||||||
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
|
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
|
||||||
@@ -18,6 +19,14 @@ if [ -z "$AGENT_NAME" ]; then
|
|||||||
exit 64
|
exit 64
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
case "$MOSAIC_AGENT_REASONING" in
|
||||||
|
''|low|medium|high) ;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: MOSAIC_AGENT_REASONING must be low, medium, or high" >&2
|
||||||
|
exit 64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
if ! command -v tmux >/dev/null 2>&1; then
|
if ! command -v tmux >/dev/null 2>&1; then
|
||||||
echo "ERROR: tmux is required" >&2
|
echo "ERROR: tmux is required" >&2
|
||||||
exit 69
|
exit 69
|
||||||
@@ -41,7 +50,7 @@ fi
|
|||||||
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
|
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
|
||||||
# Map the roster's per-agent model_hint to `--model` so workers launch on the
|
# Map the roster's per-agent model_hint to `--model` so workers launch on the
|
||||||
# configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset.
|
# configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset.
|
||||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}"
|
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}${MOSAIC_AGENT_REASONING:+ --thinking $MOSAIC_AGENT_REASONING}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
|
# ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
|
||||||
@@ -124,11 +133,12 @@ AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME")
|
|||||||
# safe single bash token; an empty/unset class %q-quotes to '' and is a harmless
|
# safe single bash token; an empty/unset class %q-quotes to '' and is a harmless
|
||||||
# no-op downstream (readPersonaContractBlock returns '' for an empty class).
|
# no-op downstream (readPersonaContractBlock returns '' for an empty class).
|
||||||
AGENT_CLASS_Q=$(printf '%q' "${MOSAIC_AGENT_CLASS:-}")
|
AGENT_CLASS_Q=$(printf '%q' "${MOSAIC_AGENT_CLASS:-}")
|
||||||
|
AGENT_TOOL_POLICY_Q=$(printf '%q' "${MOSAIC_AGENT_TOOL_POLICY:-}")
|
||||||
|
|
||||||
if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then
|
if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then
|
||||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
|
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
|
||||||
else
|
else
|
||||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; exec ${MOSAIC_AGENT_COMMAND}"
|
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; exec ${MOSAIC_AGENT_COMMAND}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
||||||
|
|||||||
19
packages/mosaic/framework/tools/fleet/start-interaction-service.sh
Executable file
19
packages/mosaic/framework/tools/fleet/start-interaction-service.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
AGENT_NAME=${1:-}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "ERROR: $*" >&2
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
|
||||||
|
[ -n "$AGENT_NAME" ] || fail "agent name argument is required"
|
||||||
|
[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || fail "agent name contains unsupported characters"
|
||||||
|
[ "${MOSAIC_AGENT_NAME:-}" = "$AGENT_NAME" ] || fail "configured agent name must exactly match the service instance"
|
||||||
|
[ "${MOSAIC_AGENT_RUNTIME:-}" = "pi" ] || fail "operator interaction service requires runtime pi"
|
||||||
|
[ "${MOSAIC_AGENT_MODEL:-}" = "openai/gpt-5.6-sol" ] || fail "operator interaction service requires the pinned model"
|
||||||
|
[ "${MOSAIC_AGENT_REASONING:-}" = "high" ] || fail "operator interaction service requires high reasoning"
|
||||||
|
[ "${MOSAIC_AGENT_TOOL_POLICY:-}" = "operator-interaction" ] || fail "operator interaction service requires the operator-interaction tool policy"
|
||||||
|
|
||||||
|
exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" "$AGENT_NAME"
|
||||||
@@ -105,6 +105,7 @@ MOSAIC_TMUX_SOCKET="$SOCKET3" \
|
|||||||
MOSAIC_AGENT_WORKDIR="$WORKDIR3" \
|
MOSAIC_AGENT_WORKDIR="$WORKDIR3" \
|
||||||
MOSAIC_AGENT_RUNTIME="pi" \
|
MOSAIC_AGENT_RUNTIME="pi" \
|
||||||
MOSAIC_AGENT_CLASS="code" \
|
MOSAIC_AGENT_CLASS="code" \
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY="operator-interaction" \
|
||||||
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \
|
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \
|
||||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \
|
MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \
|
||||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \
|
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \
|
||||||
@@ -139,6 +140,8 @@ echo "$all_args" | grep -qF "export MOSAIC_AGENT_NAME=" || \
|
|||||||
fail "pane command does not export MOSAIC_AGENT_NAME into the pane"
|
fail "pane command does not export MOSAIC_AGENT_NAME into the pane"
|
||||||
echo "$all_args" | grep -qF "export MOSAIC_AGENT_CLASS=code" || \
|
echo "$all_args" | grep -qF "export MOSAIC_AGENT_CLASS=code" || \
|
||||||
fail "pane command does not export MOSAIC_AGENT_CLASS into the pane (persona would silently drop)"
|
fail "pane command does not export MOSAIC_AGENT_CLASS into the pane (persona would silently drop)"
|
||||||
|
echo "$all_args" | grep -qF "export MOSAIC_AGENT_TOOL_POLICY=operator-interaction" || \
|
||||||
|
fail "pane command does not export MOSAIC_AGENT_TOOL_POLICY into the pane"
|
||||||
|
|
||||||
# ── Test 4: when no extra runtime-bin dirs exist, exec still appears ───────────
|
# ── Test 4: when no extra runtime-bin dirs exist, exec still appears ───────────
|
||||||
TMUX_ARGS_FILE2=$(mktemp)
|
TMUX_ARGS_FILE2=$(mktemp)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { registerQueueCommand } from '@mosaicstack/queue';
|
|||||||
import { registerStorageCommand } from '@mosaicstack/storage';
|
import { registerStorageCommand } from '@mosaicstack/storage';
|
||||||
import { registerTelemetryCommand } from './commands/telemetry.js';
|
import { registerTelemetryCommand } from './commands/telemetry.js';
|
||||||
import { registerAgentCommand } from './commands/agent.js';
|
import { registerAgentCommand } from './commands/agent.js';
|
||||||
|
import { registerInteractionCommand } from './commands/interaction.js';
|
||||||
import { registerConfigCommand } from './commands/config.js';
|
import { registerConfigCommand } from './commands/config.js';
|
||||||
import { registerFleetCommand } from './commands/fleet.js';
|
import { registerFleetCommand } from './commands/fleet.js';
|
||||||
import { registerMissionCommand } from './commands/mission.js';
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
@@ -352,6 +353,10 @@ registerFederationCommand(program);
|
|||||||
|
|
||||||
registerAgentCommand(program);
|
registerAgentCommand(program);
|
||||||
|
|
||||||
|
// ─── interaction ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerInteractionCommand(program);
|
||||||
|
|
||||||
// ─── fleet ─────────────────────────────────────────────────────────────
|
// ─── fleet ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerFleetCommand(program);
|
registerFleetCommand(program);
|
||||||
|
|||||||
@@ -165,6 +165,19 @@ describe('composeContract — overlay composer', () => {
|
|||||||
expect(composeContract('codex', fixture.home)).not.toContain('# pi runtime contract');
|
expect(composeContract('codex', fixture.home)).not.toContain('# pi runtime contract');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('injects the configured operator-interaction tool policy into the runtime contract', () => {
|
||||||
|
const previous = process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||||
|
try {
|
||||||
|
process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'operator-interaction';
|
||||||
|
const out = composeContract('pi', fixture.home);
|
||||||
|
expect(out).toContain('# Fleet Tool Policy (operator-interaction)');
|
||||||
|
expect(out).toContain('Denied by default: coding/general orchestration claims');
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||||
|
else process.env['MOSAIC_AGENT_TOOL_POLICY'] = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Persona contract injection (A3b) ──────────────────────────────────────
|
// ── Persona contract injection (A3b) ──────────────────────────────────────
|
||||||
// composeContract reads MOSAIC_AGENT_CLASS and injects the resolved persona
|
// composeContract reads MOSAIC_AGENT_CLASS and injects the resolved persona
|
||||||
// (override-aware). Save/restore the env so these tests don't leak state.
|
// (override-aware). Save/restore the env so these tests don't leak state.
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ interface RawFleetRoster {
|
|||||||
workingDirectory?: unknown;
|
workingDirectory?: unknown;
|
||||||
model_hint?: unknown;
|
model_hint?: unknown;
|
||||||
modelHint?: unknown;
|
modelHint?: unknown;
|
||||||
|
reasoning_level?: unknown;
|
||||||
|
reasoningLevel?: unknown;
|
||||||
|
tool_policy?: unknown;
|
||||||
|
toolPolicy?: unknown;
|
||||||
persistent_persona?: unknown;
|
persistent_persona?: unknown;
|
||||||
persistentPersona?: unknown;
|
persistentPersona?: unknown;
|
||||||
reset_between_tasks?: unknown;
|
reset_between_tasks?: unknown;
|
||||||
@@ -102,6 +106,8 @@ export interface FleetAgent {
|
|||||||
className: string;
|
className: string;
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
modelHint?: string;
|
modelHint?: string;
|
||||||
|
reasoningLevel?: string;
|
||||||
|
toolPolicy?: string;
|
||||||
persistentPersona?: boolean | string;
|
persistentPersona?: boolean | string;
|
||||||
resetBetweenTasks?: boolean;
|
resetBetweenTasks?: boolean;
|
||||||
kickstartTemplate?: string;
|
kickstartTemplate?: string;
|
||||||
@@ -510,6 +516,12 @@ export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string
|
|||||||
// the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on
|
// the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on
|
||||||
// openai-codex/gpt-5.5:high). Empty when the agent declares no model_hint.
|
// openai-codex/gpt-5.5:high). Empty when the agent declares no model_hint.
|
||||||
`MOSAIC_AGENT_MODEL=${shellEnvValue(agent.modelHint ?? '')}`,
|
`MOSAIC_AGENT_MODEL=${shellEnvValue(agent.modelHint ?? '')}`,
|
||||||
|
...(agent.reasoningLevel !== undefined
|
||||||
|
? [`MOSAIC_AGENT_REASONING=${shellEnvValue(agent.reasoningLevel)}`]
|
||||||
|
: []),
|
||||||
|
...(agent.toolPolicy !== undefined
|
||||||
|
? [`MOSAIC_AGENT_TOOL_POLICY=${shellEnvValue(agent.toolPolicy)}`]
|
||||||
|
: []),
|
||||||
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
|
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
|
||||||
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
|
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
|
||||||
'',
|
'',
|
||||||
@@ -2316,13 +2328,35 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
await mkdir(activePaths.agentEnvDir, { recursive: true });
|
await mkdir(activePaths.agentEnvDir, { recursive: true });
|
||||||
|
|
||||||
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
||||||
|
const startInteractionServicePath = join(
|
||||||
|
activePaths.fleetToolsDir,
|
||||||
|
'start-interaction-service.sh',
|
||||||
|
);
|
||||||
|
const printInteractionPolicyPath = join(
|
||||||
|
activePaths.fleetToolsDir,
|
||||||
|
'print-interaction-effective-policy.sh',
|
||||||
|
);
|
||||||
const sendMessagePath = join(activePaths.tmuxToolsDir, 'send-message.sh');
|
const sendMessagePath = join(activePaths.tmuxToolsDir, 'send-message.sh');
|
||||||
const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh');
|
const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh');
|
||||||
const executableToolPaths = [startAgentSessionPath, sendMessagePath, agentSendPath];
|
const executableToolPaths = [
|
||||||
|
startAgentSessionPath,
|
||||||
|
startInteractionServicePath,
|
||||||
|
printInteractionPolicyPath,
|
||||||
|
sendMessagePath,
|
||||||
|
agentSendPath,
|
||||||
|
];
|
||||||
await copyFile(
|
await copyFile(
|
||||||
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
||||||
startAgentSessionPath,
|
startAgentSessionPath,
|
||||||
);
|
);
|
||||||
|
await copyFile(
|
||||||
|
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
|
||||||
|
startInteractionServicePath,
|
||||||
|
);
|
||||||
|
await copyFile(
|
||||||
|
join(frameworkRoot, 'tools', 'fleet', 'print-interaction-effective-policy.sh'),
|
||||||
|
printInteractionPolicyPath,
|
||||||
|
);
|
||||||
await copyFile(join(frameworkRoot, 'tools', 'tmux', 'send-message.sh'), sendMessagePath);
|
await copyFile(join(frameworkRoot, 'tools', 'tmux', 'send-message.sh'), sendMessagePath);
|
||||||
await copyFile(join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh'), agentSendPath);
|
await copyFile(join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh'), agentSendPath);
|
||||||
for (const toolPath of executableToolPaths) {
|
for (const toolPath of executableToolPaths) {
|
||||||
@@ -2336,6 +2370,10 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
join(frameworkRoot, 'systemd', 'user', 'mosaic-agent@.service'),
|
join(frameworkRoot, 'systemd', 'user', 'mosaic-agent@.service'),
|
||||||
join(activePaths.systemdUserDir, 'mosaic-agent@.service'),
|
join(activePaths.systemdUserDir, 'mosaic-agent@.service'),
|
||||||
);
|
);
|
||||||
|
await copyFile(
|
||||||
|
join(frameworkRoot, 'systemd', 'user', 'mosaic-interaction-agent@.service'),
|
||||||
|
join(activePaths.systemdUserDir, 'mosaic-interaction-agent@.service'),
|
||||||
|
);
|
||||||
|
|
||||||
for (const agent of roster.agents) {
|
for (const agent of roster.agents) {
|
||||||
const envPath = join(activePaths.agentEnvDir, `${agent.name}.env`);
|
const envPath = join(activePaths.agentEnvDir, `${agent.name}.env`);
|
||||||
@@ -2462,6 +2500,10 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
|
|||||||
'workingDirectory',
|
'workingDirectory',
|
||||||
'model_hint',
|
'model_hint',
|
||||||
'modelHint',
|
'modelHint',
|
||||||
|
'reasoning_level',
|
||||||
|
'reasoningLevel',
|
||||||
|
'tool_policy',
|
||||||
|
'toolPolicy',
|
||||||
'persistent_persona',
|
'persistent_persona',
|
||||||
'persistentPersona',
|
'persistentPersona',
|
||||||
'reset_between_tasks',
|
'reset_between_tasks',
|
||||||
@@ -2493,6 +2535,14 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
|
|||||||
raw.model_hint ?? raw.modelHint,
|
raw.model_hint ?? raw.modelHint,
|
||||||
`Fleet roster agent "${name}" model_hint`,
|
`Fleet roster agent "${name}" model_hint`,
|
||||||
),
|
),
|
||||||
|
reasoningLevel: optionalString(
|
||||||
|
raw.reasoning_level ?? raw.reasoningLevel,
|
||||||
|
`Fleet roster agent "${name}" reasoning_level`,
|
||||||
|
),
|
||||||
|
toolPolicy: optionalString(
|
||||||
|
raw.tool_policy ?? raw.toolPolicy,
|
||||||
|
`Fleet roster agent "${name}" tool_policy`,
|
||||||
|
),
|
||||||
persistentPersona: optionalBooleanOrString(
|
persistentPersona: optionalBooleanOrString(
|
||||||
raw.persistent_persona ?? raw.persistentPersona,
|
raw.persistent_persona ?? raw.persistentPersona,
|
||||||
`Fleet roster agent "${name}" persistent_persona`,
|
`Fleet roster agent "${name}" persistent_persona`,
|
||||||
@@ -2783,6 +2833,12 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
|||||||
if (agent.modelHint !== undefined) {
|
if (agent.modelHint !== undefined) {
|
||||||
raw['model_hint'] = agent.modelHint;
|
raw['model_hint'] = agent.modelHint;
|
||||||
}
|
}
|
||||||
|
if (agent.reasoningLevel !== undefined) {
|
||||||
|
raw['reasoning_level'] = agent.reasoningLevel;
|
||||||
|
}
|
||||||
|
if (agent.toolPolicy !== undefined) {
|
||||||
|
raw['tool_policy'] = agent.toolPolicy;
|
||||||
|
}
|
||||||
if (agent.persistentPersona !== undefined) {
|
if (agent.persistentPersona !== undefined) {
|
||||||
raw['persistent_persona'] = agent.persistentPersona;
|
raw['persistent_persona'] = agent.persistentPersona;
|
||||||
}
|
}
|
||||||
|
|||||||
23
packages/mosaic/src/commands/interaction.test.ts
Normal file
23
packages/mosaic/src/commands/interaction.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { registerInteractionCommand } from './interaction.js';
|
||||||
|
|
||||||
|
describe('generic interaction CLI', (): void => {
|
||||||
|
it('registers every required interaction verb without an instance-specific command name', () => {
|
||||||
|
const program = new Command();
|
||||||
|
const command = registerInteractionCommand(program);
|
||||||
|
expect(command.name()).toBe('interaction');
|
||||||
|
expect(command.commands.map((item) => item.name()).sort()).toEqual([
|
||||||
|
'attach',
|
||||||
|
'chat',
|
||||||
|
'enroll',
|
||||||
|
'health',
|
||||||
|
'recover',
|
||||||
|
'send',
|
||||||
|
'sessions',
|
||||||
|
'status',
|
||||||
|
'stop',
|
||||||
|
'tree',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
232
packages/mosaic/src/commands/interaction.ts
Normal file
232
packages/mosaic/src/commands/interaction.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Command } from 'commander';
|
||||||
|
import { withAuth } from './with-auth.js';
|
||||||
|
import {
|
||||||
|
attachInteractionSession,
|
||||||
|
enrollInteractionSession,
|
||||||
|
fetchInteractionHealth,
|
||||||
|
fetchInteractionSessions,
|
||||||
|
fetchInteractionStatus,
|
||||||
|
fetchInteractionTree,
|
||||||
|
recoverInteractionSession,
|
||||||
|
sendInteractionMessage,
|
||||||
|
stopInteractionSession,
|
||||||
|
streamInteractionSession,
|
||||||
|
} from '../tui/gateway-api.js';
|
||||||
|
|
||||||
|
interface InteractionOptions {
|
||||||
|
gateway: string;
|
||||||
|
agent?: string;
|
||||||
|
correlationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredAgentName(options: InteractionOptions): string {
|
||||||
|
const name = options.agent?.trim() || process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||||
|
if (!name) throw new Error('Interaction agent name is required (--agent or MOSAIC_AGENT_NAME)');
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticatedGateway(options: InteractionOptions) {
|
||||||
|
return withAuth(options.gateway);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authRequest(options: InteractionOptions) {
|
||||||
|
const auth = await authenticatedGateway(options);
|
||||||
|
return {
|
||||||
|
gateway: auth.gateway,
|
||||||
|
cookie: auth.cookie,
|
||||||
|
agentName: configuredAgentName(options),
|
||||||
|
correlationId: options.correlationId?.trim() || randomUUID(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(command: Command): Command {
|
||||||
|
return command
|
||||||
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||||
|
.option('--agent <name>', 'Configured interaction agent name')
|
||||||
|
.option('--correlation-id <id>', 'Correlation ID for audit tracing');
|
||||||
|
}
|
||||||
|
|
||||||
|
function print(value: unknown): void {
|
||||||
|
console.log(JSON.stringify(value, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic interaction command surface. The deployed instance name is data
|
||||||
|
* (`--agent` / MOSAIC_AGENT_NAME), not a source-code command literal.
|
||||||
|
*/
|
||||||
|
export function registerInteractionCommand(program: Command): Command {
|
||||||
|
const command = program
|
||||||
|
.command('interaction')
|
||||||
|
.description('Operate a configured durable interaction agent')
|
||||||
|
.configureHelp({ sortSubcommands: true });
|
||||||
|
|
||||||
|
options(
|
||||||
|
command
|
||||||
|
.command('status')
|
||||||
|
.description('Show credential-safe effective policy and provider status'),
|
||||||
|
).action(async (opts: InteractionOptions) => {
|
||||||
|
const auth = await authenticatedGateway(opts);
|
||||||
|
print(await fetchInteractionStatus(auth.gateway, auth.cookie));
|
||||||
|
});
|
||||||
|
|
||||||
|
options(
|
||||||
|
command
|
||||||
|
.command('health')
|
||||||
|
.description('Show readiness without configuration or credential data'),
|
||||||
|
).action(async (opts: InteractionOptions) => {
|
||||||
|
print(await fetchInteractionHealth(opts.gateway));
|
||||||
|
});
|
||||||
|
|
||||||
|
options(
|
||||||
|
command
|
||||||
|
.command('sessions <provider>')
|
||||||
|
.description('List provider sessions visible to this actor'),
|
||||||
|
).action(async (provider: string, opts: InteractionOptions) => {
|
||||||
|
const request = await authRequest(opts);
|
||||||
|
print(
|
||||||
|
await fetchInteractionSessions(request.gateway, request.cookie, {
|
||||||
|
...request,
|
||||||
|
providerId: provider,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
options(
|
||||||
|
command.command('tree <provider>').description('Show the provider session hierarchy'),
|
||||||
|
).action(async (provider: string, opts: InteractionOptions) => {
|
||||||
|
const request = await authRequest(opts);
|
||||||
|
print(
|
||||||
|
await fetchInteractionTree(request.gateway, request.cookie, {
|
||||||
|
...request,
|
||||||
|
providerId: provider,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
options(
|
||||||
|
command
|
||||||
|
.command('enroll <sessionId> <provider> <runtimeSessionId>')
|
||||||
|
.description('Bind an authorized runtime session to a durable conversation handle'),
|
||||||
|
).action(
|
||||||
|
async (
|
||||||
|
sessionId: string,
|
||||||
|
providerId: string,
|
||||||
|
runtimeSessionId: string,
|
||||||
|
opts: InteractionOptions,
|
||||||
|
) => {
|
||||||
|
const request = await authRequest(opts);
|
||||||
|
print(
|
||||||
|
await enrollInteractionSession(request.gateway, request.cookie, {
|
||||||
|
...request,
|
||||||
|
sessionId,
|
||||||
|
providerId,
|
||||||
|
runtimeSessionId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
options(
|
||||||
|
command
|
||||||
|
.command('attach <sessionId>')
|
||||||
|
.description('Create a scoped read or write attachment and stream the session'),
|
||||||
|
)
|
||||||
|
.option('--control', 'Request control mode (provider policy may deny it)')
|
||||||
|
.action(async (sessionId: string, opts: InteractionOptions & { control?: boolean }) => {
|
||||||
|
const request = await authRequest(opts);
|
||||||
|
const attachment = await attachInteractionSession(request.gateway, request.cookie, {
|
||||||
|
...request,
|
||||||
|
sessionId,
|
||||||
|
mode: opts.control ? 'control' : 'read',
|
||||||
|
});
|
||||||
|
print(attachment);
|
||||||
|
for await (const event of streamInteractionSession(request.gateway, request.cookie, {
|
||||||
|
...request,
|
||||||
|
sessionId,
|
||||||
|
})) {
|
||||||
|
print(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const send = options(
|
||||||
|
command
|
||||||
|
.command('send <sessionId> <message>')
|
||||||
|
.description('Durably queue and dispatch a message'),
|
||||||
|
)
|
||||||
|
.option('--idempotency-key <key>', 'Stable idempotency key')
|
||||||
|
.action(
|
||||||
|
async (
|
||||||
|
sessionId: string,
|
||||||
|
message: string,
|
||||||
|
opts: InteractionOptions & { idempotencyKey?: string },
|
||||||
|
) => {
|
||||||
|
const request = await authRequest(opts);
|
||||||
|
print(
|
||||||
|
await sendInteractionMessage(request.gateway, request.cookie, {
|
||||||
|
...request,
|
||||||
|
sessionId,
|
||||||
|
content: message,
|
||||||
|
idempotencyKey: opts.idempotencyKey?.trim() || randomUUID(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
void send;
|
||||||
|
|
||||||
|
options(
|
||||||
|
command
|
||||||
|
.command('chat <sessionId> <message>')
|
||||||
|
.description('Send a chat message through the durable interaction session'),
|
||||||
|
)
|
||||||
|
.option('--idempotency-key <key>', 'Stable idempotency key')
|
||||||
|
.action(
|
||||||
|
async (
|
||||||
|
sessionId: string,
|
||||||
|
message: string,
|
||||||
|
opts: InteractionOptions & { idempotencyKey?: string },
|
||||||
|
) => {
|
||||||
|
const request = await authRequest(opts);
|
||||||
|
print(
|
||||||
|
await sendInteractionMessage(request.gateway, request.cookie, {
|
||||||
|
...request,
|
||||||
|
sessionId,
|
||||||
|
content: message,
|
||||||
|
idempotencyKey: opts.idempotencyKey?.trim() || randomUUID(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
options(
|
||||||
|
command
|
||||||
|
.command('stop <sessionId>')
|
||||||
|
.description('Terminate a session using a one-time exact-action approval'),
|
||||||
|
)
|
||||||
|
.requiredOption('--approval <ref>', 'Durable exact-action approval reference')
|
||||||
|
.action(async (sessionId: string, opts: InteractionOptions & { approval: string }) => {
|
||||||
|
const request = await authRequest(opts);
|
||||||
|
print(
|
||||||
|
await stopInteractionSession(request.gateway, request.cookie, {
|
||||||
|
...request,
|
||||||
|
sessionId,
|
||||||
|
approvalRef: opts.approval,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
options(
|
||||||
|
command
|
||||||
|
.command('recover <sessionId>')
|
||||||
|
.description(
|
||||||
|
'Recover durable inbox/checkpoint state without replaying ambiguous outbox effects',
|
||||||
|
),
|
||||||
|
).action(async (sessionId: string, opts: InteractionOptions) => {
|
||||||
|
const request = await authRequest(opts);
|
||||||
|
print(
|
||||||
|
await recoverInteractionSession(request.gateway, request.cookie, { ...request, sessionId }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
@@ -395,6 +395,9 @@ For required push/merge/issue-close/release actions, execute without routine con
|
|||||||
const persona = readPersonaContractBlock(mosaicHome, process.env['MOSAIC_AGENT_CLASS']);
|
const persona = readPersonaContractBlock(mosaicHome, process.env['MOSAIC_AGENT_CLASS']);
|
||||||
if (persona) parts.push('\n\n' + persona);
|
if (persona) parts.push('\n\n' + persona);
|
||||||
|
|
||||||
|
const toolPolicy = readFleetToolPolicyBlock(process.env['MOSAIC_AGENT_TOOL_POLICY']);
|
||||||
|
if (toolPolicy) parts.push('\n\n' + toolPolicy);
|
||||||
|
|
||||||
// Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
|
// Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
|
||||||
// and present in the roster), inject a comms cheat-sheet + peer roster so it
|
// and present in the roster), inject a comms cheat-sheet + peer roster so it
|
||||||
// knows how to reach the orchestrator and its peers from its first turn.
|
// knows how to reach the orchestrator and its peers from its first turn.
|
||||||
@@ -404,6 +407,17 @@ For required push/merge/issue-close/release actions, execute without routine con
|
|||||||
return parts.join('\n');
|
return parts.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readFleetToolPolicyBlock(policy: string | undefined): string {
|
||||||
|
if (policy !== 'operator-interaction') return '';
|
||||||
|
return [
|
||||||
|
'# Fleet Tool Policy (operator-interaction)',
|
||||||
|
'',
|
||||||
|
'Permitted: authorized conversation, status, retrieval, and safe diagnostics.',
|
||||||
|
'Denied by default: coding/general orchestration claims, direct fleet control, destructive actions, and credential access.',
|
||||||
|
'Delegate Mos-owned work through the authorized handoff boundary.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
|
/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
|
||||||
function buildRuntimePrompt(runtime: RuntimeName): string {
|
function buildRuntimePrompt(runtime: RuntimeName): string {
|
||||||
return composeContract(runtime);
|
return composeContract(runtime);
|
||||||
|
|||||||
110
packages/mosaic/src/fleet/interaction-service-profile.ts
Normal file
110
packages/mosaic/src/fleet/interaction-service-profile.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import YAML from 'yaml';
|
||||||
|
import type { FleetAgent } from '../commands/fleet.js';
|
||||||
|
|
||||||
|
const REQUIRED_RUNTIME = 'pi';
|
||||||
|
const REQUIRED_MODEL = 'openai/gpt-5.6-sol';
|
||||||
|
const REQUIRED_REASONING = 'high';
|
||||||
|
const REQUIRED_TOOL_POLICY = 'operator-interaction';
|
||||||
|
const AGENT_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
||||||
|
|
||||||
|
export interface InteractionServiceProfile {
|
||||||
|
runtime: string;
|
||||||
|
model: string;
|
||||||
|
reasoning: string;
|
||||||
|
toolPolicy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectiveInteractionPolicy {
|
||||||
|
agentName: string;
|
||||||
|
runtime: string;
|
||||||
|
model: string;
|
||||||
|
reasoning: string;
|
||||||
|
toolPolicy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InteractionServiceProfileError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: 'invalid_profile' | 'invalid_request',
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = InteractionServiceProfileError.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readInteractionServiceProfile(
|
||||||
|
path: string,
|
||||||
|
overrides: Partial<InteractionServiceProfile> = {},
|
||||||
|
): Promise<InteractionServiceProfile> {
|
||||||
|
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
|
||||||
|
if (!isRecord(parsed)) {
|
||||||
|
throw new InteractionServiceProfileError(
|
||||||
|
'invalid_profile',
|
||||||
|
'Service profile must be an object',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const profile: InteractionServiceProfile = {
|
||||||
|
runtime: valueOrUndefined(overrides.runtime, parsed['runtime']),
|
||||||
|
model: valueOrUndefined(overrides.model, parsed['model']),
|
||||||
|
reasoning: valueOrUndefined(overrides.reasoning, parsed['reasoning']),
|
||||||
|
toolPolicy: valueOrUndefined(overrides.toolPolicy, parsed['tool_policy']),
|
||||||
|
};
|
||||||
|
assertPinnedPolicy(profile);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function provisionInteractionService(
|
||||||
|
profile: InteractionServiceProfile,
|
||||||
|
input: { agentName: string },
|
||||||
|
): { rosterAgent: FleetAgent; effectivePolicy: EffectiveInteractionPolicy } {
|
||||||
|
assertPinnedPolicy(profile);
|
||||||
|
if (!AGENT_NAME_PATTERN.test(input.agentName)) {
|
||||||
|
throw new InteractionServiceProfileError(
|
||||||
|
'invalid_request',
|
||||||
|
'Agent name must contain only letters, numbers, dots, underscores, or hyphens',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const effectivePolicy: EffectiveInteractionPolicy = {
|
||||||
|
agentName: input.agentName,
|
||||||
|
runtime: profile.runtime,
|
||||||
|
model: profile.model,
|
||||||
|
reasoning: profile.reasoning,
|
||||||
|
toolPolicy: profile.toolPolicy,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
rosterAgent: {
|
||||||
|
name: input.agentName,
|
||||||
|
runtime: profile.runtime,
|
||||||
|
className: profile.toolPolicy,
|
||||||
|
modelHint: profile.model,
|
||||||
|
reasoningLevel: profile.reasoning,
|
||||||
|
toolPolicy: profile.toolPolicy,
|
||||||
|
persistentPersona: true,
|
||||||
|
},
|
||||||
|
effectivePolicy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPinnedPolicy(profile: InteractionServiceProfile): void {
|
||||||
|
const invalid =
|
||||||
|
profile.runtime !== REQUIRED_RUNTIME ||
|
||||||
|
profile.model !== REQUIRED_MODEL ||
|
||||||
|
profile.reasoning !== REQUIRED_REASONING ||
|
||||||
|
profile.toolPolicy !== REQUIRED_TOOL_POLICY;
|
||||||
|
if (invalid) {
|
||||||
|
throw new InteractionServiceProfileError(
|
||||||
|
'invalid_profile',
|
||||||
|
`Service profile must pin ${REQUIRED_RUNTIME}, ${REQUIRED_MODEL}, ${REQUIRED_REASONING}, and ${REQUIRED_TOOL_POLICY}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueOrUndefined(override: string | undefined, value: unknown): string {
|
||||||
|
if (override !== undefined) return override;
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
161
packages/mosaic/src/fleet/tess-service-profile.test.ts
Normal file
161
packages/mosaic/src/fleet/tess-service-profile.test.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { generateAgentEnv, loadFleetRoster } from '../commands/fleet.js';
|
||||||
|
import {
|
||||||
|
provisionInteractionService,
|
||||||
|
readInteractionServiceProfile,
|
||||||
|
} from './interaction-service-profile.js';
|
||||||
|
import type { InteractionServiceProfileError } from './interaction-service-profile.js';
|
||||||
|
|
||||||
|
const frameworkFleet = resolve(
|
||||||
|
dirname(fileURLToPath(import.meta.url)),
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'framework',
|
||||||
|
'fleet',
|
||||||
|
);
|
||||||
|
const profilePath = join(frameworkFleet, 'services', 'operator-interaction.yaml');
|
||||||
|
const examplePath = join(frameworkFleet, 'examples', 'operator-interaction.yaml');
|
||||||
|
const policyScript = join(
|
||||||
|
frameworkFleet,
|
||||||
|
'..',
|
||||||
|
'tools',
|
||||||
|
'fleet',
|
||||||
|
'print-interaction-effective-policy.sh',
|
||||||
|
);
|
||||||
|
const interactionStartScript = join(
|
||||||
|
frameworkFleet,
|
||||||
|
'..',
|
||||||
|
'tools',
|
||||||
|
'fleet',
|
||||||
|
'start-interaction-service.sh',
|
||||||
|
);
|
||||||
|
const agentStartScript = join(frameworkFleet, '..', 'tools', 'fleet', 'start-agent-session.sh');
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
describe('operator interaction service profile', (): void => {
|
||||||
|
it('provisions a user-supplied Nova identity as data with the required effective policy', async (): Promise<void> => {
|
||||||
|
const profile = await readInteractionServiceProfile(profilePath);
|
||||||
|
const provisioned = provisionInteractionService(profile, { agentName: 'Nova' });
|
||||||
|
|
||||||
|
expect(provisioned.rosterAgent).toEqual({
|
||||||
|
name: 'Nova',
|
||||||
|
runtime: 'pi',
|
||||||
|
className: 'operator-interaction',
|
||||||
|
modelHint: 'openai/gpt-5.6-sol',
|
||||||
|
reasoningLevel: 'high',
|
||||||
|
toolPolicy: 'operator-interaction',
|
||||||
|
persistentPersona: true,
|
||||||
|
});
|
||||||
|
expect(provisioned.effectivePolicy).toEqual({
|
||||||
|
agentName: 'Nova',
|
||||||
|
runtime: 'pi',
|
||||||
|
model: 'openai/gpt-5.6-sol',
|
||||||
|
reasoning: 'high',
|
||||||
|
toolPolicy: 'operator-interaction',
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(provisioned.effectivePolicy)).not.toMatch(
|
||||||
|
/token|secret|credential|api.?key/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a renamed example roster and surfaces only its effective policy', async (): Promise<void> => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'mosaic-interaction-profile-'));
|
||||||
|
const rosterPath = join(directory, 'roster.yaml');
|
||||||
|
try {
|
||||||
|
const example = await readFile(examplePath, 'utf8');
|
||||||
|
await writeFile(rosterPath, example.replace('name: Tess', 'name: Nova'), 'utf8');
|
||||||
|
const roster = await loadFleetRoster(rosterPath);
|
||||||
|
const agent = roster.agents[0]!;
|
||||||
|
|
||||||
|
expect(agent.name).toBe('Nova');
|
||||||
|
expect(agent.reasoningLevel).toBe('high');
|
||||||
|
expect(agent.toolPolicy).toBe('operator-interaction');
|
||||||
|
expect(generateAgentEnv(roster, agent)).toContain('MOSAIC_AGENT_NAME=Nova');
|
||||||
|
expect(generateAgentEnv(roster, agent)).toContain('MOSAIC_AGENT_REASONING=high');
|
||||||
|
|
||||||
|
const { stdout } = await execFileAsync(policyScript, [], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
|
MOSAIC_AGENT_MODEL: agent.modelHint,
|
||||||
|
MOSAIC_AGENT_REASONING: agent.reasoningLevel,
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.parse(stdout)).toEqual({
|
||||||
|
agentName: 'Nova',
|
||||||
|
runtime: 'pi',
|
||||||
|
model: 'openai/gpt-5.6-sol',
|
||||||
|
reasoning: 'high',
|
||||||
|
toolPolicy: 'operator-interaction',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails before launch when service environment drifts from the pinned policy', async (): Promise<void> => {
|
||||||
|
await expect(
|
||||||
|
execFileAsync(interactionStartScript, ['Nova'], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
MOSAIC_AGENT_NAME: 'Nova',
|
||||||
|
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||||
|
MOSAIC_AGENT_MODEL: 'other/model',
|
||||||
|
MOSAIC_AGENT_REASONING: 'high',
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY: 'operator-interaction',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 64 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsafe names from the policy printer and reasoning before tmux launch', async (): Promise<void> => {
|
||||||
|
await expect(
|
||||||
|
execFileAsync(policyScript, [], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
MOSAIC_AGENT_NAME: 'Nova"bad',
|
||||||
|
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||||
|
MOSAIC_AGENT_MODEL: 'openai/gpt-5.6-sol',
|
||||||
|
MOSAIC_AGENT_REASONING: 'high',
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY: 'operator-interaction',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 64 });
|
||||||
|
await expect(
|
||||||
|
execFileAsync(agentStartScript, ['Nova'], {
|
||||||
|
env: { ...process.env, MOSAIC_AGENT_REASONING: 'high; id' },
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 64 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the product name confined to an example instance, not service source or defaults', async (): Promise<void> => {
|
||||||
|
const [profile, source, example] = await Promise.all([
|
||||||
|
readFile(profilePath, 'utf8'),
|
||||||
|
readFile(new URL('./interaction-service-profile.ts', import.meta.url), 'utf8'),
|
||||||
|
readFile(examplePath, 'utf8'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(profile).not.toMatch(/tess/i);
|
||||||
|
expect(source).not.toMatch(/tess/i);
|
||||||
|
expect(example).toMatch(/name: Tess/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails fast when a required policy field is absent or changed from the pinned policy', async (): Promise<void> => {
|
||||||
|
await expect(readInteractionServiceProfile(profilePath, { model: '' })).rejects.toMatchObject({
|
||||||
|
code: 'invalid_profile',
|
||||||
|
} satisfies Partial<InteractionServiceProfileError>);
|
||||||
|
await expect(
|
||||||
|
readInteractionServiceProfile(profilePath, { reasoning: 'medium' }),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'invalid_profile',
|
||||||
|
} satisfies Partial<InteractionServiceProfileError>);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export const VERSION = '0.0.0';
|
export const VERSION = '0.0.0';
|
||||||
|
|
||||||
|
export * from './fleet/interaction-service-profile.js';
|
||||||
export * from './fleet/tmux-runtime-transport.js';
|
export * from './fleet/tmux-runtime-transport.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -361,6 +361,207 @@ export async function deleteMission(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Authenticated interaction runtime endpoints ──
|
||||||
|
|
||||||
|
export interface InteractionRequest {
|
||||||
|
agentName: string;
|
||||||
|
correlationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interactionHeaders(sessionCookie: string, gatewayUrl: string, correlationId?: string) {
|
||||||
|
return {
|
||||||
|
...jsonHeaders(sessionCookie, gatewayUrl),
|
||||||
|
...(correlationId ? { 'X-Correlation-Id': correlationId } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function interactionPath(agentName: string, suffix: string): string {
|
||||||
|
return `/api/interaction/${encodeURIComponent(agentName)}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInteractionSessions(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
request: InteractionRequest & { providerId: string },
|
||||||
|
): Promise<unknown[]> {
|
||||||
|
const params = new URLSearchParams({ provider: request.providerId });
|
||||||
|
const res = await fetch(
|
||||||
|
`${gatewayUrl}${interactionPath(request.agentName, `/sessions?${params}`)}`,
|
||||||
|
{
|
||||||
|
headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleResponse<unknown[]>(res, 'Failed to list interaction sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInteractionTree(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
request: InteractionRequest & { providerId: string },
|
||||||
|
): Promise<unknown[]> {
|
||||||
|
const params = new URLSearchParams({ provider: request.providerId });
|
||||||
|
const res = await fetch(`${gatewayUrl}${interactionPath(request.agentName, `/tree?${params}`)}`, {
|
||||||
|
headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId),
|
||||||
|
});
|
||||||
|
return handleResponse<unknown[]>(res, 'Failed to get interaction session tree');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrollInteractionSession(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
request: InteractionRequest & {
|
||||||
|
sessionId: string;
|
||||||
|
providerId: string;
|
||||||
|
runtimeSessionId: string;
|
||||||
|
},
|
||||||
|
): Promise<{ status: string; sessionId: string }> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/enroll`)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId),
|
||||||
|
body: JSON.stringify({
|
||||||
|
providerId: request.providerId,
|
||||||
|
runtimeSessionId: request.runtimeSessionId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleResponse<{ status: string; sessionId: string }>(
|
||||||
|
res,
|
||||||
|
'Failed to enroll interaction session',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachInteractionSession(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
request: InteractionRequest & { sessionId: string; mode?: 'read' | 'control' },
|
||||||
|
): Promise<unknown> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/attach`)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId),
|
||||||
|
body: JSON.stringify({ mode: request.mode ?? 'read' }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleResponse<unknown>(res, 'Failed to attach interaction session');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* streamInteractionSession(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
request: InteractionRequest & { sessionId: string; cursor?: string },
|
||||||
|
): AsyncIterable<unknown> {
|
||||||
|
const params = request.cursor?.trim()
|
||||||
|
? `?${new URLSearchParams({ cursor: request.cursor.trim() }).toString()}`
|
||||||
|
: '';
|
||||||
|
const res = await fetch(
|
||||||
|
`${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/stream${params}`)}`,
|
||||||
|
{ headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId) },
|
||||||
|
);
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Failed to stream interaction session (${res.status}): ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let pending = '';
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) break;
|
||||||
|
pending += decoder.decode(chunk.value, { stream: true });
|
||||||
|
for (;;) {
|
||||||
|
const separator = pending.indexOf('\n\n');
|
||||||
|
if (separator < 0) break;
|
||||||
|
const frame = pending.slice(0, separator);
|
||||||
|
pending = pending.slice(separator + 2);
|
||||||
|
const data = frame
|
||||||
|
.split('\n')
|
||||||
|
.find((line: string): boolean => line.startsWith('data:'))
|
||||||
|
?.slice('data:'.length)
|
||||||
|
.trim();
|
||||||
|
if (data) yield JSON.parse(data) as unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendInteractionMessage(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
request: InteractionRequest & { sessionId: string; content: string; idempotencyKey: string },
|
||||||
|
): Promise<{ status: string; sessionId: string }> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/send`)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId),
|
||||||
|
body: JSON.stringify({ content: request.content, idempotencyKey: request.idempotencyKey }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleResponse<{ status: string; sessionId: string }>(
|
||||||
|
res,
|
||||||
|
'Failed to send interaction message',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopInteractionSession(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
request: InteractionRequest & { sessionId: string; approvalRef: string },
|
||||||
|
): Promise<{ status: string; sessionId: string }> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/stop`)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId),
|
||||||
|
body: JSON.stringify({ approvalRef: request.approvalRef }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleResponse<{ status: string; sessionId: string }>(
|
||||||
|
res,
|
||||||
|
'Failed to stop interaction session',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoverInteractionSession(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
request: InteractionRequest & { sessionId: string },
|
||||||
|
): Promise<{ status: string; sessionId: string }> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/recover`)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleResponse<{ status: string; sessionId: string }>(
|
||||||
|
res,
|
||||||
|
'Failed to recover interaction session',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInteractionStatus(
|
||||||
|
gatewayUrl: string,
|
||||||
|
sessionCookie: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/providers/status`, {
|
||||||
|
headers: headers(sessionCookie, gatewayUrl),
|
||||||
|
});
|
||||||
|
return handleResponse<unknown>(res, 'Failed to get effective interaction policy');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInteractionHealth(gatewayUrl: string): Promise<unknown> {
|
||||||
|
const res = await fetch(`${gatewayUrl}/health/ready`);
|
||||||
|
return handleResponse<unknown>(res, 'Failed to get interaction readiness');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Conversation Message types ──
|
// ── Conversation Message types ──
|
||||||
|
|
||||||
export interface ConversationMessage {
|
export interface ConversationMessage {
|
||||||
|
|||||||
@@ -6,6 +6,24 @@ export type RuntimeCapability =
|
|||||||
| 'session.attach'
|
| 'session.attach'
|
||||||
| 'session.terminate';
|
| 'session.terminate';
|
||||||
export type RuntimeSessionState = 'starting' | 'active' | 'idle' | 'stopped' | 'failed';
|
export type RuntimeSessionState = 'starting' | 'active' | 'idle' | 'stopped' | 'failed';
|
||||||
|
|
||||||
|
/** Transitional capability inventory is normalized; provider legacy vocabularies never enter core. */
|
||||||
|
export type TransitionalRuntimeCapability = 'kanban' | 'skills' | 'memory' | 'tools' | 'cron';
|
||||||
|
export type TransitionalCapabilityStatus = 'supported' | 'unsupported';
|
||||||
|
export interface TransitionalCapabilityInventoryEntry {
|
||||||
|
capability: TransitionalRuntimeCapability;
|
||||||
|
status: TransitionalCapabilityStatus;
|
||||||
|
}
|
||||||
|
/** Optional extension for transitional adapters; not every runtime has Hermes inventory. */
|
||||||
|
export interface TransitionalCapabilityInventoryProvider {
|
||||||
|
transitionalCapabilityMatrix(
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): Promise<TransitionalCapabilityInventoryEntry[]>;
|
||||||
|
assertTransitionalCapability(
|
||||||
|
capability: TransitionalRuntimeCapability,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
export type RuntimeAttachMode = 'read' | 'control';
|
export type RuntimeAttachMode = 'read' | 'control';
|
||||||
|
|
||||||
/** Server-derived immutable authority context. Client identity fields are intentionally absent. */
|
/** Server-derived immutable authority context. Client identity fields are intentionally absent. */
|
||||||
|
|||||||
@@ -12,6 +12,129 @@ export interface DiscordPluginConfig {
|
|||||||
allowedGuildIds: readonly string[];
|
allowedGuildIds: readonly string[];
|
||||||
allowedChannelIds: readonly string[];
|
allowedChannelIds: readonly string[];
|
||||||
allowedUserIds: readonly string[];
|
allowedUserIds: readonly string[];
|
||||||
|
/** Provisioned interaction bindings; instance identity is configuration, never code. */
|
||||||
|
interactionBindings?: readonly DiscordInteractionBinding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscordInteractionOperation = 'bind' | 'attach' | 'send' | 'approve' | 'stop';
|
||||||
|
export type DiscordInteractionRole = 'viewer' | 'operator' | 'admin';
|
||||||
|
|
||||||
|
/** A provisioned Discord-to-Mosaic identity pairing. */
|
||||||
|
export interface DiscordInteractionUserBinding {
|
||||||
|
role: DiscordInteractionRole;
|
||||||
|
/** Required for privileged approval and stop operations. */
|
||||||
|
mosaicUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy role-only pairings remain valid for non-privileged Discord ingress. */
|
||||||
|
export type DiscordInteractionPairing = DiscordInteractionRole | DiscordInteractionUserBinding;
|
||||||
|
|
||||||
|
export interface DiscordInteractionBinding {
|
||||||
|
instanceId: string;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
/** Pairing roster keyed by Discord user ID. */
|
||||||
|
pairedUsers: Readonly<Record<string, DiscordInteractionPairing>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationRoles: Readonly<
|
||||||
|
Record<DiscordInteractionOperation, readonly DiscordInteractionRole[]>
|
||||||
|
> = {
|
||||||
|
bind: ['admin'],
|
||||||
|
attach: ['operator', 'admin'],
|
||||||
|
send: ['operator', 'admin'],
|
||||||
|
approve: ['admin'],
|
||||||
|
stop: ['admin'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolves a configuration-owned binding and applies pairing/RBAC before ingress. */
|
||||||
|
export function resolveDiscordInteractionBinding(
|
||||||
|
bindings: readonly DiscordInteractionBinding[],
|
||||||
|
guildId: string,
|
||||||
|
channelId: string,
|
||||||
|
userId: string,
|
||||||
|
operation: DiscordInteractionOperation,
|
||||||
|
): DiscordInteractionBinding | null {
|
||||||
|
const binding = bindings.find(
|
||||||
|
(candidate) => candidate.guildId === guildId && candidate.channelId === channelId,
|
||||||
|
);
|
||||||
|
if (!binding) return null;
|
||||||
|
const pairing = binding.pairedUsers[userId];
|
||||||
|
const role = typeof pairing === 'string' ? pairing : pairing?.role;
|
||||||
|
return role && operationRoles[operation].includes(role) ? binding : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves the provisioned Mosaic identity for an already-authorized Discord user. */
|
||||||
|
export function resolveDiscordInteractionActorId(
|
||||||
|
binding: DiscordInteractionBinding,
|
||||||
|
discordUserId: string,
|
||||||
|
): string | null {
|
||||||
|
const pairing = binding.pairedUsers[discordUserId];
|
||||||
|
if (typeof pairing === 'string') return null;
|
||||||
|
const mosaicUserId = pairing?.mosaicUserId?.trim();
|
||||||
|
return mosaicUserId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses provisioned binding roster JSON and rejects malformed or empty data. */
|
||||||
|
export function parseDiscordInteractionBindings(
|
||||||
|
value: string | undefined,
|
||||||
|
): DiscordInteractionBinding[] {
|
||||||
|
if (!value) throw new Error('DISCORD_INTERACTION_BINDINGS is required when Discord is enabled');
|
||||||
|
const parsed: unknown = JSON.parse(value);
|
||||||
|
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||||
|
throw new Error('DISCORD_INTERACTION_BINDINGS must be a non-empty JSON array');
|
||||||
|
}
|
||||||
|
return parsed.map((binding: unknown): DiscordInteractionBinding => {
|
||||||
|
if (typeof binding !== 'object' || binding === null)
|
||||||
|
throw new Error('Invalid Discord interaction binding');
|
||||||
|
const candidate = binding as Partial<DiscordInteractionBinding>;
|
||||||
|
if (
|
||||||
|
!candidate.instanceId ||
|
||||||
|
!candidate.guildId ||
|
||||||
|
!candidate.channelId ||
|
||||||
|
!candidate.pairedUsers ||
|
||||||
|
typeof candidate.pairedUsers !== 'object'
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid Discord interaction binding');
|
||||||
|
}
|
||||||
|
const pairedUsers = Object.fromEntries(
|
||||||
|
Object.entries(candidate.pairedUsers).map(([discordUserId, pairing]: [string, unknown]) => {
|
||||||
|
if (!discordUserId.trim()) {
|
||||||
|
throw new Error('Invalid Discord interaction user binding');
|
||||||
|
}
|
||||||
|
if (typeof pairing === 'string') {
|
||||||
|
if (!['viewer', 'operator', 'admin'].includes(pairing)) {
|
||||||
|
throw new Error('Invalid Discord interaction user binding');
|
||||||
|
}
|
||||||
|
return [discordUserId, pairing];
|
||||||
|
}
|
||||||
|
if (typeof pairing !== 'object' || pairing === null) {
|
||||||
|
throw new Error('Invalid Discord interaction user binding');
|
||||||
|
}
|
||||||
|
const userBinding = pairing as Partial<DiscordInteractionUserBinding>;
|
||||||
|
if (
|
||||||
|
!userBinding.role ||
|
||||||
|
!['viewer', 'operator', 'admin'].includes(userBinding.role) ||
|
||||||
|
(userBinding.mosaicUserId !== undefined && !userBinding.mosaicUserId.trim())
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid Discord interaction user binding');
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
discordUserId,
|
||||||
|
{
|
||||||
|
role: userBinding.role,
|
||||||
|
...(userBinding.mosaicUserId ? { mosaicUserId: userBinding.mosaicUserId } : {}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
) as Record<string, DiscordInteractionPairing>;
|
||||||
|
return {
|
||||||
|
instanceId: candidate.instanceId,
|
||||||
|
guildId: candidate.guildId,
|
||||||
|
channelId: candidate.channelId,
|
||||||
|
pairedUsers,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscordIngressPayload {
|
export interface DiscordIngressPayload {
|
||||||
@@ -22,6 +145,15 @@ export interface DiscordIngressPayload {
|
|||||||
userId: string;
|
userId: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
threadId?: string;
|
||||||
|
attachments?: readonly DiscordAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordAttachment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
contentType: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscordIngressEnvelope {
|
export interface DiscordIngressEnvelope {
|
||||||
@@ -44,6 +176,8 @@ function signedPayload(payload: DiscordIngressPayload): string {
|
|||||||
payload.userId,
|
payload.userId,
|
||||||
payload.conversationId,
|
payload.conversationId,
|
||||||
payload.content,
|
payload.content,
|
||||||
|
payload.threadId ?? '',
|
||||||
|
JSON.stringify(payload.attachments ?? []),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +348,18 @@ export class DiscordPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channelId = message.channelId;
|
const channelId = message.channelId;
|
||||||
const conversationId = this.channelConversations.get(channelId) ?? `discord-${channelId}`;
|
const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null;
|
||||||
|
const bindingChannelId = parentChannelId ?? channelId;
|
||||||
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
this.config.interactionBindings ?? [],
|
||||||
|
message.guildId,
|
||||||
|
bindingChannelId,
|
||||||
|
message.author.id,
|
||||||
|
'send',
|
||||||
|
);
|
||||||
|
if (!binding) return;
|
||||||
|
const conversationId =
|
||||||
|
this.channelConversations.get(channelId) ?? `${binding.instanceId}:discord:${channelId}`;
|
||||||
this.channelConversations.set(channelId, conversationId);
|
this.channelConversations.set(channelId, conversationId);
|
||||||
|
|
||||||
const envelope = createDiscordIngressEnvelope(
|
const envelope = createDiscordIngressEnvelope(
|
||||||
@@ -222,22 +367,39 @@ export class DiscordPlugin {
|
|||||||
correlationId: randomUUID(),
|
correlationId: randomUUID(),
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
guildId: message.guildId,
|
guildId: message.guildId,
|
||||||
channelId,
|
channelId: bindingChannelId,
|
||||||
userId: message.author.id,
|
userId: message.author.id,
|
||||||
conversationId,
|
conversationId,
|
||||||
content,
|
content,
|
||||||
|
threadId: parentChannelId ? channelId : undefined,
|
||||||
|
attachments: Array.from(message.attachments.values()).map((attachment) => ({
|
||||||
|
id: attachment.id,
|
||||||
|
name: attachment.name,
|
||||||
|
url: attachment.url,
|
||||||
|
contentType: attachment.contentType,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
this.config.serviceToken,
|
this.config.serviceToken,
|
||||||
);
|
);
|
||||||
this.socket.emit('message', envelope);
|
this.socket.emit(
|
||||||
|
/^\/approve$/i.test(content)
|
||||||
|
? 'discord:approve'
|
||||||
|
: content.startsWith('/stop ')
|
||||||
|
? 'discord:stop'
|
||||||
|
: 'message',
|
||||||
|
envelope,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isAllowedMessage(message: DiscordMessage): boolean {
|
private isAllowedMessage(message: DiscordMessage): boolean {
|
||||||
const guildId = message.guildId;
|
const guildId = message.guildId;
|
||||||
|
const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null;
|
||||||
|
// Threads inherit their authorization boundary from their configured parent.
|
||||||
|
const authorizationChannelId = parentChannelId ?? message.channelId;
|
||||||
return (
|
return (
|
||||||
guildId !== null &&
|
guildId !== null &&
|
||||||
includesId(this.config.allowedGuildIds, guildId) &&
|
includesId(this.config.allowedGuildIds, guildId) &&
|
||||||
includesId(this.config.allowedChannelIds, message.channelId) &&
|
includesId(this.config.allowedChannelIds, authorizationChannelId) &&
|
||||||
includesId(this.config.allowedUserIds, message.author.id)
|
includesId(this.config.allowedUserIds, message.author.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user