feat(tess): add Mos coordination boundary (#735)
This commit was merged in pull request #735.
This commit is contained in:
@@ -1,10 +1,30 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { InMemoryMosCoordinationPort } from '@mosaicstack/coord';
|
||||||
import { CoordService } from './coord.service.js';
|
import { CoordService } from './coord.service.js';
|
||||||
import { CoordController } from './coord.controller.js';
|
import { CoordController } from './coord.controller.js';
|
||||||
|
import {
|
||||||
|
MOS_COORDINATION_CONFIG,
|
||||||
|
MOS_COORDINATION_PORT,
|
||||||
|
MosCoordinationService,
|
||||||
|
} from './mos-coordination.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [CoordService],
|
providers: [
|
||||||
|
CoordService,
|
||||||
|
{
|
||||||
|
provide: MOS_COORDINATION_PORT,
|
||||||
|
useFactory: (): InMemoryMosCoordinationPort => new InMemoryMosCoordinationPort(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MOS_COORDINATION_CONFIG,
|
||||||
|
useFactory: () => ({
|
||||||
|
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
|
||||||
|
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
MosCoordinationService,
|
||||||
|
],
|
||||||
controllers: [CoordController],
|
controllers: [CoordController],
|
||||||
exports: [CoordService],
|
exports: [CoordService, MosCoordinationService],
|
||||||
})
|
})
|
||||||
export class CoordModule {}
|
export class CoordModule {}
|
||||||
|
|||||||
25
apps/gateway/src/coord/mos-coordination.dto.ts
Normal file
25
apps/gateway/src/coord/mos-coordination.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type {
|
||||||
|
CoordinationObservation,
|
||||||
|
CoordinationResult,
|
||||||
|
MosHandoffReceipt,
|
||||||
|
} from '@mosaicstack/coord';
|
||||||
|
|
||||||
|
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
|
||||||
|
export interface CreateMosHandoffDto {
|
||||||
|
idempotencyKey: string;
|
||||||
|
summary: string;
|
||||||
|
context?: string;
|
||||||
|
missionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosCoordinationResponseDto {
|
||||||
|
receipt: MosHandoffReceipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosCoordinationObservationDto {
|
||||||
|
observation: CoordinationObservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosCoordinationResultDto {
|
||||||
|
result: CoordinationResult;
|
||||||
|
}
|
||||||
217
apps/gateway/src/coord/mos-coordination.service.test.ts
Normal file
217
apps/gateway/src/coord/mos-coordination.service.test.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
InMemoryMosCoordinationPort,
|
||||||
|
type MosCoordinationPort,
|
||||||
|
type MosHandoff,
|
||||||
|
} from '@mosaicstack/coord';
|
||||||
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
|
import {
|
||||||
|
MosCoordinationService,
|
||||||
|
type MosCoordinationConfig,
|
||||||
|
type MosCoordinationGatewayError,
|
||||||
|
} from './mos-coordination.service.js';
|
||||||
|
|
||||||
|
const context: RuntimeProviderRequestContext = {
|
||||||
|
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: MosCoordinationConfig = {
|
||||||
|
interactionAgentId: 'Nova',
|
||||||
|
orchestrationAgentId: 'Conductor',
|
||||||
|
};
|
||||||
|
|
||||||
|
function service(
|
||||||
|
port: MosCoordinationPort = new InMemoryMosCoordinationPort(),
|
||||||
|
options: {
|
||||||
|
config?: MosCoordinationConfig;
|
||||||
|
handoffIdFactory?: () => string;
|
||||||
|
} = {},
|
||||||
|
): MosCoordinationService {
|
||||||
|
return new MosCoordinationService(
|
||||||
|
port,
|
||||||
|
options.config ?? config,
|
||||||
|
options.handoffIdFactory ?? (() => 'handoff-1'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MosCoordinationService authority boundary', (): void => {
|
||||||
|
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
const coordination = service(adapter);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).resolves.toEqual({
|
||||||
|
handoffId: 'handoff-1',
|
||||||
|
targetAgentId: 'Conductor',
|
||||||
|
status: 'queued',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request');
|
||||||
|
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
|
||||||
|
|
||||||
|
const followUpContext = { ...context, correlationId: 'corr-2' };
|
||||||
|
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||||
|
targetAgentId: 'Conductor',
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||||
|
targetAgentId: 'Conductor',
|
||||||
|
status: 'completed',
|
||||||
|
summary: 'Merged by Mos',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
const handoff = vi.spyOn(adapter, 'handoff');
|
||||||
|
const coordination = service(adapter, {
|
||||||
|
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'unconfigured_requester',
|
||||||
|
} satisfies Partial<MosCoordinationGatewayError>);
|
||||||
|
expect(handoff).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
const handoff = vi.spyOn(adapter, 'handoff');
|
||||||
|
const coordination = service(adapter, {
|
||||||
|
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Interaction and orchestration identities must differ');
|
||||||
|
expect(handoff).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
const observe = vi.spyOn(adapter, 'observe');
|
||||||
|
const result = vi.spyOn(adapter, 'result');
|
||||||
|
const coordination = service(adapter);
|
||||||
|
await coordination.handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
const otherTenant = {
|
||||||
|
...context,
|
||||||
|
actorScope: { ...context.actorScope, tenantId: 'tenant-b' },
|
||||||
|
};
|
||||||
|
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||||
|
code: 'cross_tenant_forbidden',
|
||||||
|
} satisfies Partial<MosCoordinationGatewayError>);
|
||||||
|
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||||
|
code: 'cross_tenant_forbidden',
|
||||||
|
} satisfies Partial<MosCoordinationGatewayError>);
|
||||||
|
expect(observe).not.toHaveBeenCalled();
|
||||||
|
expect(result).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes idempotency by actor and joins concurrent retries without duplicate delivery', async (): Promise<void> => {
|
||||||
|
let handoffSequence = 0;
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
const delivered = new Promise<void>((resolve: () => void): void => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const adapter: MosCoordinationPort = {
|
||||||
|
handoff: vi.fn(async (handoff: MosHandoff) => {
|
||||||
|
await delivered;
|
||||||
|
return {
|
||||||
|
handoffId: handoff.handoffId,
|
||||||
|
targetAgentId: handoff.targetAgentId,
|
||||||
|
status: 'queued' as const,
|
||||||
|
correlationId: handoff.scope.correlationId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
observe: vi.fn(),
|
||||||
|
result: vi.fn(),
|
||||||
|
};
|
||||||
|
const coordination = service(adapter, {
|
||||||
|
handoffIdFactory: (): string => `handoff-${++handoffSequence}`,
|
||||||
|
});
|
||||||
|
const request = { idempotencyKey: 'request-1', summary: 'Implement the requested feature' };
|
||||||
|
|
||||||
|
const first = coordination.handoff(request, context);
|
||||||
|
const retry = coordination.handoff(request, context);
|
||||||
|
expect(adapter.handoff).toHaveBeenCalledTimes(1);
|
||||||
|
release?.();
|
||||||
|
await expect(Promise.all([first, retry])).resolves.toEqual([
|
||||||
|
expect.objectContaining({ handoffId: 'handoff-1' }),
|
||||||
|
expect.objectContaining({ handoffId: 'handoff-1' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff(request, {
|
||||||
|
...context,
|
||||||
|
actorScope: { ...context.actorScope, userId: 'operator-2' },
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ handoffId: 'handoff-2' });
|
||||||
|
expect(adapter.handoff).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
const handoff = vi.spyOn(adapter, 'handoff');
|
||||||
|
const coordination = service(adapter);
|
||||||
|
await coordination.handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'handoff_conflict',
|
||||||
|
} satisfies Partial<MosCoordinationGatewayError>);
|
||||||
|
await expect(
|
||||||
|
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'invalid_request',
|
||||||
|
} satisfies Partial<MosCoordinationGatewayError>);
|
||||||
|
await expect(
|
||||||
|
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'invalid_request',
|
||||||
|
} satisfies Partial<MosCoordinationGatewayError>);
|
||||||
|
expect(handoff).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
|
||||||
|
const adapter: MosCoordinationPort = {
|
||||||
|
handoff: vi.fn(async (handoff: MosHandoff) => ({
|
||||||
|
handoffId: handoff.handoffId,
|
||||||
|
targetAgentId: 'Unexpected',
|
||||||
|
status: 'accepted' as const,
|
||||||
|
correlationId: handoff.scope.correlationId,
|
||||||
|
})),
|
||||||
|
observe: vi.fn(),
|
||||||
|
result: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service(adapter).handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: 'target_drift' });
|
||||||
|
});
|
||||||
|
});
|
||||||
300
apps/gateway/src/coord/mos-coordination.service.ts
Normal file
300
apps/gateway/src/coord/mos-coordination.service.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
MosCoordinationClient,
|
||||||
|
type CoordinationObservation,
|
||||||
|
type CoordinationResult,
|
||||||
|
type CoordinationScope,
|
||||||
|
type MosCoordinationIdentity,
|
||||||
|
type MosCoordinationPort,
|
||||||
|
type MosHandoffReceipt,
|
||||||
|
} from '@mosaicstack/coord';
|
||||||
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
|
import type { CreateMosHandoffDto } from './mos-coordination.dto.js';
|
||||||
|
|
||||||
|
export const MOS_COORDINATION_PORT = Symbol('MOS_COORDINATION_PORT');
|
||||||
|
export const MOS_COORDINATION_CONFIG = Symbol('MOS_COORDINATION_CONFIG');
|
||||||
|
|
||||||
|
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||||
|
const MAX_TRACKED_HANDOFFS = 1_000;
|
||||||
|
const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
|
||||||
|
const MAX_SUMMARY_LENGTH = 2_048;
|
||||||
|
const MAX_CONTEXT_LENGTH = 8_192;
|
||||||
|
const MAX_MISSION_ID_LENGTH = 128;
|
||||||
|
|
||||||
|
export interface MosCoordinationConfig {
|
||||||
|
interactionAgentId?: string;
|
||||||
|
orchestrationAgentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HandoffOwner {
|
||||||
|
actorId: string;
|
||||||
|
tenantId: string;
|
||||||
|
requesterAgentId: string;
|
||||||
|
correlationId: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NormalizedMosHandoffRequest {
|
||||||
|
idempotencyKey: string;
|
||||||
|
summary: string;
|
||||||
|
context?: string;
|
||||||
|
missionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrackedHandoff {
|
||||||
|
request: NormalizedMosHandoffRequest;
|
||||||
|
receipt: Promise<MosHandoffReceipt>;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway authority boundary for the interaction agent. It derives requester,
|
||||||
|
* actor, and tenant from trusted server configuration and authentication; no
|
||||||
|
* channel request can name a target or gain Mos-owned orchestration verbs.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MosCoordinationService {
|
||||||
|
private readonly owners = new Map<string, HandoffOwner>();
|
||||||
|
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(MOS_COORDINATION_PORT) private readonly port: MosCoordinationPort,
|
||||||
|
@Inject(MOS_COORDINATION_CONFIG) private readonly config: MosCoordinationConfig,
|
||||||
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handoff(
|
||||||
|
request: CreateMosHandoffDto,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<MosHandoffReceipt> {
|
||||||
|
this.pruneExpiredTracking();
|
||||||
|
const normalized = this.normalizeRequest(request);
|
||||||
|
const scope = this.scope(context);
|
||||||
|
const idempotencyKey = this.idempotencyKey(normalized.idempotencyKey, scope);
|
||||||
|
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
if (!sameRequest(existing.request, normalized)) {
|
||||||
|
throw new MosCoordinationGatewayError(
|
||||||
|
'handoff_conflict',
|
||||||
|
'Mos handoff idempotency key is already bound to different immutable input',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return existing.receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = this.deliverHandoff(this.handoffIdFactory(), normalized, scope);
|
||||||
|
const tracked: TrackedHandoff = {
|
||||||
|
request: normalized,
|
||||||
|
receipt: pending,
|
||||||
|
expiresAt: this.expiresAt(),
|
||||||
|
};
|
||||||
|
this.handoffsByIdempotencyKey.set(idempotencyKey, tracked);
|
||||||
|
this.enforceTrackingLimit(this.handoffsByIdempotencyKey);
|
||||||
|
try {
|
||||||
|
return await pending;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (this.handoffsByIdempotencyKey.get(idempotencyKey) === tracked) {
|
||||||
|
this.handoffsByIdempotencyKey.delete(idempotencyKey);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async observe(
|
||||||
|
handoffId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<CoordinationObservation> {
|
||||||
|
this.pruneExpiredTracking();
|
||||||
|
const scope = this.scope(context);
|
||||||
|
const owner = this.ownerFor(handoffId, scope);
|
||||||
|
return this.client().observe(handoffId, { ...scope, correlationId: owner.correlationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async result(
|
||||||
|
handoffId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<CoordinationResult> {
|
||||||
|
this.pruneExpiredTracking();
|
||||||
|
const scope = this.scope(context);
|
||||||
|
const owner = this.ownerFor(handoffId, scope);
|
||||||
|
return this.client().result(handoffId, { ...scope, correlationId: owner.correlationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverHandoff(
|
||||||
|
handoffId: string,
|
||||||
|
request: NormalizedMosHandoffRequest,
|
||||||
|
scope: CoordinationScope,
|
||||||
|
): Promise<MosHandoffReceipt> {
|
||||||
|
const receipt = await this.client((): string => handoffId).handoff(request, scope);
|
||||||
|
const owner: HandoffOwner = {
|
||||||
|
actorId: scope.actorId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
requesterAgentId: scope.requesterAgentId,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
expiresAt: this.expiresAt(),
|
||||||
|
};
|
||||||
|
const existing = this.owners.get(receipt.handoffId);
|
||||||
|
if (existing !== undefined && !sameOwner(existing, owner)) {
|
||||||
|
throw new MosCoordinationGatewayError(
|
||||||
|
'handoff_conflict',
|
||||||
|
'Mos handoff ID is already bound to a different authenticated scope',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.owners.set(receipt.handoffId, owner);
|
||||||
|
this.enforceTrackingLimit(this.owners);
|
||||||
|
return receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private client(handoffIdFactory?: () => string): MosCoordinationClient {
|
||||||
|
return new MosCoordinationClient(this.identity(), this.port, handoffIdFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private identity(): MosCoordinationIdentity {
|
||||||
|
const interactionAgentId = this.config.interactionAgentId?.trim();
|
||||||
|
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
|
||||||
|
if (!interactionAgentId) {
|
||||||
|
throw new MosCoordinationGatewayError(
|
||||||
|
'unconfigured_requester',
|
||||||
|
'Interaction agent identity is not configured',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!orchestrationAgentId) {
|
||||||
|
throw new MosCoordinationGatewayError(
|
||||||
|
'unconfigured_target',
|
||||||
|
'Orchestration agent identity is not configured',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { interactionAgentId, orchestrationAgentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private scope(context: RuntimeProviderRequestContext): CoordinationScope {
|
||||||
|
const identity = this.identity();
|
||||||
|
return Object.freeze({
|
||||||
|
actorId: context.actorScope.userId,
|
||||||
|
tenantId: context.actorScope.tenantId,
|
||||||
|
correlationId: context.correlationId,
|
||||||
|
requesterAgentId: identity.interactionAgentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRequest(request: CreateMosHandoffDto): NormalizedMosHandoffRequest {
|
||||||
|
if (typeof request !== 'object' || request === null) {
|
||||||
|
throw new MosCoordinationGatewayError('invalid_request', 'Mos handoff request is invalid');
|
||||||
|
}
|
||||||
|
const idempotencyKey = this.requiredString(
|
||||||
|
request.idempotencyKey,
|
||||||
|
'idempotency key',
|
||||||
|
MAX_IDEMPOTENCY_KEY_LENGTH,
|
||||||
|
);
|
||||||
|
const summary = this.requiredString(request.summary, 'summary', MAX_SUMMARY_LENGTH);
|
||||||
|
const context = this.optionalString(request.context, 'context', MAX_CONTEXT_LENGTH);
|
||||||
|
const missionId = this.optionalString(request.missionId, 'mission ID', MAX_MISSION_ID_LENGTH);
|
||||||
|
return Object.freeze({
|
||||||
|
idempotencyKey,
|
||||||
|
summary,
|
||||||
|
...(context === undefined ? {} : { context }),
|
||||||
|
...(missionId === undefined ? {} : { missionId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private idempotencyKey(requestKey: string, scope: CoordinationScope): string {
|
||||||
|
return `${scope.tenantId}\u0000${scope.actorId}\u0000${scope.requesterAgentId}\u0000${requestKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requiredString(value: unknown, field: string, maximumLength: number): string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new MosCoordinationGatewayError(
|
||||||
|
'invalid_request',
|
||||||
|
`Mos handoff ${field} must be a string`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (normalized.length === 0 || normalized.length > maximumLength) {
|
||||||
|
throw new MosCoordinationGatewayError('invalid_request', `Mos handoff ${field} is invalid`);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private optionalString(value: unknown, field: string, maximumLength: number): string | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
return this.requiredString(value, field, maximumLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
private expiresAt(): number {
|
||||||
|
return Date.now() + HANDOFF_TRACKING_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneExpiredTracking(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, tracked] of this.handoffsByIdempotencyKey) {
|
||||||
|
if (tracked.expiresAt <= now) this.handoffsByIdempotencyKey.delete(key);
|
||||||
|
}
|
||||||
|
for (const [key, owner] of this.owners) {
|
||||||
|
if (owner.expiresAt <= now) this.owners.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enforceTrackingLimit<T>(entries: Map<string, T>): void {
|
||||||
|
while (entries.size > MAX_TRACKED_HANDOFFS) {
|
||||||
|
const oldest = entries.keys().next().value;
|
||||||
|
if (typeof oldest !== 'string') return;
|
||||||
|
entries.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
|
||||||
|
const owner = this.owners.get(handoffId);
|
||||||
|
if (owner === undefined) {
|
||||||
|
throw new MosCoordinationGatewayError('not_found', 'Mos handoff was not found');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
owner.tenantId !== scope.tenantId ||
|
||||||
|
owner.actorId !== scope.actorId ||
|
||||||
|
owner.requesterAgentId !== scope.requesterAgentId
|
||||||
|
) {
|
||||||
|
throw new MosCoordinationGatewayError(
|
||||||
|
'cross_tenant_forbidden',
|
||||||
|
'Mos handoff is outside the authenticated scope',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MosCoordinationGatewayErrorCode =
|
||||||
|
| 'cross_tenant_forbidden'
|
||||||
|
| 'handoff_conflict'
|
||||||
|
| 'invalid_request'
|
||||||
|
| 'not_found'
|
||||||
|
| 'unconfigured_requester'
|
||||||
|
| 'unconfigured_target';
|
||||||
|
|
||||||
|
function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean {
|
||||||
|
return (
|
||||||
|
left.actorId === right.actorId &&
|
||||||
|
left.tenantId === right.tenantId &&
|
||||||
|
left.requesterAgentId === right.requesterAgentId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameRequest(
|
||||||
|
left: NormalizedMosHandoffRequest,
|
||||||
|
right: NormalizedMosHandoffRequest,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
left.idempotencyKey === right.idempotencyKey &&
|
||||||
|
left.summary === right.summary &&
|
||||||
|
left.context === right.context &&
|
||||||
|
left.missionId === right.missionId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MosCoordinationGatewayError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: MosCoordinationGatewayErrorCode,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = MosCoordinationGatewayError.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
docs/scratchpads/tess-m4-001-mos-coordination.md
Normal file
46
docs/scratchpads/tess-m4-001-mos-coordination.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# TESS-M4-001 — Mos Coordination
|
||||||
|
|
||||||
|
- **Issue/task:** #710 / TESS-M4-001
|
||||||
|
- **Branch/base:** `feat/tess-mos-coordination` rebased onto `origin/main` `f1c6b37b`
|
||||||
|
- **Budget assumption:** task estimate 25K; design-first and TDD, with package contract plus gateway boundary only.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Implement a transport-neutral coordination contract allowing a configured interaction agent to hand off Mos-owned work, observe activity, and receive results while preventing it from gaining coding/general orchestration authority.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Document the contract and enforcement-point sketch; request Mos's decision on the initial concrete transport.
|
||||||
|
2. Add `@mosaicstack/coord` typed handoff/observe/result contracts and denial errors.
|
||||||
|
3. Add a gateway service which derives actor/tenant/requester identity from trusted context/configuration and validates authority.
|
||||||
|
4. Add contract and gateway boundary tests for configurable identities, self-delegation, target drift, and cross-tenant read denial.
|
||||||
|
5. Run focused, cold-cache, baseline tests; independent review; PR lifecycle.
|
||||||
|
|
||||||
|
## Design checkpoint — 2026-07-12
|
||||||
|
|
||||||
|
Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `InMemoryMosCoordinationPort` for M4. Fleet/tmux remains a documented M5 adapter seam; no Mos-side consumer is built in this task.
|
||||||
|
|
||||||
|
## Progress checkpoint — 2026-07-13
|
||||||
|
|
||||||
|
- Implemented `MosCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`.
|
||||||
|
- Implemented the gateway `MosCoordinationService`, deriving requester identity from trusted configuration and actor/tenant/correlation from authenticated context.
|
||||||
|
- Added contract and gateway boundary tests for configurable identities, native round-trip, unconfigured requester, self-delegation, target drift, and cross-tenant observe/result denial before adapter invocation.
|
||||||
|
- Did not modify `apps/gateway/src/commands/command-authorization.service.ts`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm --filter @mosaicstack/coord test` — PASS (16 tests after authority/idempotency remediation).
|
||||||
|
- `pnpm --filter @mosaicstack/coord build` — PASS.
|
||||||
|
- `pnpm --filter @mosaicstack/gateway test -- mos-coordination.service.test.ts` — PASS (7 tests after authority/idempotency remediation).
|
||||||
|
- Standalone gateway typecheck initially reported missing built workspace packages after fresh worktree setup; root validation builds the workspace graph and passed.
|
||||||
|
- `TURBO_FORCE=true pnpm typecheck` — PASS (42 tasks, 0 cached).
|
||||||
|
- `TURBO_FORCE=true pnpm lint` — PASS (23 tasks, 0 cached after one import-type remediation).
|
||||||
|
- `TURBO_FORCE=true pnpm format:check` — PASS.
|
||||||
|
- `TURBO_FORCE=true pnpm test` — PASS (42 tasks, 0 cached; expected existing integration skips only).
|
||||||
|
|
||||||
|
## Review checkpoint
|
||||||
|
|
||||||
|
- Codex code review found idempotency keys needed actor scope and concurrent retries needed an in-flight reservation; both were remediated with regression coverage.
|
||||||
|
- Codex security review found whitespace-equivalent self-delegation was accepted by the exported client; identities are now normalized before invariant checks, with regression coverage.
|
||||||
|
- Re-review added immutable payload comparison for idempotency reuse, runtime string/size validation, bounded TTL/capacity tracking for gateway and native adapter state, and fresh-correlation follow-up reads; targeted tests pass (16 coord / 7 gateway).
|
||||||
|
- Final Codex security review found no issues. PR #735 was opened from commit `7936e15d`; Woodpecker pipeline #1752 is green.
|
||||||
@@ -52,6 +52,21 @@ Termination is fail-closed: a runtime approval verifier consumes a one-time, exa
|
|||||||
| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently |
|
| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently |
|
||||||
| Provider-specific unsupported action | None | Fail closed; never emulate silently |
|
| Provider-specific unsupported action | None | Fail closed; never emulate silently |
|
||||||
|
|
||||||
|
### Mos Coordination Boundary
|
||||||
|
|
||||||
|
`@mosaicstack/coord` exposes only the transport-neutral `MosCoordinationPort`
|
||||||
|
verbs `handoff`, `observe`, and `result`. Gateway derives the actor, tenant,
|
||||||
|
correlation, and interaction-agent identity from authenticated context plus
|
||||||
|
trusted configuration; callers never provide an orchestration target. It
|
||||||
|
rejects unconfigured identities, self-delegation, target/correlation drift, and
|
||||||
|
cross-tenant handoff reads before an adapter call. No dispatch, assignment,
|
||||||
|
review, merge, or cancellation API exists at this boundary.
|
||||||
|
|
||||||
|
M4 uses a deterministic native in-process queue adapter to prove the handoff →
|
||||||
|
observe → result flow without coupling the contract to tmux. A fleet/tmux
|
||||||
|
adapter is deferred to the M5 live-deployment seam and must implement the same
|
||||||
|
port.
|
||||||
|
|
||||||
## Session and State Model
|
## Session and State Model
|
||||||
|
|
||||||
A Tess session has stable `sessionId`, `tenantId`, `ownerId`, provider/runtime identity, ingress bindings, cursor, checkpoint, inbox/outbox, and idempotency records. Discord and CLI bind to the same authorized session. Ownership is verified server-side on every list/read/attach/send/terminate operation.
|
A Tess session has stable `sessionId`, `tenantId`, `ownerId`, provider/runtime identity, ingress bindings, cursor, checkpoint, inbox/outbox, and idempotency records. Discord and CLI bind to the same authorized session. Ownership is verified server-side on every list/read/attach/send/terminate operation.
|
||||||
|
|||||||
83
docs/tess/MOS-COORDINATION.md
Normal file
83
docs/tess/MOS-COORDINATION.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Tess–Mos Coordination Contract Sketch
|
||||||
|
|
||||||
|
**Task:** TESS-M4-001 · **PRD:** TESS-MOS-001 / AC-TESS-04
|
||||||
|
|
||||||
|
## Boundary
|
||||||
|
|
||||||
|
Agent identities are deployment data. A configured interaction agent may request
|
||||||
|
Mos-owned work; the configured orchestration agent owns decomposition, worker
|
||||||
|
assignment, reviews, and merge decisions. The interaction agent receives a
|
||||||
|
correlated receipt, read-only activity projection, and terminal result. It has
|
||||||
|
no dispatch, assignment, review, merge, or cancellation operation.
|
||||||
|
|
||||||
|
## `@mosaicstack/coord` interface
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CoordinationScope {
|
||||||
|
readonly actorId: string;
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly requesterAgentId: string; // trusted gateway/configuration data
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MosHandoffRequest {
|
||||||
|
readonly idempotencyKey: string;
|
||||||
|
readonly summary: string;
|
||||||
|
readonly context?: string;
|
||||||
|
readonly missionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MosHandoffReceipt {
|
||||||
|
readonly handoffId: string;
|
||||||
|
readonly targetAgentId: string;
|
||||||
|
readonly status: 'accepted' | 'queued';
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MosHandoff {
|
||||||
|
readonly handoffId: string;
|
||||||
|
readonly targetAgentId: string;
|
||||||
|
readonly request: MosHandoffRequest;
|
||||||
|
readonly scope: CoordinationScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MosCoordinationPort {
|
||||||
|
handoff(handoff: MosHandoff): Promise<MosHandoffReceipt>;
|
||||||
|
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
||||||
|
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The port deliberately omits generic orchestrator verbs. It is tenant- and
|
||||||
|
correlation-scoped; its gateway implementation obtains `actorId`, `tenantId`,
|
||||||
|
and the requester agent from trusted authentication/configuration only.
|
||||||
|
|
||||||
|
## Enforcement point
|
||||||
|
|
||||||
|
`apps/gateway` owns a `MosCoordinationService` boundary that compares the
|
||||||
|
trusted configured requester/target identities and rejects all of the following
|
||||||
|
before calling a transport: unconfigured requester, self-delegation, target
|
||||||
|
identity drift, cross-tenant observe/result lookup, and attempts to observe or
|
||||||
|
receive a result for a handoff outside the originating tenant. The service exposes handoff, observe,
|
||||||
|
and result only, and delegates delivery to an injected adapter.
|
||||||
|
|
||||||
|
M4 ships a native in-process `InMemoryMosCoordinationPort` as the concrete,
|
||||||
|
deterministic adapter. It preserves the immutable handoff ID, tenant, requester
|
||||||
|
identity, and correlation ID while demonstrating the handoff → observe → result
|
||||||
|
round trip. It is a queue/port adapter, not a Mos-side consumer.
|
||||||
|
|
||||||
|
A future fleet/tmux adapter is a documented M5 deployment seam and must
|
||||||
|
implement the same `MosCoordinationPort`; no channel client or interaction
|
||||||
|
runtime calls a transport directly.
|
||||||
|
|
||||||
|
## Required tests
|
||||||
|
|
||||||
|
1. A configured non-default interaction identity can hand off work to a
|
||||||
|
configured non-default orchestration identity and receive its result.
|
||||||
|
2. The gateway passes only server-derived scope/identity to the adapter.
|
||||||
|
3. Self-targeting, target drift, and cross-tenant observe/result all fail closed
|
||||||
|
without invoking the adapter.
|
||||||
|
4. The exported public contract has no worker-dispatch, assignment, review,
|
||||||
|
merge, or cancellation capability.
|
||||||
|
5. The native adapter round-trips queued work, activity, and a host-recorded
|
||||||
|
terminal result without a live fleet dependency.
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
# Tess Verification Matrix
|
# Tess Verification Matrix
|
||||||
|
|
||||||
| Acceptance criterion | Requirements | Planned evidence | Gate |
|
| Acceptance criterion | Requirements | Planned evidence | Gate |
|
||||||
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
|
| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
|
||||||
| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V |
|
| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V |
|
||||||
| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V |
|
| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V |
|
||||||
| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V |
|
| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V |
|
||||||
| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V |
|
| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | M4 contract/gateway native-port handoff → observe → result round trip; configurable identity, target-drift and tenant-denial tests; M4-V fleet authority qualification | M4-001, M4-V |
|
||||||
| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V |
|
| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V |
|
||||||
| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V |
|
| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V |
|
||||||
| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V |
|
| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V |
|
||||||
| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V |
|
| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V |
|
||||||
| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone |
|
| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone |
|
||||||
| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V |
|
| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V |
|
||||||
| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V |
|
| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V |
|
||||||
|
|
||||||
## Security Abuse Suite Minimum
|
## Security Abuse Suite Minimum
|
||||||
|
|
||||||
|
|||||||
154
packages/coord/src/__tests__/mos-coordination.test.ts
Normal file
154
packages/coord/src/__tests__/mos-coordination.test.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
InMemoryMosCoordinationPort,
|
||||||
|
MosCoordinationClient,
|
||||||
|
type CoordinationScope,
|
||||||
|
type MosCoordinationAuthorityError,
|
||||||
|
type MosCoordinationPort,
|
||||||
|
} from '../index.js';
|
||||||
|
|
||||||
|
const scope: CoordinationScope = {
|
||||||
|
actorId: 'operator-1',
|
||||||
|
tenantId: 'tenant-a',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
requesterAgentId: 'Nova',
|
||||||
|
};
|
||||||
|
|
||||||
|
function client(
|
||||||
|
port: MosCoordinationPort,
|
||||||
|
handoffIdFactory: () => string = (): string => 'handoff-1',
|
||||||
|
): MosCoordinationClient {
|
||||||
|
return new MosCoordinationClient(
|
||||||
|
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' },
|
||||||
|
port,
|
||||||
|
handoffIdFactory,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MosCoordinationClient', (): void => {
|
||||||
|
it('round-trips handoff, observation, and result through the native port with identities as data', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
const coordination = client(adapter);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff(
|
||||||
|
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
|
||||||
|
scope,
|
||||||
|
),
|
||||||
|
).resolves.toEqual({
|
||||||
|
handoffId: 'handoff-1',
|
||||||
|
targetAgentId: 'Conductor',
|
||||||
|
status: 'queued',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request');
|
||||||
|
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
|
||||||
|
|
||||||
|
await expect(coordination.observe('handoff-1', scope)).resolves.toMatchObject({
|
||||||
|
status: 'completed',
|
||||||
|
targetAgentId: 'Conductor',
|
||||||
|
activity: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ status: 'queued' }),
|
||||||
|
expect.objectContaining({ status: 'running' }),
|
||||||
|
expect.objectContaining({ status: 'completed' }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
await expect(coordination.result('handoff-1', scope)).resolves.toEqual({
|
||||||
|
handoffId: 'handoff-1',
|
||||||
|
targetAgentId: 'Conductor',
|
||||||
|
status: 'completed',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
summary: 'Merged by Mos',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(coordination).not.toHaveProperty('dispatch');
|
||||||
|
expect(coordination).not.toHaveProperty('assign');
|
||||||
|
expect(coordination).not.toHaveProperty('review');
|
||||||
|
expect(coordination).not.toHaveProperty('merge');
|
||||||
|
expect(coordination).not.toHaveProperty('cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed before delivery when an unconfigured agent requests Mos work', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client(adapter).handoff(
|
||||||
|
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
|
||||||
|
{ ...scope, requesterAgentId: 'Untrusted' },
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'requester_forbidden',
|
||||||
|
} satisfies Partial<MosCoordinationAuthorityError>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects self-delegation configuration before constructing a client', (): void => {
|
||||||
|
expect(
|
||||||
|
(): MosCoordinationClient =>
|
||||||
|
new MosCoordinationClient(
|
||||||
|
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
||||||
|
new InMemoryMosCoordinationPort(),
|
||||||
|
),
|
||||||
|
).toThrow('Interaction and orchestration identities must differ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects whitespace-equivalent self-delegation identities', (): void => {
|
||||||
|
expect(
|
||||||
|
(): MosCoordinationClient =>
|
||||||
|
new MosCoordinationClient(
|
||||||
|
{ interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' },
|
||||||
|
new InMemoryMosCoordinationPort(),
|
||||||
|
),
|
||||||
|
).toThrow('Interaction and orchestration identities must differ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose another tenant handoff to observe or result', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort();
|
||||||
|
const coordination = client(adapter);
|
||||||
|
await coordination.handoff(
|
||||||
|
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
|
||||||
|
const otherTenantScope = { ...scope, tenantId: 'tenant-b' };
|
||||||
|
await expect(coordination.observe('handoff-1', otherTenantScope)).rejects.toMatchObject({
|
||||||
|
code: 'forbidden',
|
||||||
|
});
|
||||||
|
await expect(coordination.result('handoff-1', otherTenantScope)).rejects.toMatchObject({
|
||||||
|
code: 'forbidden',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds native handoff retention by evicting the oldest handoff', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryMosCoordinationPort({ maxHandoffs: 1 });
|
||||||
|
const first = client(adapter, (): string => 'handoff-1');
|
||||||
|
const second = client(adapter, (): string => 'handoff-2');
|
||||||
|
await first.handoff({ idempotencyKey: 'handoff-request-1', summary: 'First request' }, scope);
|
||||||
|
await second.handoff({ idempotencyKey: 'handoff-request-2', summary: 'Second request' }, scope);
|
||||||
|
|
||||||
|
await expect(first.observe('handoff-1', scope)).rejects.toMatchObject({ code: 'not_found' });
|
||||||
|
await expect(second.observe('handoff-2', scope)).resolves.toMatchObject({ status: 'queued' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when a transport reports target drift', async (): Promise<void> => {
|
||||||
|
const adapter: MosCoordinationPort = {
|
||||||
|
handoff: vi.fn(async () => ({
|
||||||
|
handoffId: 'handoff-1',
|
||||||
|
targetAgentId: 'Unexpected',
|
||||||
|
status: 'accepted' as const,
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
})),
|
||||||
|
observe: vi.fn(),
|
||||||
|
result: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client(adapter).handoff(
|
||||||
|
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
|
||||||
|
scope,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'target_drift',
|
||||||
|
} satisfies Partial<MosCoordinationAuthorityError>);
|
||||||
|
});
|
||||||
|
});
|
||||||
208
packages/coord/src/in-memory-mos-coordination-port.ts
Normal file
208
packages/coord/src/in-memory-mos-coordination-port.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import {
|
||||||
|
type CoordinationObservation,
|
||||||
|
type CoordinationResult,
|
||||||
|
type CoordinationScope,
|
||||||
|
type MosCoordinationActivity,
|
||||||
|
type MosCoordinationPort,
|
||||||
|
type MosHandoff,
|
||||||
|
type MosHandoffReceipt,
|
||||||
|
type MosHandoffStatus,
|
||||||
|
} from './mos-coordination.js';
|
||||||
|
|
||||||
|
const DEFAULT_HANDOFF_TTL_MS = 60 * 60 * 1_000;
|
||||||
|
const DEFAULT_MAX_HANDOFFS = 1_000;
|
||||||
|
|
||||||
|
interface StoredHandoff {
|
||||||
|
readonly handoff: MosHandoff;
|
||||||
|
status: MosHandoffStatus;
|
||||||
|
readonly activity: MosCoordinationActivity[];
|
||||||
|
readonly expiresAt: number;
|
||||||
|
result?: CoordinationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InMemoryMosCoordinationPortOptions {
|
||||||
|
now?: () => Date;
|
||||||
|
handoffTtlMs?: number;
|
||||||
|
maxHandoffs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native deterministic queue/port adapter for the coordination boundary.
|
||||||
|
* It intentionally has no fleet/tmux dependency. A future deployment adapter
|
||||||
|
* implements MosCoordinationPort without changing interaction-plane callers.
|
||||||
|
*/
|
||||||
|
export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
||||||
|
private readonly handoffs = new Map<string, StoredHandoff>();
|
||||||
|
private readonly now: () => Date;
|
||||||
|
private readonly handoffTtlMs: number;
|
||||||
|
private readonly maxHandoffs: number;
|
||||||
|
|
||||||
|
constructor(options: InMemoryMosCoordinationPortOptions = {}) {
|
||||||
|
this.now = options.now ?? (() => new Date());
|
||||||
|
this.handoffTtlMs = options.handoffTtlMs ?? DEFAULT_HANDOFF_TTL_MS;
|
||||||
|
this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handoff(handoff: MosHandoff): Promise<MosHandoffReceipt> {
|
||||||
|
this.pruneExpiredHandoffs();
|
||||||
|
const existing = this.handoffs.get(handoff.handoffId);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
this.assertSameHandoff(existing.handoff, handoff);
|
||||||
|
return this.receipt(existing.handoff, existing.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored: StoredHandoff = {
|
||||||
|
handoff: snapshotHandoff(handoff),
|
||||||
|
status: 'queued',
|
||||||
|
activity: [activity('queued', 'Handoff accepted by the native coordination queue', this.now)],
|
||||||
|
expiresAt: this.now().getTime() + this.handoffTtlMs,
|
||||||
|
};
|
||||||
|
this.handoffs.set(handoff.handoffId, stored);
|
||||||
|
this.enforceHandoffLimit();
|
||||||
|
return this.receipt(stored.handoff, stored.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation> {
|
||||||
|
this.pruneExpiredHandoffs();
|
||||||
|
const stored = this.requireScopedHandoff(handoffId, scope);
|
||||||
|
return {
|
||||||
|
handoffId: stored.handoff.handoffId,
|
||||||
|
targetAgentId: stored.handoff.targetAgentId,
|
||||||
|
status: stored.status,
|
||||||
|
correlationId: stored.handoff.scope.correlationId,
|
||||||
|
activity: stored.activity.map(copyActivity),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult> {
|
||||||
|
this.pruneExpiredHandoffs();
|
||||||
|
const stored = this.requireScopedHandoff(handoffId, scope);
|
||||||
|
return (
|
||||||
|
stored.result ?? {
|
||||||
|
handoffId: stored.handoff.handoffId,
|
||||||
|
targetAgentId: stored.handoff.targetAgentId,
|
||||||
|
status: 'pending',
|
||||||
|
correlationId: stored.handoff.scope.correlationId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host-side progression seam; interaction clients never receive this capability. */
|
||||||
|
recordActivity(handoffId: string, status: MosHandoffStatus, summary: string): void {
|
||||||
|
this.pruneExpiredHandoffs();
|
||||||
|
const stored = this.requireHandoff(handoffId);
|
||||||
|
stored.status = status;
|
||||||
|
stored.activity.push(activity(status, summary, this.now));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host-side result seam for deterministic qualification; not a Mos consumer. */
|
||||||
|
recordResult(handoffId: string, status: 'completed' | 'failed', summary: string): void {
|
||||||
|
this.pruneExpiredHandoffs();
|
||||||
|
const stored = this.requireHandoff(handoffId);
|
||||||
|
stored.status = status;
|
||||||
|
stored.activity.push(activity(status, summary, this.now));
|
||||||
|
stored.result = {
|
||||||
|
handoffId: stored.handoff.handoffId,
|
||||||
|
targetAgentId: stored.handoff.targetAgentId,
|
||||||
|
status,
|
||||||
|
correlationId: stored.handoff.scope.correlationId,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private receipt(handoff: MosHandoff, status: MosHandoffStatus): MosHandoffReceipt {
|
||||||
|
return {
|
||||||
|
handoffId: handoff.handoffId,
|
||||||
|
targetAgentId: handoff.targetAgentId,
|
||||||
|
status: status === 'accepted' ? 'accepted' : 'queued',
|
||||||
|
correlationId: handoff.scope.correlationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneExpiredHandoffs(): void {
|
||||||
|
const nowMs = this.now().getTime();
|
||||||
|
for (const [handoffId, handoff] of this.handoffs) {
|
||||||
|
if (handoff.expiresAt <= nowMs) this.handoffs.delete(handoffId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enforceHandoffLimit(): void {
|
||||||
|
while (this.handoffs.size > this.maxHandoffs) {
|
||||||
|
const oldest = this.handoffs.keys().next().value;
|
||||||
|
if (typeof oldest !== 'string') return;
|
||||||
|
this.handoffs.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireScopedHandoff(handoffId: string, scope: CoordinationScope): StoredHandoff {
|
||||||
|
const stored = this.requireHandoff(handoffId);
|
||||||
|
if (
|
||||||
|
stored.handoff.scope.tenantId !== scope.tenantId ||
|
||||||
|
stored.handoff.scope.actorId !== scope.actorId ||
|
||||||
|
stored.handoff.scope.requesterAgentId !== scope.requesterAgentId
|
||||||
|
) {
|
||||||
|
throw new InMemoryMosCoordinationError('forbidden', 'Handoff scope does not match');
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireHandoff(handoffId: string): StoredHandoff {
|
||||||
|
const stored = this.handoffs.get(handoffId);
|
||||||
|
if (stored === undefined) {
|
||||||
|
throw new InMemoryMosCoordinationError('not_found', 'Handoff was not found');
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSameHandoff(existing: MosHandoff, incoming: MosHandoff): void {
|
||||||
|
if (
|
||||||
|
existing.targetAgentId !== incoming.targetAgentId ||
|
||||||
|
existing.request.idempotencyKey !== incoming.request.idempotencyKey ||
|
||||||
|
existing.request.summary !== incoming.request.summary ||
|
||||||
|
existing.request.context !== incoming.request.context ||
|
||||||
|
existing.request.missionId !== incoming.request.missionId ||
|
||||||
|
existing.scope.actorId !== incoming.scope.actorId ||
|
||||||
|
existing.scope.tenantId !== incoming.scope.tenantId ||
|
||||||
|
existing.scope.correlationId !== incoming.scope.correlationId ||
|
||||||
|
existing.scope.requesterAgentId !== incoming.scope.requesterAgentId
|
||||||
|
) {
|
||||||
|
throw new InMemoryMosCoordinationError(
|
||||||
|
'conflict',
|
||||||
|
'Handoff ID is already bound to different immutable input',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InMemoryMosCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found';
|
||||||
|
|
||||||
|
export class InMemoryMosCoordinationError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: InMemoryMosCoordinationErrorCode,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = InMemoryMosCoordinationError.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activity(
|
||||||
|
status: MosHandoffStatus,
|
||||||
|
summary: string,
|
||||||
|
now: () => Date,
|
||||||
|
): MosCoordinationActivity {
|
||||||
|
return { occurredAt: now().toISOString(), status, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyActivity(entry: MosCoordinationActivity): MosCoordinationActivity {
|
||||||
|
return { ...entry };
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotHandoff(handoff: MosHandoff): MosHandoff {
|
||||||
|
return Object.freeze({
|
||||||
|
handoffId: handoff.handoffId,
|
||||||
|
targetAgentId: handoff.targetAgentId,
|
||||||
|
request: Object.freeze({ ...handoff.request }),
|
||||||
|
scope: Object.freeze({ ...handoff.scope }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,23 @@ export { createMission, loadMission, missionFilePath, saveMission } from './miss
|
|||||||
export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.js';
|
export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.js';
|
||||||
export { runTask, resumeTask } from './runner.js';
|
export { runTask, resumeTask } from './runner.js';
|
||||||
export { getMissionStatus, getTaskStatus } from './status.js';
|
export { getMissionStatus, getTaskStatus } from './status.js';
|
||||||
|
export {
|
||||||
|
InMemoryMosCoordinationError,
|
||||||
|
InMemoryMosCoordinationPort,
|
||||||
|
} from './in-memory-mos-coordination-port.js';
|
||||||
|
export { MosCoordinationAuthorityError, MosCoordinationClient } from './mos-coordination.js';
|
||||||
|
export type {
|
||||||
|
CoordinationObservation,
|
||||||
|
CoordinationResult,
|
||||||
|
CoordinationScope,
|
||||||
|
MosCoordinationActivity,
|
||||||
|
MosCoordinationIdentity,
|
||||||
|
MosCoordinationPort,
|
||||||
|
MosHandoff,
|
||||||
|
MosHandoffReceipt,
|
||||||
|
MosHandoffRequest,
|
||||||
|
MosHandoffStatus,
|
||||||
|
} from './mos-coordination.js';
|
||||||
export type {
|
export type {
|
||||||
CreateMissionOptions,
|
CreateMissionOptions,
|
||||||
Mission,
|
Mission,
|
||||||
|
|||||||
207
packages/coord/src/mos-coordination.ts
Normal file
207
packages/coord/src/mos-coordination.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
export type MosHandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
export interface CoordinationScope {
|
||||||
|
readonly actorId: string;
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
/** Trusted gateway/configuration identity; never supplied by a channel client. */
|
||||||
|
readonly requesterAgentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosCoordinationIdentity {
|
||||||
|
readonly interactionAgentId: string;
|
||||||
|
readonly orchestrationAgentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosHandoffRequest {
|
||||||
|
readonly idempotencyKey: string;
|
||||||
|
readonly summary: string;
|
||||||
|
readonly context?: string;
|
||||||
|
readonly missionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosHandoff {
|
||||||
|
readonly handoffId: string;
|
||||||
|
readonly targetAgentId: string;
|
||||||
|
readonly request: MosHandoffRequest;
|
||||||
|
readonly scope: CoordinationScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosHandoffReceipt {
|
||||||
|
readonly handoffId: string;
|
||||||
|
readonly targetAgentId: string;
|
||||||
|
readonly status: 'queued' | 'accepted';
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosCoordinationActivity {
|
||||||
|
readonly occurredAt: string;
|
||||||
|
readonly status: MosHandoffStatus;
|
||||||
|
readonly summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoordinationObservation {
|
||||||
|
readonly handoffId: string;
|
||||||
|
readonly targetAgentId: string;
|
||||||
|
readonly status: MosHandoffStatus;
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly activity: readonly MosCoordinationActivity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoordinationResult {
|
||||||
|
readonly handoffId: string;
|
||||||
|
readonly targetAgentId: string;
|
||||||
|
readonly status: 'completed' | 'failed' | 'pending';
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transport-neutral boundary. The interaction plane can request work and read
|
||||||
|
* its progress/result, but it cannot issue worker, review, merge, or other
|
||||||
|
* general orchestration commands.
|
||||||
|
*/
|
||||||
|
export interface MosCoordinationPort {
|
||||||
|
handoff(handoff: MosHandoff): Promise<MosHandoffReceipt>;
|
||||||
|
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
||||||
|
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MosCoordinationAuthorityErrorCode =
|
||||||
|
| 'invalid_identity'
|
||||||
|
| 'requester_forbidden'
|
||||||
|
| 'target_drift'
|
||||||
|
| 'correlation_drift';
|
||||||
|
|
||||||
|
export class MosCoordinationAuthorityError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: MosCoordinationAuthorityErrorCode,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = MosCoordinationAuthorityError.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforces the interaction-to-orchestration authority boundary before a
|
||||||
|
* transport is reached. Identity names remain configuration data.
|
||||||
|
*/
|
||||||
|
export class MosCoordinationClient {
|
||||||
|
private readonly identity: MosCoordinationIdentity;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
identity: MosCoordinationIdentity,
|
||||||
|
private readonly port: MosCoordinationPort,
|
||||||
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||||
|
) {
|
||||||
|
this.identity = normalizeIdentity(identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handoff(request: MosHandoffRequest, scope: CoordinationScope): Promise<MosHandoffReceipt> {
|
||||||
|
this.assertRequester(scope);
|
||||||
|
const handoff: MosHandoff = {
|
||||||
|
handoffId: this.handoffIdFactory(),
|
||||||
|
targetAgentId: this.identity.orchestrationAgentId,
|
||||||
|
request: snapshotRequest(request),
|
||||||
|
scope: snapshotScope(scope),
|
||||||
|
};
|
||||||
|
const receipt = await this.port.handoff(handoff);
|
||||||
|
if (
|
||||||
|
receipt.handoffId !== handoff.handoffId ||
|
||||||
|
receipt.targetAgentId !== handoff.targetAgentId
|
||||||
|
) {
|
||||||
|
throw new MosCoordinationAuthorityError(
|
||||||
|
'target_drift',
|
||||||
|
'Mos coordination transport returned a mismatched handoff target',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (receipt.correlationId !== handoff.scope.correlationId) {
|
||||||
|
throw new MosCoordinationAuthorityError(
|
||||||
|
'correlation_drift',
|
||||||
|
'Mos coordination transport returned a mismatched correlation ID',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation> {
|
||||||
|
this.assertRequester(scope);
|
||||||
|
return this.assertObservation(await this.port.observe(handoffId, snapshotScope(scope)), scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult> {
|
||||||
|
this.assertRequester(scope);
|
||||||
|
return this.assertResult(await this.port.result(handoffId, snapshotScope(scope)), scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertRequester(scope: CoordinationScope): void {
|
||||||
|
if (scope.requesterAgentId !== this.identity.interactionAgentId) {
|
||||||
|
throw new MosCoordinationAuthorityError(
|
||||||
|
'requester_forbidden',
|
||||||
|
'Requester is not the configured interaction agent',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertObservation(
|
||||||
|
observation: CoordinationObservation,
|
||||||
|
scope: CoordinationScope,
|
||||||
|
): CoordinationObservation {
|
||||||
|
if (observation.targetAgentId !== this.identity.orchestrationAgentId) {
|
||||||
|
throw new MosCoordinationAuthorityError(
|
||||||
|
'target_drift',
|
||||||
|
'Mos coordination transport returned an unexpected observation target',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (observation.correlationId !== scope.correlationId) {
|
||||||
|
throw new MosCoordinationAuthorityError(
|
||||||
|
'correlation_drift',
|
||||||
|
'Mos coordination transport returned a mismatched observation correlation ID',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return observation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertResult(result: CoordinationResult, scope: CoordinationScope): CoordinationResult {
|
||||||
|
if (result.targetAgentId !== this.identity.orchestrationAgentId) {
|
||||||
|
throw new MosCoordinationAuthorityError(
|
||||||
|
'target_drift',
|
||||||
|
'Mos coordination transport returned an unexpected result target',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result.correlationId !== scope.correlationId) {
|
||||||
|
throw new MosCoordinationAuthorityError(
|
||||||
|
'correlation_drift',
|
||||||
|
'Mos coordination transport returned a mismatched result correlation ID',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIdentity(identity: MosCoordinationIdentity): MosCoordinationIdentity {
|
||||||
|
const interactionAgentId = identity.interactionAgentId.trim();
|
||||||
|
const orchestrationAgentId = identity.orchestrationAgentId.trim();
|
||||||
|
if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) {
|
||||||
|
throw new MosCoordinationAuthorityError(
|
||||||
|
'invalid_identity',
|
||||||
|
'Interaction and orchestration identities are required',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (interactionAgentId === orchestrationAgentId) {
|
||||||
|
throw new MosCoordinationAuthorityError(
|
||||||
|
'invalid_identity',
|
||||||
|
'Interaction and orchestration identities must differ',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze({ interactionAgentId, orchestrationAgentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotRequest(request: MosHandoffRequest): MosHandoffRequest {
|
||||||
|
return Object.freeze({ ...request });
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotScope(scope: CoordinationScope): CoordinationScope {
|
||||||
|
return Object.freeze({ ...scope });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user