Compare commits
2 Commits
main
...
17932c3fb1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17932c3fb1 | ||
|
|
83a96b0081 |
@@ -7,7 +7,7 @@ import {
|
|||||||
} from '@mosaicstack/discord-plugin';
|
} from '@mosaicstack/discord-plugin';
|
||||||
import { InteractionController } from '../../agent/interaction.controller.js';
|
import { InteractionController } from '../../agent/interaction.controller.js';
|
||||||
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
|
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
|
||||||
import { TessDurableSessionService } from '../../agent/tess-durable-session.service.js';
|
import { DurableSessionService } from '../../agent/durable-session.service.js';
|
||||||
import { ChatGateway } from '../../chat/chat.gateway.js';
|
import { ChatGateway } from '../../chat/chat.gateway.js';
|
||||||
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
|
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ function authorization(): CommandAuthorizationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Tess Discord/CLI durable-session integration', () => {
|
describe('interaction Discord/CLI durable-session integration', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const key of envKeys) {
|
for (const key of envKeys) {
|
||||||
const value = priorEnv.get(key);
|
const value = priorEnv.get(key);
|
||||||
@@ -80,7 +80,7 @@ describe('Tess Discord/CLI durable-session integration', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const durable = new TessDurableSessionService(
|
const durable = new DurableSessionService(
|
||||||
new InMemoryDurableSessionStore() as never,
|
new InMemoryDurableSessionStore() as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ 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 { 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 { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
import { DurableSessionService } from './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';
|
||||||
@@ -44,8 +44,8 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
TessDurableSessionRepository,
|
DurableSessionRepository,
|
||||||
TessDurableSessionService,
|
DurableSessionService,
|
||||||
{
|
{
|
||||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
useFactory: createGatewayRuntimeProviderRegistry,
|
useFactory: createGatewayRuntimeProviderRegistry,
|
||||||
@@ -76,7 +76,7 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
TessDurableSessionService,
|
DurableSessionService,
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
|
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
/** Server-side request for a replay-safe provider message. */
|
/** Server-side request for a replay-safe provider message. */
|
||||||
export interface TessProviderOutboxDto {
|
export interface ProviderOutboxDto {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
correlationId: string;
|
correlationId: string;
|
||||||
@@ -6,8 +6,8 @@ import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/
|
|||||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
|
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
|
||||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
import { DurableSessionService } from './durable-session.service.js';
|
||||||
|
|
||||||
const IDENTITY: DurableSessionIdentity = {
|
const IDENTITY: DurableSessionIdentity = {
|
||||||
agentName: 'Nova',
|
agentName: 'Nova',
|
||||||
@@ -18,7 +18,7 @@ const IDENTITY: DurableSessionIdentity = {
|
|||||||
runtimeSessionId: 'nova',
|
runtimeSessionId: 'nova',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('TessDurableSessionRepository', () => {
|
describe('DurableSessionRepository', () => {
|
||||||
let dataDir: string | undefined;
|
let dataDir: string | undefined;
|
||||||
let handle: DbHandle;
|
let handle: DbHandle;
|
||||||
let previousAuthSecret: string | undefined;
|
let previousAuthSecret: string | undefined;
|
||||||
@@ -48,9 +48,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
|
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
|
||||||
const beforeRestart = new DurableSessionCoordinator(
|
const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
new TessDurableSessionRepository(handle.db),
|
|
||||||
);
|
|
||||||
await beforeRestart.create(IDENTITY);
|
await beforeRestart.create(IDENTITY);
|
||||||
await beforeRestart.receive({
|
await beforeRestart.receive({
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -92,7 +90,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
await handle.close();
|
await handle.close();
|
||||||
handle = createPgliteDb(dataDir!);
|
handle = createPgliteDb(dataDir!);
|
||||||
|
|
||||||
const afterRestart = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
const recovered = await afterRestart.recover(IDENTITY.sessionId);
|
const recovered = await afterRestart.recover(IDENTITY.sessionId);
|
||||||
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
|
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
|
||||||
const handled: string[] = [];
|
const handled: string[] = [];
|
||||||
@@ -120,7 +118,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('redacts sensitive durable payloads before persistence', async () => {
|
it('redacts sensitive durable payloads before persistence', async () => {
|
||||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
await coordinator.create(IDENTITY);
|
await coordinator.create(IDENTITY);
|
||||||
await coordinator.receive({
|
await coordinator.receive({
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -157,7 +155,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('fails closed when the configured idempotency secret is unavailable', async () => {
|
it('fails closed when the configured idempotency secret is unavailable', async () => {
|
||||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
await coordinator.create(IDENTITY);
|
await coordinator.create(IDENTITY);
|
||||||
const secret = process.env['BETTER_AUTH_SECRET'];
|
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||||
delete process.env['BETTER_AUTH_SECRET'];
|
delete process.env['BETTER_AUTH_SECRET'];
|
||||||
@@ -177,7 +175,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
|
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
|
||||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
await coordinator.create(IDENTITY);
|
await coordinator.create(IDENTITY);
|
||||||
const input = {
|
const input = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -227,7 +225,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
|
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
|
||||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
await coordinator.create(IDENTITY);
|
await coordinator.create(IDENTITY);
|
||||||
const inbox = {
|
const inbox = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -255,7 +253,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
|
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
|
||||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
await coordinator.create(IDENTITY);
|
await coordinator.create(IDENTITY);
|
||||||
await coordinator.receive({
|
await coordinator.receive({
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -293,10 +291,10 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
|
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
|
||||||
const repository = new TessDurableSessionRepository(handle.db);
|
const repository = new DurableSessionRepository(handle.db);
|
||||||
const coordinator = new DurableSessionCoordinator(repository);
|
const coordinator = new DurableSessionCoordinator(repository);
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||||
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
const service = new DurableSessionService(repository, runtimeProviders as never);
|
||||||
const input = {
|
const input = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
idempotencyKey: 'live-effect',
|
idempotencyKey: 'live-effect',
|
||||||
@@ -333,10 +331,10 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
|
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
|
||||||
const repository = new TessDurableSessionRepository(handle.db);
|
const repository = new DurableSessionRepository(handle.db);
|
||||||
const coordinator = new DurableSessionCoordinator(repository);
|
const coordinator = new DurableSessionCoordinator(repository);
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||||
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
const service = new DurableSessionService(repository, runtimeProviders as never);
|
||||||
const input = {
|
const input = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
idempotencyKey: 'mismatch-effect',
|
idempotencyKey: 'mismatch-effect',
|
||||||
@@ -366,10 +364,10 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
|
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
|
||||||
const repository = new TessDurableSessionRepository(handle.db);
|
const repository = new DurableSessionRepository(handle.db);
|
||||||
const coordinator = new DurableSessionCoordinator(repository);
|
const coordinator = new DurableSessionCoordinator(repository);
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||||
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
const service = new DurableSessionService(repository, runtimeProviders as never);
|
||||||
const first = {
|
const first = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
idempotencyKey: 'scoped-effect-one',
|
idempotencyKey: 'scoped-effect-one',
|
||||||
@@ -33,7 +33,7 @@ import type {
|
|||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TessDurableSessionRepository implements DurableSessionStore {
|
export class DurableSessionRepository implements DurableSessionStore {
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
async create(identity: DurableSessionIdentity): Promise<void> {
|
async create(identity: DurableSessionIdentity): Promise<void> {
|
||||||
@@ -51,7 +51,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
|
|
||||||
const existing = await this.session(identity.sessionId);
|
const existing = await this.session(identity.sessionId);
|
||||||
if (!existing || !sameEnrollmentScope(existing, identity)) {
|
if (!existing || !sameEnrollmentScope(existing, identity)) {
|
||||||
throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`);
|
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
|
||||||
}
|
}
|
||||||
// A recovered/re-enrolled runtime can receive a new provider session ID;
|
// A recovered/re-enrolled runtime can receive a new provider session ID;
|
||||||
// the conversation handle and owner scope remain immutable.
|
// the conversation handle and owner scope remain immutable.
|
||||||
@@ -135,13 +135,13 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!existing[0]) throw new Error(`Durable Tess inbox enqueue failed: ${input.idempotencyKey}`);
|
if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`);
|
||||||
const entry = toInbox(existing[0]);
|
const entry = toInbox(existing[0]);
|
||||||
if (
|
if (
|
||||||
!sameInbox(entry, record) ||
|
!sameInbox(entry, record) ||
|
||||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||||
) {
|
) {
|
||||||
throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`);
|
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
|
||||||
}
|
}
|
||||||
return { accepted: false, status: entry.status };
|
return { accepted: false, status: entry.status };
|
||||||
}
|
}
|
||||||
@@ -224,14 +224,13 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!existing[0])
|
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
|
||||||
throw new Error(`Durable Tess outbox enqueue failed: ${input.idempotencyKey}`);
|
|
||||||
const entry = toOutbox(existing[0]);
|
const entry = toOutbox(existing[0]);
|
||||||
if (
|
if (
|
||||||
!sameOutbox(entry, record) ||
|
!sameOutbox(entry, record) ||
|
||||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||||
) {
|
) {
|
||||||
throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`);
|
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
|
||||||
}
|
}
|
||||||
return { accepted: false, status: entry.status };
|
return { accepted: false, status: entry.status };
|
||||||
}
|
}
|
||||||
@@ -338,7 +337,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
|
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
|
||||||
!matchesCheckpointDigest(existing[0].contentDigest, digest)
|
!matchesCheckpointDigest(existing[0].contentDigest, digest)
|
||||||
) {
|
) {
|
||||||
throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`);
|
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +359,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
async handoff(input: DurableHandoffInput): Promise<void> {
|
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||||
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
|
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
|
||||||
if (!checkpoint) {
|
if (!checkpoint) {
|
||||||
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
|
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||||
}
|
}
|
||||||
const inserted = await this.db
|
const inserted = await this.db
|
||||||
.insert(interactionHandoffs)
|
.insert(interactionHandoffs)
|
||||||
@@ -371,7 +370,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
|
|
||||||
const existing = await this.findHandoff(input.handoffId);
|
const existing = await this.findHandoff(input.handoffId);
|
||||||
if (!existing || !sameHandoff(existing, input)) {
|
if (!existing || !sameHandoff(existing, input)) {
|
||||||
throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`);
|
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||||
import type { TessProviderOutboxDto } from './tess-durable-session.dto.js';
|
import type { ProviderOutboxDto } from './durable-session.dto.js';
|
||||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
import {
|
import {
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
type RuntimeProviderRequestContext,
|
type RuntimeProviderRequestContext,
|
||||||
} from './runtime-provider-registry.service.js';
|
} from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scoped gateway boundary for the canonical Tess state machine. It deliberately
|
* Scoped gateway boundary for the canonical durable session state machine. It deliberately
|
||||||
* uses composition: raw state methods cannot be injected into channel, CLI, or
|
* uses composition: raw state methods cannot be injected into channel, CLI, or
|
||||||
* MCP adapters without a server-derived actor/tenant/correlation context.
|
* MCP adapters without a server-derived actor/tenant/correlation context.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TessDurableSessionService {
|
export class DurableSessionService {
|
||||||
private readonly coordinator: DurableSessionCoordinator;
|
private readonly coordinator: DurableSessionCoordinator;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(TessDurableSessionRepository) repository: TessDurableSessionRepository,
|
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
|
||||||
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
|
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
|
||||||
) {
|
) {
|
||||||
this.coordinator = new DurableSessionCoordinator(repository);
|
this.coordinator = new DurableSessionCoordinator(repository);
|
||||||
@@ -32,12 +32,12 @@ export class TessDurableSessionService {
|
|||||||
identity.ownerId !== context.actorScope.userId ||
|
identity.ownerId !== context.actorScope.userId ||
|
||||||
identity.tenantId !== context.actorScope.tenantId
|
identity.tenantId !== context.actorScope.tenantId
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException('Durable Tess enrollment scope mismatch');
|
throw new ForbiddenException('Durable session enrollment scope mismatch');
|
||||||
}
|
}
|
||||||
await this.coordinator.create(identity);
|
await this.coordinator.create(identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async queueProviderSend(input: TessProviderOutboxDto): Promise<void> {
|
async queueProviderSend(input: ProviderOutboxDto): Promise<void> {
|
||||||
const snapshot = await this.coordinator.snapshot(input.sessionId);
|
const snapshot = await this.coordinator.snapshot(input.sessionId);
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
await this.coordinator.enqueueOutbox({
|
await this.coordinator.enqueueOutbox({
|
||||||
@@ -50,9 +50,9 @@ export class TessDurableSessionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispatchProviderOutbox(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
|
async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise<void> {
|
||||||
if (sessionId !== input.sessionId) {
|
if (sessionId !== input.sessionId) {
|
||||||
throw new ForbiddenException('Durable Tess outbox session mismatch');
|
throw new ForbiddenException('Durable outbox session mismatch');
|
||||||
}
|
}
|
||||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
@@ -92,7 +92,7 @@ export class TessDurableSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
|
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
|
||||||
async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
|
async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise<void> {
|
||||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
await this.coordinator.recover(sessionId);
|
await this.coordinator.recover(sessionId);
|
||||||
@@ -100,24 +100,24 @@ export class TessDurableSessionService {
|
|||||||
|
|
||||||
private assertOutboxScope(
|
private assertOutboxScope(
|
||||||
entry: { kind: string; correlationId: string; channelId: string },
|
entry: { kind: string; correlationId: string; channelId: string },
|
||||||
input: TessProviderOutboxDto,
|
input: ProviderOutboxDto,
|
||||||
): void {
|
): void {
|
||||||
if (
|
if (
|
||||||
entry.kind !== 'provider.send' ||
|
entry.kind !== 'provider.send' ||
|
||||||
entry.correlationId !== input.correlationId ||
|
entry.correlationId !== input.correlationId ||
|
||||||
entry.channelId !== input.context.channelId
|
entry.channelId !== input.context.channelId
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException('Durable Tess outbox scope or correlation mismatch');
|
throw new ForbiddenException('Durable outbox scope or correlation mismatch');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertScope(ownerId: string, tenantId: string, input: TessProviderOutboxDto): void {
|
private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void {
|
||||||
if (
|
if (
|
||||||
input.context.actorScope.userId !== ownerId ||
|
input.context.actorScope.userId !== ownerId ||
|
||||||
input.context.actorScope.tenantId !== tenantId ||
|
input.context.actorScope.tenantId !== tenantId ||
|
||||||
input.context.correlationId !== input.correlationId
|
input.context.correlationId !== input.correlationId
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException('Durable Tess session scope or correlation mismatch');
|
throw new ForbiddenException('Durable session scope or correlation mismatch');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,8 +21,8 @@ import {
|
|||||||
RUNTIME_PROVIDER_AUDIT_SINK,
|
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
RuntimeProviderAuditService,
|
RuntimeProviderAuditService,
|
||||||
} from './runtime-provider-registry.service.js';
|
} from './runtime-provider-registry.service.js';
|
||||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
import { DurableSessionService } from './durable-session.service.js';
|
||||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
import { AgentService } from './agent.service.js';
|
import { AgentService } from './agent.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
||||||
@@ -88,9 +88,9 @@ describe('Hermes runtime provider reachability', (): void => {
|
|||||||
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
|
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
|
||||||
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
|
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
|
||||||
.useValue({ consume: vi.fn().mockResolvedValue(false) })
|
.useValue({ consume: vi.fn().mockResolvedValue(false) })
|
||||||
.overrideProvider(TessDurableSessionService)
|
.overrideProvider(DurableSessionService)
|
||||||
.useValue({})
|
.useValue({})
|
||||||
.overrideProvider(TessDurableSessionRepository)
|
.overrideProvider(DurableSessionRepository)
|
||||||
.useValue({})
|
.useValue({})
|
||||||
.overrideProvider(AgentService)
|
.overrideProvider(AgentService)
|
||||||
.useValue({})
|
.useValue({})
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Observable } from 'rxjs';
|
|||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
import { DurableSessionService } from './durable-session.service.js';
|
||||||
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
||||||
import {
|
import {
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
export class InteractionController {
|
export class InteractionController {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
|
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
|
||||||
@Inject(TessDurableSessionService) private readonly durable: TessDurableSessionService,
|
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('sessions')
|
@Get('sessions')
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
type RuntimeAuditSink,
|
type RuntimeAuditSink,
|
||||||
} from '../agent/runtime-provider-registry.service.js';
|
} from '../agent/runtime-provider-registry.service.js';
|
||||||
import { TessDurableSessionService } from '../agent/tess-durable-session.service.js';
|
import { DurableSessionService } from '../agent/durable-session.service.js';
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
import {
|
import {
|
||||||
scopeFromUser,
|
scopeFromUser,
|
||||||
@@ -140,8 +140,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@Inject(RuntimeProviderService)
|
@Inject(RuntimeProviderService)
|
||||||
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(TessDurableSessionService)
|
@Inject(DurableSessionService)
|
||||||
private readonly durableSessions: TessDurableSessionService | null = null,
|
private readonly durableSessions: DurableSessionService | null = null,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
||||||
private readonly runtimeAudit: RuntimeAuditSink | null = null,
|
private readonly runtimeAudit: RuntimeAuditSink | null = null,
|
||||||
|
|||||||
@@ -1,31 +1,32 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { InMemoryMosCoordinationPort } from '@mosaicstack/coord';
|
import { InMemoryInteractionCoordinationPort } 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 { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
||||||
import {
|
import {
|
||||||
MOS_COORDINATION_CONFIG,
|
COORDINATION_CONFIG,
|
||||||
MOS_COORDINATION_PORT,
|
COORDINATION_PORT,
|
||||||
MosCoordinationService,
|
InteractionCoordinationService,
|
||||||
} from './mos-coordination.service.js';
|
} from './interaction-coordination.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
CoordService,
|
CoordService,
|
||||||
{
|
{
|
||||||
provide: MOS_COORDINATION_PORT,
|
provide: COORDINATION_PORT,
|
||||||
useFactory: (): InMemoryMosCoordinationPort => new InMemoryMosCoordinationPort(),
|
useFactory: (): InMemoryInteractionCoordinationPort =>
|
||||||
|
new InMemoryInteractionCoordinationPort(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: MOS_COORDINATION_CONFIG,
|
provide: COORDINATION_CONFIG,
|
||||||
useFactory: () => ({
|
useFactory: () => ({
|
||||||
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
|
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
|
||||||
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
|
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
MosCoordinationService,
|
InteractionCoordinationService,
|
||||||
],
|
],
|
||||||
controllers: [CoordController, MosCoordinationController],
|
controllers: [CoordController, InteractionCoordinationController],
|
||||||
exports: [CoordService, MosCoordinationService],
|
exports: [CoordService, InteractionCoordinationService],
|
||||||
})
|
})
|
||||||
export class CoordModule {}
|
export class CoordModule {}
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
|
const PATH_METADATA = 'path';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { MosCoordinationController } from './mos-coordination.controller.js';
|
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
||||||
|
|
||||||
const user = { id: 'operator-1', tenantId: 'tenant-a' };
|
const user = { id: 'operator-1', tenantId: 'tenant-a' };
|
||||||
|
|
||||||
describe('MosCoordinationController', () => {
|
describe('InteractionCoordinationController', () => {
|
||||||
|
it('exposes the neutral canonical route and Mos compatibility alias over identical handlers', () => {
|
||||||
|
expect(Reflect.getMetadata(PATH_METADATA, InteractionCoordinationController)).toEqual([
|
||||||
|
'api/coord/interaction',
|
||||||
|
'api/coord/mos',
|
||||||
|
]);
|
||||||
|
expect(InteractionCoordinationController.prototype.handoff).toBeTypeOf('function');
|
||||||
|
expect(InteractionCoordinationController.prototype.observe).toBeTypeOf('function');
|
||||||
|
expect(InteractionCoordinationController.prototype.result).toBeTypeOf('function');
|
||||||
|
});
|
||||||
|
|
||||||
it('derives actor and tenant from the authenticated user rather than handoff input', async () => {
|
it('derives actor and tenant from the authenticated user rather than handoff input', async () => {
|
||||||
const coordination = {
|
const coordination = {
|
||||||
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
||||||
observe: vi.fn(),
|
observe: vi.fn(),
|
||||||
result: vi.fn(),
|
result: vi.fn(),
|
||||||
};
|
};
|
||||||
const controller = new MosCoordinationController(coordination as never);
|
const controller = new InteractionCoordinationController(coordination as never);
|
||||||
|
|
||||||
await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1');
|
await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1');
|
||||||
|
|
||||||
@@ -26,7 +37,7 @@ describe('MosCoordinationController', () => {
|
|||||||
|
|
||||||
it('requires a correlation header before invoking the coordination service', async () => {
|
it('requires a correlation header before invoking the coordination service', async () => {
|
||||||
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
|
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
|
||||||
const controller = new MosCoordinationController(coordination as never);
|
const controller = new InteractionCoordinationController(coordination as never);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),
|
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),
|
||||||
@@ -14,27 +14,29 @@ import { CurrentUser } from '../auth/current-user.decorator.js';
|
|||||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
import type {
|
import type {
|
||||||
MosCoordinationObservationDto,
|
InteractionCoordinationObservationDto,
|
||||||
MosCoordinationResponseDto,
|
InteractionCoordinationResponseDto,
|
||||||
MosCoordinationResultDto,
|
InteractionCoordinationResultDto,
|
||||||
CreateMosHandoffDto,
|
CreateHandoffDto,
|
||||||
} from './mos-coordination.dto.js';
|
} from './interaction-coordination.dto.js';
|
||||||
import { MosCoordinationService } from './mos-coordination.service.js';
|
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
||||||
|
|
||||||
/** Authenticated interaction-plane boundary for the handoff/observe/result-only Mos contract. */
|
/** Authenticated interaction-plane boundary for the handoff/observe/result-only interaction coordination contract. */
|
||||||
@Controller('api/coord/mos')
|
/** `api/coord/interaction` is canonical; the Mos path remains a compatibility alias. */
|
||||||
|
@Controller(['api/coord/interaction', 'api/coord/mos'])
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class MosCoordinationController {
|
export class InteractionCoordinationController {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(MosCoordinationService) private readonly coordination: MosCoordinationService,
|
@Inject(InteractionCoordinationService)
|
||||||
|
private readonly coordination: InteractionCoordinationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('handoff')
|
@Post('handoff')
|
||||||
async handoff(
|
async handoff(
|
||||||
@Body() request: CreateMosHandoffDto,
|
@Body() request: CreateHandoffDto,
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
): Promise<MosCoordinationResponseDto> {
|
): Promise<InteractionCoordinationResponseDto> {
|
||||||
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
|
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ export class MosCoordinationController {
|
|||||||
@Param('handoffId') handoffId: string,
|
@Param('handoffId') handoffId: string,
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
): Promise<MosCoordinationObservationDto> {
|
): Promise<InteractionCoordinationObservationDto> {
|
||||||
return {
|
return {
|
||||||
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
|
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
|
||||||
};
|
};
|
||||||
@@ -54,7 +56,7 @@ export class MosCoordinationController {
|
|||||||
@Param('handoffId') handoffId: string,
|
@Param('handoffId') handoffId: string,
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
): Promise<MosCoordinationResultDto> {
|
): Promise<InteractionCoordinationResultDto> {
|
||||||
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
|
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
import type {
|
import type {
|
||||||
CoordinationObservation,
|
CoordinationObservation,
|
||||||
CoordinationResult,
|
CoordinationResult,
|
||||||
MosHandoffReceipt,
|
HandoffReceipt,
|
||||||
} from '@mosaicstack/coord';
|
} from '@mosaicstack/coord';
|
||||||
|
|
||||||
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
|
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
|
||||||
export interface CreateMosHandoffDto {
|
export interface CreateHandoffDto {
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
context?: string;
|
context?: string;
|
||||||
missionId?: string;
|
missionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosCoordinationResponseDto {
|
export interface InteractionCoordinationResponseDto {
|
||||||
receipt: MosHandoffReceipt;
|
receipt: HandoffReceipt;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosCoordinationObservationDto {
|
export interface InteractionCoordinationObservationDto {
|
||||||
observation: CoordinationObservation;
|
observation: CoordinationObservation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosCoordinationResultDto {
|
export interface InteractionCoordinationResultDto {
|
||||||
result: CoordinationResult;
|
result: CoordinationResult;
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
InMemoryMosCoordinationPort,
|
InMemoryInteractionCoordinationPort,
|
||||||
type MosCoordinationPort,
|
type InteractionCoordinationPort,
|
||||||
type MosHandoff,
|
type Handoff,
|
||||||
} from '@mosaicstack/coord';
|
} from '@mosaicstack/coord';
|
||||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
import {
|
import {
|
||||||
MosCoordinationService,
|
InteractionCoordinationService,
|
||||||
type MosCoordinationConfig,
|
type InteractionCoordinationConfig,
|
||||||
type MosCoordinationGatewayError,
|
type InteractionCoordinationGatewayError,
|
||||||
} from './mos-coordination.service.js';
|
} from './interaction-coordination.service.js';
|
||||||
|
|
||||||
const context: RuntimeProviderRequestContext = {
|
const context: RuntimeProviderRequestContext = {
|
||||||
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
||||||
@@ -17,28 +17,28 @@ const context: RuntimeProviderRequestContext = {
|
|||||||
correlationId: 'corr-1',
|
correlationId: 'corr-1',
|
||||||
};
|
};
|
||||||
|
|
||||||
const config: MosCoordinationConfig = {
|
const config: InteractionCoordinationConfig = {
|
||||||
interactionAgentId: 'Nova',
|
interactionAgentId: 'Nova',
|
||||||
orchestrationAgentId: 'Conductor',
|
orchestrationAgentId: 'Conductor',
|
||||||
};
|
};
|
||||||
|
|
||||||
function service(
|
function service(
|
||||||
port: MosCoordinationPort = new InMemoryMosCoordinationPort(),
|
port: InteractionCoordinationPort = new InMemoryInteractionCoordinationPort(),
|
||||||
options: {
|
options: {
|
||||||
config?: MosCoordinationConfig;
|
config?: InteractionCoordinationConfig;
|
||||||
handoffIdFactory?: () => string;
|
handoffIdFactory?: () => string;
|
||||||
} = {},
|
} = {},
|
||||||
): MosCoordinationService {
|
): InteractionCoordinationService {
|
||||||
return new MosCoordinationService(
|
return new InteractionCoordinationService(
|
||||||
port,
|
port,
|
||||||
options.config ?? config,
|
options.config ?? config,
|
||||||
options.handoffIdFactory ?? (() => 'handoff-1'),
|
options.handoffIdFactory ?? (() => 'handoff-1'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('MosCoordinationService authority boundary', (): void => {
|
describe('InteractionCoordinationService authority boundary', (): void => {
|
||||||
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
|
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
|
||||||
const adapter = new InMemoryMosCoordinationPort();
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
const coordination = service(adapter);
|
const coordination = service(adapter);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -53,8 +53,8 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
correlationId: 'corr-1',
|
correlationId: 'corr-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request');
|
adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request');
|
||||||
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
|
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
|
||||||
|
|
||||||
const followUpContext = { ...context, correlationId: 'corr-2' };
|
const followUpContext = { ...context, correlationId: 'corr-2' };
|
||||||
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
|
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||||
@@ -64,12 +64,12 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
|
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||||
targetAgentId: 'Conductor',
|
targetAgentId: 'Conductor',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
summary: 'Merged by Mos',
|
summary: 'Merged by orchestrator',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
|
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
|
||||||
const adapter = new InMemoryMosCoordinationPort();
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
const handoff = vi.spyOn(adapter, 'handoff');
|
const handoff = vi.spyOn(adapter, 'handoff');
|
||||||
const coordination = service(adapter, {
|
const coordination = service(adapter, {
|
||||||
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
|
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
|
||||||
@@ -82,12 +82,12 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
),
|
),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'unconfigured_requester',
|
code: 'unconfigured_requester',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
expect(handoff).not.toHaveBeenCalled();
|
expect(handoff).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
|
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
|
||||||
const adapter = new InMemoryMosCoordinationPort();
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
const handoff = vi.spyOn(adapter, 'handoff');
|
const handoff = vi.spyOn(adapter, 'handoff');
|
||||||
const coordination = service(adapter, {
|
const coordination = service(adapter, {
|
||||||
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
||||||
@@ -103,7 +103,7 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
|
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
|
||||||
const adapter = new InMemoryMosCoordinationPort();
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
const observe = vi.spyOn(adapter, 'observe');
|
const observe = vi.spyOn(adapter, 'observe');
|
||||||
const result = vi.spyOn(adapter, 'result');
|
const result = vi.spyOn(adapter, 'result');
|
||||||
const coordination = service(adapter);
|
const coordination = service(adapter);
|
||||||
@@ -118,10 +118,10 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
};
|
};
|
||||||
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
|
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||||
code: 'cross_tenant_forbidden',
|
code: 'cross_tenant_forbidden',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
|
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||||
code: 'cross_tenant_forbidden',
|
code: 'cross_tenant_forbidden',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
expect(observe).not.toHaveBeenCalled();
|
expect(observe).not.toHaveBeenCalled();
|
||||||
expect(result).not.toHaveBeenCalled();
|
expect(result).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -132,8 +132,8 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
const delivered = new Promise<void>((resolve: () => void): void => {
|
const delivered = new Promise<void>((resolve: () => void): void => {
|
||||||
release = resolve;
|
release = resolve;
|
||||||
});
|
});
|
||||||
const adapter: MosCoordinationPort = {
|
const adapter: InteractionCoordinationPort = {
|
||||||
handoff: vi.fn(async (handoff: MosHandoff) => {
|
handoff: vi.fn(async (handoff: Handoff) => {
|
||||||
await delivered;
|
await delivered;
|
||||||
return {
|
return {
|
||||||
handoffId: handoff.handoffId,
|
handoffId: handoff.handoffId,
|
||||||
@@ -169,7 +169,7 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
|
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
|
||||||
const adapter = new InMemoryMosCoordinationPort();
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
const handoff = vi.spyOn(adapter, 'handoff');
|
const handoff = vi.spyOn(adapter, 'handoff');
|
||||||
const coordination = service(adapter);
|
const coordination = service(adapter);
|
||||||
await coordination.handoff(
|
await coordination.handoff(
|
||||||
@@ -181,23 +181,23 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
|
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'handoff_conflict',
|
code: 'handoff_conflict',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
await expect(
|
await expect(
|
||||||
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
|
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'invalid_request',
|
code: 'invalid_request',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
await expect(
|
await expect(
|
||||||
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
|
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'invalid_request',
|
code: 'invalid_request',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
expect(handoff).toHaveBeenCalledTimes(1);
|
expect(handoff).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
|
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
|
||||||
const adapter: MosCoordinationPort = {
|
const adapter: InteractionCoordinationPort = {
|
||||||
handoff: vi.fn(async (handoff: MosHandoff) => ({
|
handoff: vi.fn(async (handoff: Handoff) => ({
|
||||||
handoffId: handoff.handoffId,
|
handoffId: handoff.handoffId,
|
||||||
targetAgentId: 'Unexpected',
|
targetAgentId: 'Unexpected',
|
||||||
status: 'accepted' as const,
|
status: 'accepted' as const,
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
MosCoordinationClient,
|
InteractionCoordinationClient,
|
||||||
type CoordinationObservation,
|
type CoordinationObservation,
|
||||||
type CoordinationResult,
|
type CoordinationResult,
|
||||||
type CoordinationScope,
|
type CoordinationScope,
|
||||||
type MosCoordinationIdentity,
|
type InteractionCoordinationIdentity,
|
||||||
type MosCoordinationPort,
|
type InteractionCoordinationPort,
|
||||||
type MosHandoffReceipt,
|
type HandoffReceipt,
|
||||||
} from '@mosaicstack/coord';
|
} from '@mosaicstack/coord';
|
||||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
import type { CreateMosHandoffDto } from './mos-coordination.dto.js';
|
import type { CreateHandoffDto } from './interaction-coordination.dto.js';
|
||||||
|
|
||||||
export const MOS_COORDINATION_PORT = Symbol('MOS_COORDINATION_PORT');
|
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
||||||
export const MOS_COORDINATION_CONFIG = Symbol('MOS_COORDINATION_CONFIG');
|
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
||||||
|
|
||||||
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||||
const MAX_TRACKED_HANDOFFS = 1_000;
|
const MAX_TRACKED_HANDOFFS = 1_000;
|
||||||
@@ -21,7 +21,7 @@ const MAX_SUMMARY_LENGTH = 2_048;
|
|||||||
const MAX_CONTEXT_LENGTH = 8_192;
|
const MAX_CONTEXT_LENGTH = 8_192;
|
||||||
const MAX_MISSION_ID_LENGTH = 128;
|
const MAX_MISSION_ID_LENGTH = 128;
|
||||||
|
|
||||||
export interface MosCoordinationConfig {
|
export interface InteractionCoordinationConfig {
|
||||||
interactionAgentId?: string;
|
interactionAgentId?: string;
|
||||||
orchestrationAgentId?: string;
|
orchestrationAgentId?: string;
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ interface HandoffOwner {
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NormalizedMosHandoffRequest {
|
interface NormalizedHandoffRequest {
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
context?: string;
|
context?: string;
|
||||||
@@ -42,31 +42,31 @@ interface NormalizedMosHandoffRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TrackedHandoff {
|
interface TrackedHandoff {
|
||||||
request: NormalizedMosHandoffRequest;
|
request: NormalizedHandoffRequest;
|
||||||
receipt: Promise<MosHandoffReceipt>;
|
receipt: Promise<HandoffReceipt>;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gateway authority boundary for the interaction agent. It derives requester,
|
* Gateway authority boundary for the interaction agent. It derives requester,
|
||||||
* actor, and tenant from trusted server configuration and authentication; no
|
* actor, and tenant from trusted server configuration and authentication; no
|
||||||
* channel request can name a target or gain Mos-owned orchestration verbs.
|
* channel request can name a target or gain orchestrator-owned orchestration verbs.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MosCoordinationService {
|
export class InteractionCoordinationService {
|
||||||
private readonly owners = new Map<string, HandoffOwner>();
|
private readonly owners = new Map<string, HandoffOwner>();
|
||||||
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
|
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(MOS_COORDINATION_PORT) private readonly port: MosCoordinationPort,
|
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
||||||
@Inject(MOS_COORDINATION_CONFIG) private readonly config: MosCoordinationConfig,
|
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
||||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handoff(
|
async handoff(
|
||||||
request: CreateMosHandoffDto,
|
request: CreateHandoffDto,
|
||||||
context: RuntimeProviderRequestContext,
|
context: RuntimeProviderRequestContext,
|
||||||
): Promise<MosHandoffReceipt> {
|
): Promise<HandoffReceipt> {
|
||||||
this.pruneExpiredTracking();
|
this.pruneExpiredTracking();
|
||||||
const normalized = this.normalizeRequest(request);
|
const normalized = this.normalizeRequest(request);
|
||||||
const scope = this.scope(context);
|
const scope = this.scope(context);
|
||||||
@@ -74,9 +74,9 @@ export class MosCoordinationService {
|
|||||||
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
|
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
if (!sameRequest(existing.request, normalized)) {
|
if (!sameRequest(existing.request, normalized)) {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'handoff_conflict',
|
'handoff_conflict',
|
||||||
'Mos handoff idempotency key is already bound to different immutable input',
|
'Handoff idempotency key is already bound to different immutable input',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return existing.receipt;
|
return existing.receipt;
|
||||||
@@ -122,9 +122,9 @@ export class MosCoordinationService {
|
|||||||
|
|
||||||
private async deliverHandoff(
|
private async deliverHandoff(
|
||||||
handoffId: string,
|
handoffId: string,
|
||||||
request: NormalizedMosHandoffRequest,
|
request: NormalizedHandoffRequest,
|
||||||
scope: CoordinationScope,
|
scope: CoordinationScope,
|
||||||
): Promise<MosHandoffReceipt> {
|
): Promise<HandoffReceipt> {
|
||||||
const receipt = await this.client((): string => handoffId).handoff(request, scope);
|
const receipt = await this.client((): string => handoffId).handoff(request, scope);
|
||||||
const owner: HandoffOwner = {
|
const owner: HandoffOwner = {
|
||||||
actorId: scope.actorId,
|
actorId: scope.actorId,
|
||||||
@@ -135,9 +135,9 @@ export class MosCoordinationService {
|
|||||||
};
|
};
|
||||||
const existing = this.owners.get(receipt.handoffId);
|
const existing = this.owners.get(receipt.handoffId);
|
||||||
if (existing !== undefined && !sameOwner(existing, owner)) {
|
if (existing !== undefined && !sameOwner(existing, owner)) {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'handoff_conflict',
|
'handoff_conflict',
|
||||||
'Mos handoff ID is already bound to a different authenticated scope',
|
'Handoff ID is already bound to a different authenticated scope',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.owners.set(receipt.handoffId, owner);
|
this.owners.set(receipt.handoffId, owner);
|
||||||
@@ -145,21 +145,21 @@ export class MosCoordinationService {
|
|||||||
return receipt;
|
return receipt;
|
||||||
}
|
}
|
||||||
|
|
||||||
private client(handoffIdFactory?: () => string): MosCoordinationClient {
|
private client(handoffIdFactory?: () => string): InteractionCoordinationClient {
|
||||||
return new MosCoordinationClient(this.identity(), this.port, handoffIdFactory);
|
return new InteractionCoordinationClient(this.identity(), this.port, handoffIdFactory);
|
||||||
}
|
}
|
||||||
|
|
||||||
private identity(): MosCoordinationIdentity {
|
private identity(): InteractionCoordinationIdentity {
|
||||||
const interactionAgentId = this.config.interactionAgentId?.trim();
|
const interactionAgentId = this.config.interactionAgentId?.trim();
|
||||||
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
|
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
|
||||||
if (!interactionAgentId) {
|
if (!interactionAgentId) {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'unconfigured_requester',
|
'unconfigured_requester',
|
||||||
'Interaction agent identity is not configured',
|
'Interaction agent identity is not configured',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!orchestrationAgentId) {
|
if (!orchestrationAgentId) {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'unconfigured_target',
|
'unconfigured_target',
|
||||||
'Orchestration agent identity is not configured',
|
'Orchestration agent identity is not configured',
|
||||||
);
|
);
|
||||||
@@ -177,9 +177,12 @@ export class MosCoordinationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeRequest(request: CreateMosHandoffDto): NormalizedMosHandoffRequest {
|
private normalizeRequest(request: CreateHandoffDto): NormalizedHandoffRequest {
|
||||||
if (typeof request !== 'object' || request === null) {
|
if (typeof request !== 'object' || request === null) {
|
||||||
throw new MosCoordinationGatewayError('invalid_request', 'Mos handoff request is invalid');
|
throw new InteractionCoordinationGatewayError(
|
||||||
|
'invalid_request',
|
||||||
|
'Handoff request is invalid',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const idempotencyKey = this.requiredString(
|
const idempotencyKey = this.requiredString(
|
||||||
request.idempotencyKey,
|
request.idempotencyKey,
|
||||||
@@ -203,14 +206,17 @@ export class MosCoordinationService {
|
|||||||
|
|
||||||
private requiredString(value: unknown, field: string, maximumLength: number): string {
|
private requiredString(value: unknown, field: string, maximumLength: number): string {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'invalid_request',
|
'invalid_request',
|
||||||
`Mos handoff ${field} must be a string`,
|
`Handoff ${field} must be a string`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const normalized = value.trim();
|
const normalized = value.trim();
|
||||||
if (normalized.length === 0 || normalized.length > maximumLength) {
|
if (normalized.length === 0 || normalized.length > maximumLength) {
|
||||||
throw new MosCoordinationGatewayError('invalid_request', `Mos handoff ${field} is invalid`);
|
throw new InteractionCoordinationGatewayError(
|
||||||
|
'invalid_request',
|
||||||
|
`Handoff ${field} is invalid`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -245,23 +251,23 @@ export class MosCoordinationService {
|
|||||||
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
|
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
|
||||||
const owner = this.owners.get(handoffId);
|
const owner = this.owners.get(handoffId);
|
||||||
if (owner === undefined) {
|
if (owner === undefined) {
|
||||||
throw new MosCoordinationGatewayError('not_found', 'Mos handoff was not found');
|
throw new InteractionCoordinationGatewayError('not_found', 'Handoff was not found');
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
owner.tenantId !== scope.tenantId ||
|
owner.tenantId !== scope.tenantId ||
|
||||||
owner.actorId !== scope.actorId ||
|
owner.actorId !== scope.actorId ||
|
||||||
owner.requesterAgentId !== scope.requesterAgentId
|
owner.requesterAgentId !== scope.requesterAgentId
|
||||||
) {
|
) {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'cross_tenant_forbidden',
|
'cross_tenant_forbidden',
|
||||||
'Mos handoff is outside the authenticated scope',
|
'Handoff is outside the authenticated scope',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return owner;
|
return owner;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MosCoordinationGatewayErrorCode =
|
export type InteractionCoordinationGatewayErrorCode =
|
||||||
| 'cross_tenant_forbidden'
|
| 'cross_tenant_forbidden'
|
||||||
| 'handoff_conflict'
|
| 'handoff_conflict'
|
||||||
| 'invalid_request'
|
| 'invalid_request'
|
||||||
@@ -277,10 +283,7 @@ function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameRequest(
|
function sameRequest(left: NormalizedHandoffRequest, right: NormalizedHandoffRequest): boolean {
|
||||||
left: NormalizedMosHandoffRequest,
|
|
||||||
right: NormalizedMosHandoffRequest,
|
|
||||||
): boolean {
|
|
||||||
return (
|
return (
|
||||||
left.idempotencyKey === right.idempotencyKey &&
|
left.idempotencyKey === right.idempotencyKey &&
|
||||||
left.summary === right.summary &&
|
left.summary === right.summary &&
|
||||||
@@ -289,12 +292,12 @@ function sameRequest(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MosCoordinationGatewayError extends Error {
|
export class InteractionCoordinationGatewayError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
readonly code: MosCoordinationGatewayErrorCode,
|
readonly code: InteractionCoordinationGatewayErrorCode,
|
||||||
message: string,
|
message: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = MosCoordinationGatewayError.name;
|
this.name = InteractionCoordinationGatewayError.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
43
docs/scratchpads/747-wsa-dehardcode.md
Normal file
43
docs/scratchpads/747-wsa-dehardcode.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# #747 — De-hardcode orchestrator and interaction agent names
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Replace branded Mos/Tess symbols, filenames, DI tokens, and error prose with role-neutral orchestrator/interaction vocabulary without changing env-driven runtime identity behavior. Add optional roster `alias` and `provider` fields and show aliases in `mosaic fleet ps` with name fallback.
|
||||||
|
|
||||||
|
## Scope and constraints
|
||||||
|
|
||||||
|
- Requirements: `/home/hermes/agent-work/reviews/747-wsa-dehardcode-brief.md`.
|
||||||
|
- Branch: `feat/747-dehardcode-orchestrator-interaction-names` from `main` at `e72388b2`.
|
||||||
|
- Keep `MOSAIC_AGENT_NAME` and `MOSAIC_ORCHESTRATOR_AGENT_NAME` unchanged.
|
||||||
|
- Sample/test data may retain operator display names.
|
||||||
|
- No behavior change beyond optional roster metadata and alias display.
|
||||||
|
- Budget: no explicit cap; conservative mechanical-rename scope only.
|
||||||
|
- TDD: optional and skipped because this is a mechanical rename with existing focused coverage; add focused alias/schema regression coverage before completion.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Rename coordination and durable-session files and symbols using canonical vocabulary.
|
||||||
|
2. Scrub branded symbol names and error prose in the assigned source trees while preserving allowed sample data.
|
||||||
|
3. Extend roster schema with optional `alias` and `provider`; update fleet roster typing/rendering and focused tests.
|
||||||
|
4. Run grep-clean verification, build, typecheck, lint/format, focused coord/durable-session/fleet tests, and roster validation.
|
||||||
|
5. Commit, queue-guard, push, open a Gitea PR closing #747, and report to the coordinator.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- 2026-07-13: Task resumed from coordinator brief; repository clean at `e72388b2`.
|
||||||
|
- Renamed coordination and durable-session files, exports, gateway DI symbols, DTOs, services, repositories, and tests.
|
||||||
|
- Replaced branded authority/error prose while preserving the existing `/api/coord/mos` compatibility route and env-variable identity inputs.
|
||||||
|
- Added optional roster `alias`/`provider` support and alias-first `fleet ps` display with canonical-name fallback.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm typecheck`: passed (42 tasks).
|
||||||
|
- `pnpm build`: passed (23 tasks).
|
||||||
|
- `pnpm lint`: passed (23 tasks).
|
||||||
|
- `pnpm format:check`: passed.
|
||||||
|
- Coordination tests: 7 passed.
|
||||||
|
- Agent durable-session/runtime tests: 23 passed.
|
||||||
|
- Gateway coordination/durable-session/integration tests: 20 passed.
|
||||||
|
- Full `fleet.spec.ts`: 192 passed, including alias/provider parsing and alias display.
|
||||||
|
- JSON Schema 2020 validation: legacy minimal roster and extended alias/provider roster passed; `alias` and `provider` remain absent from `required`.
|
||||||
|
- Grep verification: no branded symbol/type/file/DI names or error prose remain in assigned source trees; one allowed `Tess Owner` test-data display name remains.
|
||||||
@@ -52,6 +52,10 @@ The port deliberately omits generic orchestrator verbs. It is tenant- and
|
|||||||
correlation-scoped; its gateway implementation obtains `actorId`, `tenantId`,
|
correlation-scoped; its gateway implementation obtains `actorId`, `tenantId`,
|
||||||
and the requester agent from trusted authentication/configuration only.
|
and the requester agent from trusted authentication/configuration only.
|
||||||
|
|
||||||
|
## HTTP routes
|
||||||
|
|
||||||
|
`/api/coord/interaction` is the canonical HTTP coordination prefix for handoff, observe, and result. `/api/coord/mos` remains a backward-compatible alias with the same handlers and DTOs; new integrations use the neutral canonical prefix.
|
||||||
|
|
||||||
## Enforcement point
|
## Enforcement point
|
||||||
|
|
||||||
`apps/gateway` owns a `MosCoordinationService` boundary that compares the
|
`apps/gateway` owns a `MosCoordinationService` boundary that compares the
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
DurableSessionCoordinator,
|
DurableSessionCoordinator,
|
||||||
InMemoryDurableSessionStore,
|
InMemoryDurableSessionStore,
|
||||||
type DurableSessionIdentity,
|
type DurableSessionIdentity,
|
||||||
} from './tess-durable-session.js';
|
} from './durable-session.js';
|
||||||
|
|
||||||
const IDENTITY: DurableSessionIdentity = {
|
const IDENTITY: DurableSessionIdentity = {
|
||||||
agentName: 'Nova',
|
agentName: 'Nova',
|
||||||
@@ -102,7 +102,7 @@ export interface DurableSessionStore {
|
|||||||
|
|
||||||
export class DurableSessionNotFoundError extends Error {
|
export class DurableSessionNotFoundError extends Error {
|
||||||
constructor(sessionId: string) {
|
constructor(sessionId: string) {
|
||||||
super(`Durable Tess session not found: ${sessionId}`);
|
super(`Durable session not found: ${sessionId}`);
|
||||||
this.name = 'DurableSessionNotFoundError';
|
this.name = 'DurableSessionNotFoundError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,11 +212,11 @@ export class DurableSessionCoordinator {
|
|||||||
|
|
||||||
async resumeHandoff(handoffId: string): Promise<DurableHandoffRecovery> {
|
async resumeHandoff(handoffId: string): Promise<DurableHandoffRecovery> {
|
||||||
const handoff = await this.store.findHandoff(handoffId);
|
const handoff = await this.store.findHandoff(handoffId);
|
||||||
if (!handoff) throw new Error(`Durable Tess handoff not found: ${handoffId}`);
|
if (!handoff) throw new Error(`Durable handoff not found: ${handoffId}`);
|
||||||
const snapshot = await this.snapshot(handoff.sessionId);
|
const snapshot = await this.snapshot(handoff.sessionId);
|
||||||
const checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId);
|
const checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId);
|
||||||
if (!checkpoint) {
|
if (!checkpoint) {
|
||||||
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${handoff.checkpointId}`);
|
throw new Error(`Durable handoff checkpoint is unavailable: ${handoff.checkpointId}`);
|
||||||
}
|
}
|
||||||
return { identity: snapshot.identity, checkpoint, handoff };
|
return { identity: snapshot.identity, checkpoint, handoff };
|
||||||
}
|
}
|
||||||
@@ -257,7 +257,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
const existing = this.sessions.get(identity.sessionId);
|
const existing = this.sessions.get(identity.sessionId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!sameEnrollmentScope(existing.identity, identity)) {
|
if (!sameEnrollmentScope(existing.identity, identity)) {
|
||||||
throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`);
|
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
|
||||||
}
|
}
|
||||||
existing.identity.providerId = identity.providerId;
|
existing.identity.providerId = identity.providerId;
|
||||||
existing.identity.runtimeSessionId = identity.runtimeSessionId;
|
existing.identity.runtimeSessionId = identity.runtimeSessionId;
|
||||||
@@ -290,7 +290,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
const existing = state.inbox.get(input.idempotencyKey);
|
const existing = state.inbox.get(input.idempotencyKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!sameInbox(existing, input)) {
|
if (!sameInbox(existing, input)) {
|
||||||
throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`);
|
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
|
||||||
}
|
}
|
||||||
return { accepted: false, status: existing.status };
|
return { accepted: false, status: existing.status };
|
||||||
}
|
}
|
||||||
@@ -323,7 +323,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
const existing = state.outbox.get(input.idempotencyKey);
|
const existing = state.outbox.get(input.idempotencyKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!sameOutbox(existing, input)) {
|
if (!sameOutbox(existing, input)) {
|
||||||
throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`);
|
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
|
||||||
}
|
}
|
||||||
return { accepted: false, status: existing.status };
|
return { accepted: false, status: existing.status };
|
||||||
}
|
}
|
||||||
@@ -364,7 +364,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
const checkpoints = this.require(input.sessionId).checkpoints;
|
const checkpoints = this.require(input.sessionId).checkpoints;
|
||||||
const existing = checkpoints.get(input.checkpointId);
|
const existing = checkpoints.get(input.checkpointId);
|
||||||
if (existing && !sameCheckpoint(existing, input)) {
|
if (existing && !sameCheckpoint(existing, input)) {
|
||||||
throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`);
|
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
|
||||||
}
|
}
|
||||||
if (!existing) checkpoints.set(input.checkpointId, { ...input });
|
if (!existing) checkpoints.set(input.checkpointId, { ...input });
|
||||||
}
|
}
|
||||||
@@ -377,11 +377,11 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
async handoff(input: DurableHandoffInput): Promise<void> {
|
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||||
const state = this.require(input.sessionId);
|
const state = this.require(input.sessionId);
|
||||||
if (!state.checkpoints.has(input.checkpointId)) {
|
if (!state.checkpoints.has(input.checkpointId)) {
|
||||||
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
|
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||||
}
|
}
|
||||||
const existing = state.handoffs.get(input.handoffId);
|
const existing = state.handoffs.get(input.handoffId);
|
||||||
if (existing && !sameHandoff(existing, input)) {
|
if (existing && !sameHandoff(existing, input)) {
|
||||||
throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`);
|
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
|
||||||
}
|
}
|
||||||
if (!existing) state.handoffs.set(input.handoffId, { ...input });
|
if (!existing) state.handoffs.set(input.handoffId, { ...input });
|
||||||
}
|
}
|
||||||
@@ -413,7 +413,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
kind: string,
|
kind: string,
|
||||||
): T {
|
): T {
|
||||||
const entry = records.get(idempotencyKey);
|
const entry = records.get(idempotencyKey);
|
||||||
if (!entry) throw new Error(`Durable Tess ${kind} entry not found: ${idempotencyKey}`);
|
if (!entry) throw new Error(`Durable ${kind} entry not found: ${idempotencyKey}`);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,4 +4,4 @@ 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 './hermes-runtime-provider.js';
|
||||||
export * from './matrix-native-runtime-provider.js';
|
export * from './matrix-native-runtime-provider.js';
|
||||||
export * from './tess-durable-session.js';
|
export * from './durable-session.js';
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ class DenyMatrixWriteAuthority implements MatrixWriteAuthority {
|
|||||||
async assertAuthorized(): Promise<void> {
|
async assertAuthorized(): Promise<void> {
|
||||||
throw new MatrixRuntimeProviderError(
|
throw new MatrixRuntimeProviderError(
|
||||||
'forbidden',
|
'forbidden',
|
||||||
'Matrix runtime writes require Mos authority',
|
'Matrix runtime writes require orchestrator authority',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
|||||||
expect(fleet.terminate).not.toHaveBeenCalled();
|
expect(fleet.terminate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unverified target before consulting Mos write authority', async (): Promise<void> => {
|
it('rejects an unverified target before consulting orchestrator write authority', async (): Promise<void> => {
|
||||||
const fleet = transport();
|
const fleet = transport();
|
||||||
fleet.verifySession = vi.fn(async (): Promise<FleetRuntimeTarget> => {
|
fleet.verifySession = vi.fn(async (): Promise<FleetRuntimeTarget> => {
|
||||||
throw new Error('target identity mismatch');
|
throw new Error('target identity mismatch');
|
||||||
@@ -220,7 +220,20 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
|||||||
expect(fleet.sendMessage).not.toHaveBeenCalled();
|
expect(fleet.sendMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes an exact session ID to the fleet transport only through authorized Mos writes', async (): Promise<void> => {
|
it('uses the role-neutral interaction source label by default', async (): Promise<void> => {
|
||||||
|
const fleet = transport();
|
||||||
|
const writeAuthority: FleetWriteAuthority = {
|
||||||
|
canWrite: vi.fn(async (): Promise<boolean> => true),
|
||||||
|
assertAuthorized: vi.fn(async (): Promise<void> => undefined),
|
||||||
|
};
|
||||||
|
const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority });
|
||||||
|
|
||||||
|
await provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope);
|
||||||
|
|
||||||
|
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'interaction');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes an exact session ID to the fleet transport only through orchestrator-authorized writes', async (): Promise<void> => {
|
||||||
const fleet = transport();
|
const fleet = transport();
|
||||||
const writeAuthority: FleetWriteAuthority = {
|
const writeAuthority: FleetWriteAuthority = {
|
||||||
canWrite: vi.fn(async (): Promise<boolean> => true),
|
canWrite: vi.fn(async (): Promise<boolean> => true),
|
||||||
@@ -228,7 +241,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
|||||||
};
|
};
|
||||||
const provider = new TmuxFleetRuntimeProvider({
|
const provider = new TmuxFleetRuntimeProvider({
|
||||||
transport: fleet,
|
transport: fleet,
|
||||||
sourceLabel: 'tess',
|
sourceLabel: 'operator',
|
||||||
writeAuthority,
|
writeAuthority,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,7 +259,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
|||||||
scope,
|
scope,
|
||||||
approvalRef: 'approval-1',
|
approvalRef: 'approval-1',
|
||||||
});
|
});
|
||||||
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'tess');
|
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'operator');
|
||||||
expect(fleet.terminate).toHaveBeenCalledWith('coder0');
|
expect(fleet.terminate).toHaveBeenCalledWith('coder0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -64,14 +64,14 @@ export interface FleetWriteAuthorization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mos is the only authority that may permit Tess write/control requests to a
|
* The orchestrator is the only authority that may permit interaction-plane write/control requests to a
|
||||||
* fleet peer. Gateway records the request and denial/success around provider
|
* fleet peer. Gateway records the request and denial/success around provider
|
||||||
* invocation; the default authority prevents direct Tess writes by design.
|
* invocation; the default authority prevents direct interaction-plane writes by design.
|
||||||
*/
|
*/
|
||||||
export interface FleetWriteAuthority {
|
export interface FleetWriteAuthority {
|
||||||
/** Non-consuming preflight used before probing the fleet transport. */
|
/** Non-consuming preflight used before probing the fleet transport. */
|
||||||
canWrite(authorization: FleetWriteAuthorization): Promise<boolean>;
|
canWrite(authorization: FleetWriteAuthorization): Promise<boolean>;
|
||||||
/** Final exact-target authorization; may consume a Mos grant. */
|
/** Final exact-target authorization; may consume an orchestrator grant. */
|
||||||
assertAuthorized(authorization: FleetWriteAuthorization): Promise<void>;
|
assertAuthorized(authorization: FleetWriteAuthorization): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ class DenyFleetWriteAuthority implements FleetWriteAuthority {
|
|||||||
async assertAuthorized(_authorization: FleetWriteAuthorization): Promise<void> {
|
async assertAuthorized(_authorization: FleetWriteAuthorization): Promise<void> {
|
||||||
throw new FleetRuntimeProviderError(
|
throw new FleetRuntimeProviderError(
|
||||||
'forbidden',
|
'forbidden',
|
||||||
'Fleet writes require an explicit Mos authority decision',
|
'Fleet writes require an explicit orchestrator authority decision',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ class DenyFleetWriteAuthority implements FleetWriteAuthority {
|
|||||||
/**
|
/**
|
||||||
* A capability-limited provider for rostered local fleet peers. It never
|
* A capability-limited provider for rostered local fleet peers. It never
|
||||||
* permits raw tmux socket/target selection, interactive control attach, or
|
* permits raw tmux socket/target selection, interactive control attach, or
|
||||||
* direct Tess writes; all side effects pass through exact transport checks.
|
* direct interaction-plane writes; all side effects pass through exact transport checks.
|
||||||
*/
|
*/
|
||||||
export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider {
|
export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider {
|
||||||
readonly id = FLEET_PROVIDER_ID;
|
readonly id = FLEET_PROVIDER_ID;
|
||||||
@@ -139,7 +139,7 @@ export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider {
|
|||||||
constructor(private readonly options: TmuxFleetRuntimeProviderOptions) {
|
constructor(private readonly options: TmuxFleetRuntimeProviderOptions) {
|
||||||
this.readAuthority = options.readAuthority ?? new DenyFleetReadAuthority();
|
this.readAuthority = options.readAuthority ?? new DenyFleetReadAuthority();
|
||||||
this.writeAuthority = options.writeAuthority ?? new DenyFleetWriteAuthority();
|
this.writeAuthority = options.writeAuthority ?? new DenyFleetWriteAuthority();
|
||||||
this.sourceLabel = options.sourceLabel ?? 'tess';
|
this.sourceLabel = options.sourceLabel ?? 'interaction';
|
||||||
this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID;
|
this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID;
|
||||||
this.now = options.now ?? (() => new Date());
|
this.now = options.now ?? (() => new Date());
|
||||||
this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS;
|
this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
InMemoryMosCoordinationPort,
|
InMemoryInteractionCoordinationPort,
|
||||||
MosCoordinationClient,
|
InteractionCoordinationClient,
|
||||||
type CoordinationScope,
|
type CoordinationScope,
|
||||||
type MosCoordinationAuthorityError,
|
type InteractionCoordinationAuthorityError,
|
||||||
type MosCoordinationPort,
|
type InteractionCoordinationPort,
|
||||||
} from '../index.js';
|
} from '../index.js';
|
||||||
|
|
||||||
const scope: CoordinationScope = {
|
const scope: CoordinationScope = {
|
||||||
@@ -15,19 +15,19 @@ const scope: CoordinationScope = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function client(
|
function client(
|
||||||
port: MosCoordinationPort,
|
port: InteractionCoordinationPort,
|
||||||
handoffIdFactory: () => string = (): string => 'handoff-1',
|
handoffIdFactory: () => string = (): string => 'handoff-1',
|
||||||
): MosCoordinationClient {
|
): InteractionCoordinationClient {
|
||||||
return new MosCoordinationClient(
|
return new InteractionCoordinationClient(
|
||||||
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' },
|
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' },
|
||||||
port,
|
port,
|
||||||
handoffIdFactory,
|
handoffIdFactory,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('MosCoordinationClient', (): void => {
|
describe('InteractionCoordinationClient', (): void => {
|
||||||
it('round-trips handoff, observation, and result through the native port with identities as data', async (): Promise<void> => {
|
it('round-trips handoff, observation, and result through the native port with identities as data', async (): Promise<void> => {
|
||||||
const adapter = new InMemoryMosCoordinationPort();
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
const coordination = client(adapter);
|
const coordination = client(adapter);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -42,8 +42,8 @@ describe('MosCoordinationClient', (): void => {
|
|||||||
correlationId: 'corr-1',
|
correlationId: 'corr-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request');
|
adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request');
|
||||||
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
|
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
|
||||||
|
|
||||||
await expect(coordination.observe('handoff-1', scope)).resolves.toMatchObject({
|
await expect(coordination.observe('handoff-1', scope)).resolves.toMatchObject({
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
@@ -59,7 +59,7 @@ describe('MosCoordinationClient', (): void => {
|
|||||||
targetAgentId: 'Conductor',
|
targetAgentId: 'Conductor',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
correlationId: 'corr-1',
|
correlationId: 'corr-1',
|
||||||
summary: 'Merged by Mos',
|
summary: 'Merged by orchestrator',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(coordination).not.toHaveProperty('dispatch');
|
expect(coordination).not.toHaveProperty('dispatch');
|
||||||
@@ -69,8 +69,8 @@ describe('MosCoordinationClient', (): void => {
|
|||||||
expect(coordination).not.toHaveProperty('cancel');
|
expect(coordination).not.toHaveProperty('cancel');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails closed before delivery when an unconfigured agent requests Mos work', async (): Promise<void> => {
|
it('fails closed before delivery when an unconfigured agent requests orchestrator work', async (): Promise<void> => {
|
||||||
const adapter = new InMemoryMosCoordinationPort();
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client(adapter).handoff(
|
client(adapter).handoff(
|
||||||
@@ -79,31 +79,31 @@ describe('MosCoordinationClient', (): void => {
|
|||||||
),
|
),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'requester_forbidden',
|
code: 'requester_forbidden',
|
||||||
} satisfies Partial<MosCoordinationAuthorityError>);
|
} satisfies Partial<InteractionCoordinationAuthorityError>);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects self-delegation configuration before constructing a client', (): void => {
|
it('rejects self-delegation configuration before constructing a client', (): void => {
|
||||||
expect(
|
expect(
|
||||||
(): MosCoordinationClient =>
|
(): InteractionCoordinationClient =>
|
||||||
new MosCoordinationClient(
|
new InteractionCoordinationClient(
|
||||||
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
||||||
new InMemoryMosCoordinationPort(),
|
new InMemoryInteractionCoordinationPort(),
|
||||||
),
|
),
|
||||||
).toThrow('Interaction and orchestration identities must differ');
|
).toThrow('Interaction and orchestration identities must differ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects whitespace-equivalent self-delegation identities', (): void => {
|
it('rejects whitespace-equivalent self-delegation identities', (): void => {
|
||||||
expect(
|
expect(
|
||||||
(): MosCoordinationClient =>
|
(): InteractionCoordinationClient =>
|
||||||
new MosCoordinationClient(
|
new InteractionCoordinationClient(
|
||||||
{ interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' },
|
{ interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' },
|
||||||
new InMemoryMosCoordinationPort(),
|
new InMemoryInteractionCoordinationPort(),
|
||||||
),
|
),
|
||||||
).toThrow('Interaction and orchestration identities must differ');
|
).toThrow('Interaction and orchestration identities must differ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not expose another tenant handoff to observe or result', async (): Promise<void> => {
|
it('does not expose another tenant handoff to observe or result', async (): Promise<void> => {
|
||||||
const adapter = new InMemoryMosCoordinationPort();
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
const coordination = client(adapter);
|
const coordination = client(adapter);
|
||||||
await coordination.handoff(
|
await coordination.handoff(
|
||||||
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
|
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
|
||||||
@@ -120,7 +120,7 @@ describe('MosCoordinationClient', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('bounds native handoff retention by evicting the oldest handoff', async (): Promise<void> => {
|
it('bounds native handoff retention by evicting the oldest handoff', async (): Promise<void> => {
|
||||||
const adapter = new InMemoryMosCoordinationPort({ maxHandoffs: 1 });
|
const adapter = new InMemoryInteractionCoordinationPort({ maxHandoffs: 1 });
|
||||||
const first = client(adapter, (): string => 'handoff-1');
|
const first = client(adapter, (): string => 'handoff-1');
|
||||||
const second = client(adapter, (): string => 'handoff-2');
|
const second = client(adapter, (): string => 'handoff-2');
|
||||||
await first.handoff({ idempotencyKey: 'handoff-request-1', summary: 'First request' }, scope);
|
await first.handoff({ idempotencyKey: 'handoff-request-1', summary: 'First request' }, scope);
|
||||||
@@ -131,7 +131,7 @@ describe('MosCoordinationClient', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('fails closed when a transport reports target drift', async (): Promise<void> => {
|
it('fails closed when a transport reports target drift', async (): Promise<void> => {
|
||||||
const adapter: MosCoordinationPort = {
|
const adapter: InteractionCoordinationPort = {
|
||||||
handoff: vi.fn(async () => ({
|
handoff: vi.fn(async () => ({
|
||||||
handoffId: 'handoff-1',
|
handoffId: 'handoff-1',
|
||||||
targetAgentId: 'Unexpected',
|
targetAgentId: 'Unexpected',
|
||||||
@@ -149,6 +149,6 @@ describe('MosCoordinationClient', (): void => {
|
|||||||
),
|
),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'target_drift',
|
code: 'target_drift',
|
||||||
} satisfies Partial<MosCoordinationAuthorityError>);
|
} satisfies Partial<InteractionCoordinationAuthorityError>);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,25 +2,25 @@ import {
|
|||||||
type CoordinationObservation,
|
type CoordinationObservation,
|
||||||
type CoordinationResult,
|
type CoordinationResult,
|
||||||
type CoordinationScope,
|
type CoordinationScope,
|
||||||
type MosCoordinationActivity,
|
type InteractionCoordinationActivity,
|
||||||
type MosCoordinationPort,
|
type InteractionCoordinationPort,
|
||||||
type MosHandoff,
|
type Handoff,
|
||||||
type MosHandoffReceipt,
|
type HandoffReceipt,
|
||||||
type MosHandoffStatus,
|
type HandoffStatus,
|
||||||
} from './mos-coordination.js';
|
} from './interaction-coordination.js';
|
||||||
|
|
||||||
const DEFAULT_HANDOFF_TTL_MS = 60 * 60 * 1_000;
|
const DEFAULT_HANDOFF_TTL_MS = 60 * 60 * 1_000;
|
||||||
const DEFAULT_MAX_HANDOFFS = 1_000;
|
const DEFAULT_MAX_HANDOFFS = 1_000;
|
||||||
|
|
||||||
interface StoredHandoff {
|
interface StoredHandoff {
|
||||||
readonly handoff: MosHandoff;
|
readonly handoff: Handoff;
|
||||||
status: MosHandoffStatus;
|
status: HandoffStatus;
|
||||||
readonly activity: MosCoordinationActivity[];
|
readonly activity: InteractionCoordinationActivity[];
|
||||||
readonly expiresAt: number;
|
readonly expiresAt: number;
|
||||||
result?: CoordinationResult;
|
result?: CoordinationResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InMemoryMosCoordinationPortOptions {
|
export interface InMemoryInteractionCoordinationPortOptions {
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
handoffTtlMs?: number;
|
handoffTtlMs?: number;
|
||||||
maxHandoffs?: number;
|
maxHandoffs?: number;
|
||||||
@@ -29,21 +29,21 @@ export interface InMemoryMosCoordinationPortOptions {
|
|||||||
/**
|
/**
|
||||||
* Native deterministic queue/port adapter for the coordination boundary.
|
* Native deterministic queue/port adapter for the coordination boundary.
|
||||||
* It intentionally has no fleet/tmux dependency. A future deployment adapter
|
* It intentionally has no fleet/tmux dependency. A future deployment adapter
|
||||||
* implements MosCoordinationPort without changing interaction-plane callers.
|
* implements InteractionCoordinationPort without changing interaction-plane callers.
|
||||||
*/
|
*/
|
||||||
export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
export class InMemoryInteractionCoordinationPort implements InteractionCoordinationPort {
|
||||||
private readonly handoffs = new Map<string, StoredHandoff>();
|
private readonly handoffs = new Map<string, StoredHandoff>();
|
||||||
private readonly now: () => Date;
|
private readonly now: () => Date;
|
||||||
private readonly handoffTtlMs: number;
|
private readonly handoffTtlMs: number;
|
||||||
private readonly maxHandoffs: number;
|
private readonly maxHandoffs: number;
|
||||||
|
|
||||||
constructor(options: InMemoryMosCoordinationPortOptions = {}) {
|
constructor(options: InMemoryInteractionCoordinationPortOptions = {}) {
|
||||||
this.now = options.now ?? (() => new Date());
|
this.now = options.now ?? (() => new Date());
|
||||||
this.handoffTtlMs = options.handoffTtlMs ?? DEFAULT_HANDOFF_TTL_MS;
|
this.handoffTtlMs = options.handoffTtlMs ?? DEFAULT_HANDOFF_TTL_MS;
|
||||||
this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS;
|
this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handoff(handoff: MosHandoff): Promise<MosHandoffReceipt> {
|
async handoff(handoff: Handoff): Promise<HandoffReceipt> {
|
||||||
this.pruneExpiredHandoffs();
|
this.pruneExpiredHandoffs();
|
||||||
const existing = this.handoffs.get(handoff.handoffId);
|
const existing = this.handoffs.get(handoff.handoffId);
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
@@ -88,14 +88,14 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Host-side progression seam; interaction clients never receive this capability. */
|
/** Host-side progression seam; interaction clients never receive this capability. */
|
||||||
recordActivity(handoffId: string, status: MosHandoffStatus, summary: string): void {
|
recordActivity(handoffId: string, status: HandoffStatus, summary: string): void {
|
||||||
this.pruneExpiredHandoffs();
|
this.pruneExpiredHandoffs();
|
||||||
const stored = this.requireHandoff(handoffId);
|
const stored = this.requireHandoff(handoffId);
|
||||||
stored.status = status;
|
stored.status = status;
|
||||||
stored.activity.push(activity(status, summary, this.now));
|
stored.activity.push(activity(status, summary, this.now));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Host-side result seam for deterministic qualification; not a Mos consumer. */
|
/** Host-side result seam for deterministic qualification; not an orchestrator consumer. */
|
||||||
recordResult(handoffId: string, status: 'completed' | 'failed', summary: string): void {
|
recordResult(handoffId: string, status: 'completed' | 'failed', summary: string): void {
|
||||||
this.pruneExpiredHandoffs();
|
this.pruneExpiredHandoffs();
|
||||||
const stored = this.requireHandoff(handoffId);
|
const stored = this.requireHandoff(handoffId);
|
||||||
@@ -110,7 +110,7 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private receipt(handoff: MosHandoff, status: MosHandoffStatus): MosHandoffReceipt {
|
private receipt(handoff: Handoff, status: HandoffStatus): HandoffReceipt {
|
||||||
return {
|
return {
|
||||||
handoffId: handoff.handoffId,
|
handoffId: handoff.handoffId,
|
||||||
targetAgentId: handoff.targetAgentId,
|
targetAgentId: handoff.targetAgentId,
|
||||||
@@ -141,7 +141,7 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
|||||||
stored.handoff.scope.actorId !== scope.actorId ||
|
stored.handoff.scope.actorId !== scope.actorId ||
|
||||||
stored.handoff.scope.requesterAgentId !== scope.requesterAgentId
|
stored.handoff.scope.requesterAgentId !== scope.requesterAgentId
|
||||||
) {
|
) {
|
||||||
throw new InMemoryMosCoordinationError('forbidden', 'Handoff scope does not match');
|
throw new InMemoryInteractionCoordinationError('forbidden', 'Handoff scope does not match');
|
||||||
}
|
}
|
||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
@@ -149,12 +149,12 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
|||||||
private requireHandoff(handoffId: string): StoredHandoff {
|
private requireHandoff(handoffId: string): StoredHandoff {
|
||||||
const stored = this.handoffs.get(handoffId);
|
const stored = this.handoffs.get(handoffId);
|
||||||
if (stored === undefined) {
|
if (stored === undefined) {
|
||||||
throw new InMemoryMosCoordinationError('not_found', 'Handoff was not found');
|
throw new InMemoryInteractionCoordinationError('not_found', 'Handoff was not found');
|
||||||
}
|
}
|
||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertSameHandoff(existing: MosHandoff, incoming: MosHandoff): void {
|
private assertSameHandoff(existing: Handoff, incoming: Handoff): void {
|
||||||
if (
|
if (
|
||||||
existing.targetAgentId !== incoming.targetAgentId ||
|
existing.targetAgentId !== incoming.targetAgentId ||
|
||||||
existing.request.idempotencyKey !== incoming.request.idempotencyKey ||
|
existing.request.idempotencyKey !== incoming.request.idempotencyKey ||
|
||||||
@@ -166,7 +166,7 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
|||||||
existing.scope.correlationId !== incoming.scope.correlationId ||
|
existing.scope.correlationId !== incoming.scope.correlationId ||
|
||||||
existing.scope.requesterAgentId !== incoming.scope.requesterAgentId
|
existing.scope.requesterAgentId !== incoming.scope.requesterAgentId
|
||||||
) {
|
) {
|
||||||
throw new InMemoryMosCoordinationError(
|
throw new InMemoryInteractionCoordinationError(
|
||||||
'conflict',
|
'conflict',
|
||||||
'Handoff ID is already bound to different immutable input',
|
'Handoff ID is already bound to different immutable input',
|
||||||
);
|
);
|
||||||
@@ -174,31 +174,31 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InMemoryMosCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found';
|
export type InMemoryInteractionCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found';
|
||||||
|
|
||||||
export class InMemoryMosCoordinationError extends Error {
|
export class InMemoryInteractionCoordinationError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
readonly code: InMemoryMosCoordinationErrorCode,
|
readonly code: InMemoryInteractionCoordinationErrorCode,
|
||||||
message: string,
|
message: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = InMemoryMosCoordinationError.name;
|
this.name = InMemoryInteractionCoordinationError.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function activity(
|
function activity(
|
||||||
status: MosHandoffStatus,
|
status: HandoffStatus,
|
||||||
summary: string,
|
summary: string,
|
||||||
now: () => Date,
|
now: () => Date,
|
||||||
): MosCoordinationActivity {
|
): InteractionCoordinationActivity {
|
||||||
return { occurredAt: now().toISOString(), status, summary };
|
return { occurredAt: now().toISOString(), status, summary };
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyActivity(entry: MosCoordinationActivity): MosCoordinationActivity {
|
function copyActivity(entry: InteractionCoordinationActivity): InteractionCoordinationActivity {
|
||||||
return { ...entry };
|
return { ...entry };
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshotHandoff(handoff: MosHandoff): MosHandoff {
|
function snapshotHandoff(handoff: Handoff): Handoff {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
handoffId: handoff.handoffId,
|
handoffId: handoff.handoffId,
|
||||||
targetAgentId: handoff.targetAgentId,
|
targetAgentId: handoff.targetAgentId,
|
||||||
@@ -3,22 +3,25 @@ export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.j
|
|||||||
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 {
|
export {
|
||||||
InMemoryMosCoordinationError,
|
InMemoryInteractionCoordinationError,
|
||||||
InMemoryMosCoordinationPort,
|
InMemoryInteractionCoordinationPort,
|
||||||
} from './in-memory-mos-coordination-port.js';
|
} from './in-memory-interaction-coordination-port.js';
|
||||||
export { MosCoordinationAuthorityError, MosCoordinationClient } from './mos-coordination.js';
|
export {
|
||||||
|
InteractionCoordinationAuthorityError,
|
||||||
|
InteractionCoordinationClient,
|
||||||
|
} from './interaction-coordination.js';
|
||||||
export type {
|
export type {
|
||||||
CoordinationObservation,
|
CoordinationObservation,
|
||||||
CoordinationResult,
|
CoordinationResult,
|
||||||
CoordinationScope,
|
CoordinationScope,
|
||||||
MosCoordinationActivity,
|
InteractionCoordinationActivity,
|
||||||
MosCoordinationIdentity,
|
InteractionCoordinationIdentity,
|
||||||
MosCoordinationPort,
|
InteractionCoordinationPort,
|
||||||
MosHandoff,
|
Handoff,
|
||||||
MosHandoffReceipt,
|
HandoffReceipt,
|
||||||
MosHandoffRequest,
|
HandoffRequest,
|
||||||
MosHandoffStatus,
|
HandoffStatus,
|
||||||
} from './mos-coordination.js';
|
} from './interaction-coordination.js';
|
||||||
export type {
|
export type {
|
||||||
CreateMissionOptions,
|
CreateMissionOptions,
|
||||||
Mission,
|
Mission,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type MosHandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed';
|
export type HandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed';
|
||||||
|
|
||||||
export interface CoordinationScope {
|
export interface CoordinationScope {
|
||||||
readonly actorId: string;
|
readonly actorId: string;
|
||||||
@@ -8,44 +8,44 @@ export interface CoordinationScope {
|
|||||||
readonly requesterAgentId: string;
|
readonly requesterAgentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosCoordinationIdentity {
|
export interface InteractionCoordinationIdentity {
|
||||||
readonly interactionAgentId: string;
|
readonly interactionAgentId: string;
|
||||||
readonly orchestrationAgentId: string;
|
readonly orchestrationAgentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosHandoffRequest {
|
export interface HandoffRequest {
|
||||||
readonly idempotencyKey: string;
|
readonly idempotencyKey: string;
|
||||||
readonly summary: string;
|
readonly summary: string;
|
||||||
readonly context?: string;
|
readonly context?: string;
|
||||||
readonly missionId?: string;
|
readonly missionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosHandoff {
|
export interface Handoff {
|
||||||
readonly handoffId: string;
|
readonly handoffId: string;
|
||||||
readonly targetAgentId: string;
|
readonly targetAgentId: string;
|
||||||
readonly request: MosHandoffRequest;
|
readonly request: HandoffRequest;
|
||||||
readonly scope: CoordinationScope;
|
readonly scope: CoordinationScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosHandoffReceipt {
|
export interface HandoffReceipt {
|
||||||
readonly handoffId: string;
|
readonly handoffId: string;
|
||||||
readonly targetAgentId: string;
|
readonly targetAgentId: string;
|
||||||
readonly status: 'queued' | 'accepted';
|
readonly status: 'queued' | 'accepted';
|
||||||
readonly correlationId: string;
|
readonly correlationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosCoordinationActivity {
|
export interface InteractionCoordinationActivity {
|
||||||
readonly occurredAt: string;
|
readonly occurredAt: string;
|
||||||
readonly status: MosHandoffStatus;
|
readonly status: HandoffStatus;
|
||||||
readonly summary: string;
|
readonly summary: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoordinationObservation {
|
export interface CoordinationObservation {
|
||||||
readonly handoffId: string;
|
readonly handoffId: string;
|
||||||
readonly targetAgentId: string;
|
readonly targetAgentId: string;
|
||||||
readonly status: MosHandoffStatus;
|
readonly status: HandoffStatus;
|
||||||
readonly correlationId: string;
|
readonly correlationId: string;
|
||||||
readonly activity: readonly MosCoordinationActivity[];
|
readonly activity: readonly InteractionCoordinationActivity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoordinationResult {
|
export interface CoordinationResult {
|
||||||
@@ -61,25 +61,25 @@ export interface CoordinationResult {
|
|||||||
* its progress/result, but it cannot issue worker, review, merge, or other
|
* its progress/result, but it cannot issue worker, review, merge, or other
|
||||||
* general orchestration commands.
|
* general orchestration commands.
|
||||||
*/
|
*/
|
||||||
export interface MosCoordinationPort {
|
export interface InteractionCoordinationPort {
|
||||||
handoff(handoff: MosHandoff): Promise<MosHandoffReceipt>;
|
handoff(handoff: Handoff): Promise<HandoffReceipt>;
|
||||||
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
||||||
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MosCoordinationAuthorityErrorCode =
|
export type InteractionCoordinationAuthorityErrorCode =
|
||||||
| 'invalid_identity'
|
| 'invalid_identity'
|
||||||
| 'requester_forbidden'
|
| 'requester_forbidden'
|
||||||
| 'target_drift'
|
| 'target_drift'
|
||||||
| 'correlation_drift';
|
| 'correlation_drift';
|
||||||
|
|
||||||
export class MosCoordinationAuthorityError extends Error {
|
export class InteractionCoordinationAuthorityError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
readonly code: MosCoordinationAuthorityErrorCode,
|
readonly code: InteractionCoordinationAuthorityErrorCode,
|
||||||
message: string,
|
message: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = MosCoordinationAuthorityError.name;
|
this.name = InteractionCoordinationAuthorityError.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,20 +87,20 @@ export class MosCoordinationAuthorityError extends Error {
|
|||||||
* Enforces the interaction-to-orchestration authority boundary before a
|
* Enforces the interaction-to-orchestration authority boundary before a
|
||||||
* transport is reached. Identity names remain configuration data.
|
* transport is reached. Identity names remain configuration data.
|
||||||
*/
|
*/
|
||||||
export class MosCoordinationClient {
|
export class InteractionCoordinationClient {
|
||||||
private readonly identity: MosCoordinationIdentity;
|
private readonly identity: InteractionCoordinationIdentity;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
identity: MosCoordinationIdentity,
|
identity: InteractionCoordinationIdentity,
|
||||||
private readonly port: MosCoordinationPort,
|
private readonly port: InteractionCoordinationPort,
|
||||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||||
) {
|
) {
|
||||||
this.identity = normalizeIdentity(identity);
|
this.identity = normalizeIdentity(identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handoff(request: MosHandoffRequest, scope: CoordinationScope): Promise<MosHandoffReceipt> {
|
async handoff(request: HandoffRequest, scope: CoordinationScope): Promise<HandoffReceipt> {
|
||||||
this.assertRequester(scope);
|
this.assertRequester(scope);
|
||||||
const handoff: MosHandoff = {
|
const handoff: Handoff = {
|
||||||
handoffId: this.handoffIdFactory(),
|
handoffId: this.handoffIdFactory(),
|
||||||
targetAgentId: this.identity.orchestrationAgentId,
|
targetAgentId: this.identity.orchestrationAgentId,
|
||||||
request: snapshotRequest(request),
|
request: snapshotRequest(request),
|
||||||
@@ -111,15 +111,15 @@ export class MosCoordinationClient {
|
|||||||
receipt.handoffId !== handoff.handoffId ||
|
receipt.handoffId !== handoff.handoffId ||
|
||||||
receipt.targetAgentId !== handoff.targetAgentId
|
receipt.targetAgentId !== handoff.targetAgentId
|
||||||
) {
|
) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'target_drift',
|
'target_drift',
|
||||||
'Mos coordination transport returned a mismatched handoff target',
|
'Interaction coordination transport returned a mismatched handoff target',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (receipt.correlationId !== handoff.scope.correlationId) {
|
if (receipt.correlationId !== handoff.scope.correlationId) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'correlation_drift',
|
'correlation_drift',
|
||||||
'Mos coordination transport returned a mismatched correlation ID',
|
'Interaction coordination transport returned a mismatched correlation ID',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return receipt;
|
return receipt;
|
||||||
@@ -137,7 +137,7 @@ export class MosCoordinationClient {
|
|||||||
|
|
||||||
private assertRequester(scope: CoordinationScope): void {
|
private assertRequester(scope: CoordinationScope): void {
|
||||||
if (scope.requesterAgentId !== this.identity.interactionAgentId) {
|
if (scope.requesterAgentId !== this.identity.interactionAgentId) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'requester_forbidden',
|
'requester_forbidden',
|
||||||
'Requester is not the configured interaction agent',
|
'Requester is not the configured interaction agent',
|
||||||
);
|
);
|
||||||
@@ -149,15 +149,15 @@ export class MosCoordinationClient {
|
|||||||
scope: CoordinationScope,
|
scope: CoordinationScope,
|
||||||
): CoordinationObservation {
|
): CoordinationObservation {
|
||||||
if (observation.targetAgentId !== this.identity.orchestrationAgentId) {
|
if (observation.targetAgentId !== this.identity.orchestrationAgentId) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'target_drift',
|
'target_drift',
|
||||||
'Mos coordination transport returned an unexpected observation target',
|
'Interaction coordination transport returned an unexpected observation target',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (observation.correlationId !== scope.correlationId) {
|
if (observation.correlationId !== scope.correlationId) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'correlation_drift',
|
'correlation_drift',
|
||||||
'Mos coordination transport returned a mismatched observation correlation ID',
|
'Interaction coordination transport returned a mismatched observation correlation ID',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return observation;
|
return observation;
|
||||||
@@ -165,32 +165,34 @@ export class MosCoordinationClient {
|
|||||||
|
|
||||||
private assertResult(result: CoordinationResult, scope: CoordinationScope): CoordinationResult {
|
private assertResult(result: CoordinationResult, scope: CoordinationScope): CoordinationResult {
|
||||||
if (result.targetAgentId !== this.identity.orchestrationAgentId) {
|
if (result.targetAgentId !== this.identity.orchestrationAgentId) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'target_drift',
|
'target_drift',
|
||||||
'Mos coordination transport returned an unexpected result target',
|
'Interaction coordination transport returned an unexpected result target',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (result.correlationId !== scope.correlationId) {
|
if (result.correlationId !== scope.correlationId) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'correlation_drift',
|
'correlation_drift',
|
||||||
'Mos coordination transport returned a mismatched result correlation ID',
|
'Interaction coordination transport returned a mismatched result correlation ID',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeIdentity(identity: MosCoordinationIdentity): MosCoordinationIdentity {
|
function normalizeIdentity(
|
||||||
|
identity: InteractionCoordinationIdentity,
|
||||||
|
): InteractionCoordinationIdentity {
|
||||||
const interactionAgentId = identity.interactionAgentId.trim();
|
const interactionAgentId = identity.interactionAgentId.trim();
|
||||||
const orchestrationAgentId = identity.orchestrationAgentId.trim();
|
const orchestrationAgentId = identity.orchestrationAgentId.trim();
|
||||||
if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) {
|
if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'invalid_identity',
|
'invalid_identity',
|
||||||
'Interaction and orchestration identities are required',
|
'Interaction and orchestration identities are required',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (interactionAgentId === orchestrationAgentId) {
|
if (interactionAgentId === orchestrationAgentId) {
|
||||||
throw new MosCoordinationAuthorityError(
|
throw new InteractionCoordinationAuthorityError(
|
||||||
'invalid_identity',
|
'invalid_identity',
|
||||||
'Interaction and orchestration identities must differ',
|
'Interaction and orchestration identities must differ',
|
||||||
);
|
);
|
||||||
@@ -198,7 +200,7 @@ function normalizeIdentity(identity: MosCoordinationIdentity): MosCoordinationId
|
|||||||
return Object.freeze({ interactionAgentId, orchestrationAgentId });
|
return Object.freeze({ interactionAgentId, orchestrationAgentId });
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshotRequest(request: MosHandoffRequest): MosHandoffRequest {
|
function snapshotRequest(request: HandoffRequest): HandoffRequest {
|
||||||
return Object.freeze({ ...request });
|
return Object.freeze({ ...request });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
The **operator-interaction** role is the authorized human interaction plane for
|
The **operator-interaction** role is the authorized human interaction plane for
|
||||||
Mosaic. It presents runtime and fleet state, mediates approved actions, and
|
Mosaic. It presents runtime and fleet state, mediates approved actions, and
|
||||||
hands coding or general orchestration work to Mos.
|
hands coding or general orchestration work to the orchestrator.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- It does not claim Mos-owned coding or general orchestration work.
|
- It does not claim orchestrator-owned coding or general orchestration work.
|
||||||
- It exposes only the configured, observable tool policy.
|
- It exposes only the configured, observable tool policy.
|
||||||
- It does not receive or surface credentials in its effective policy.
|
- It does not receive or surface credentials in its effective policy.
|
||||||
|
|||||||
@@ -75,6 +75,14 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[A-Za-z0-9_.-]+$"
|
"pattern": "^[A-Za-z0-9_.-]+$"
|
||||||
},
|
},
|
||||||
|
"alias": {
|
||||||
|
"description": "Optional operator-defined display name for the agent.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"description": "Optional agent runtime provider identifier such as openai-codex.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -189,6 +189,36 @@ describe('fleet roster parsing', () => {
|
|||||||
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
|
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts optional agent alias and provider metadata without requiring them', async () => {
|
||||||
|
cleanup = await tempDir();
|
||||||
|
const rosterPath = join(cleanup, 'roster.yaml');
|
||||||
|
await writeFile(
|
||||||
|
rosterPath,
|
||||||
|
[
|
||||||
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
|
'agents:',
|
||||||
|
' - name: interaction',
|
||||||
|
' alias: Friday',
|
||||||
|
' provider: openai-codex',
|
||||||
|
' runtime: pi',
|
||||||
|
' - name: worker0',
|
||||||
|
' runtime: codex',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const roster = await loadFleetRoster(rosterPath);
|
||||||
|
|
||||||
|
expect(getRosterAgent(roster, 'interaction')).toMatchObject({
|
||||||
|
name: 'interaction',
|
||||||
|
alias: 'Friday',
|
||||||
|
provider: 'openai-codex',
|
||||||
|
});
|
||||||
|
expect(getRosterAgent(roster, 'worker0')).toMatchObject({ name: 'worker0' });
|
||||||
|
expect(getRosterAgent(roster, 'worker0').alias).toBeUndefined();
|
||||||
|
expect(getRosterAgent(roster, 'worker0').provider).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('socketArgs: named socket → -L <name>; empty → no -L (default socket)', () => {
|
it('socketArgs: named socket → -L <name>; empty → no -L (default socket)', () => {
|
||||||
expect(socketArgs('mosaic-fleet')).toEqual(['-L', 'mosaic-fleet']);
|
expect(socketArgs('mosaic-fleet')).toEqual(['-L', 'mosaic-fleet']);
|
||||||
expect(socketArgs('')).toEqual([]);
|
expect(socketArgs('')).toEqual([]);
|
||||||
@@ -1872,7 +1902,61 @@ describe('fleet ps — tenant and host', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fleet ps — JSON output shape (FR-6)', () => {
|
describe('fleet ps — aliases and JSON output shape (FR-6)', () => {
|
||||||
|
it('shows an agent alias in table output and falls back to name when absent', async () => {
|
||||||
|
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||||
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
rosterPath,
|
||||||
|
[
|
||||||
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
|
'agents:',
|
||||||
|
' - name: interaction',
|
||||||
|
' alias: Friday',
|
||||||
|
' runtime: pi',
|
||||||
|
' - name: worker0',
|
||||||
|
' runtime: codex',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const runner: CommandRunner = async (command, args) => {
|
||||||
|
const fullArgs = [command, ...args].join(' ');
|
||||||
|
if (fullArgs.includes('list-sessions')) {
|
||||||
|
return { stdout: 'interaction\nworker0\n', stderr: '', exitCode: 0 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
stdout: fullArgs.includes('systemctl')
|
||||||
|
? 'ActiveState=active\nSubState=running\nUnitFileState=enabled\n'
|
||||||
|
: '12345 pi 0 0 0 0\n',
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const origLog = console.log;
|
||||||
|
console.log = (msg: string) => {
|
||||||
|
lines.push(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']);
|
||||||
|
} finally {
|
||||||
|
console.log = origLog;
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = lines.join('\n');
|
||||||
|
expect(output).toContain('Friday');
|
||||||
|
expect(output).toContain('worker0');
|
||||||
|
});
|
||||||
|
|
||||||
it('produces --json records including tenant_id and host for each agent', async () => {
|
it('produces --json records including tenant_id and host for each agent', async () => {
|
||||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ interface RawFleetRoster {
|
|||||||
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
||||||
agents?: Array<{
|
agents?: Array<{
|
||||||
name?: unknown;
|
name?: unknown;
|
||||||
|
alias?: unknown;
|
||||||
|
provider?: unknown;
|
||||||
runtime?: unknown;
|
runtime?: unknown;
|
||||||
class?: unknown;
|
class?: unknown;
|
||||||
working_directory?: unknown;
|
working_directory?: unknown;
|
||||||
@@ -102,6 +104,8 @@ interface RawFleetRoster {
|
|||||||
|
|
||||||
export interface FleetAgent {
|
export interface FleetAgent {
|
||||||
name: string;
|
name: string;
|
||||||
|
alias?: string;
|
||||||
|
provider?: string;
|
||||||
runtime: string;
|
runtime: string;
|
||||||
className: string;
|
className: string;
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
@@ -1074,6 +1078,7 @@ export interface HeartbeatInfo {
|
|||||||
|
|
||||||
export interface AgentPsRow {
|
export interface AgentPsRow {
|
||||||
name: string;
|
name: string;
|
||||||
|
alias?: string;
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
host: string;
|
host: string;
|
||||||
runtime: string;
|
runtime: string;
|
||||||
@@ -1755,6 +1760,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
|
alias: agent.alias,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
host,
|
host,
|
||||||
runtime: agent.runtime,
|
runtime: agent.runtime,
|
||||||
@@ -1888,7 +1894,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
[
|
[
|
||||||
row.name.padEnd(18),
|
(row.alias ?? row.name).padEnd(18),
|
||||||
row.tenant_id.padEnd(12),
|
row.tenant_id.padEnd(12),
|
||||||
row.host.padEnd(12),
|
row.host.padEnd(12),
|
||||||
row.runtime.padEnd(10),
|
row.runtime.padEnd(10),
|
||||||
@@ -2494,6 +2500,8 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
|
|||||||
assertObject(raw, 'Fleet roster agent');
|
assertObject(raw, 'Fleet roster agent');
|
||||||
assertKnownKeys(raw, 'Fleet roster agent', [
|
assertKnownKeys(raw, 'Fleet roster agent', [
|
||||||
'name',
|
'name',
|
||||||
|
'alias',
|
||||||
|
'provider',
|
||||||
'runtime',
|
'runtime',
|
||||||
'class',
|
'class',
|
||||||
'working_directory',
|
'working_directory',
|
||||||
@@ -2525,6 +2533,8 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
|
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
|
||||||
|
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
|
||||||
runtime,
|
runtime,
|
||||||
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
||||||
workingDirectory: optionalString(
|
workingDirectory: optionalString(
|
||||||
@@ -2827,6 +2837,12 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
|||||||
runtime: agent.runtime,
|
runtime: agent.runtime,
|
||||||
class: agent.className,
|
class: agent.className,
|
||||||
};
|
};
|
||||||
|
if (agent.alias !== undefined) {
|
||||||
|
raw['alias'] = agent.alias;
|
||||||
|
}
|
||||||
|
if (agent.provider !== undefined) {
|
||||||
|
raw['provider'] = agent.provider;
|
||||||
|
}
|
||||||
if (agent.workingDirectory !== undefined) {
|
if (agent.workingDirectory !== undefined) {
|
||||||
raw['working_directory'] = agent.workingDirectory;
|
raw['working_directory'] = agent.workingDirectory;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ function readFleetToolPolicyBlock(policy: string | undefined): string {
|
|||||||
'',
|
'',
|
||||||
'Permitted: authorized conversation, status, retrieval, and safe diagnostics.',
|
'Permitted: authorized conversation, status, retrieval, and safe diagnostics.',
|
||||||
'Denied by default: coding/general orchestration claims, direct fleet control, destructive actions, and credential access.',
|
'Denied by default: coding/general orchestration claims, direct fleet control, destructive actions, and credential access.',
|
||||||
'Delegate Mos-owned work through the authorized handoff boundary.',
|
'Delegate orchestrator-owned work through the authorized handoff boundary.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user