Compare commits
9 Commits
feat/feder
...
draft/mosa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2708de55b9 | ||
|
|
6b8c3c5d3a | ||
|
|
d1f69bfd37 | ||
| 4e9e053800 | |||
| 851c67c27b | |||
| 86e106fcc9 | |||
| 67135d3822 | |||
| adb153428b | |||
| c739256a2c |
@@ -7,10 +7,12 @@ import { FederationController } from './federation.controller.js';
|
||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||
import { FederationAuthGuard } from './server/index.js';
|
||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
||||
import { ListController } from './server/verbs/list.controller.js';
|
||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [EnrollmentController, FederationController, CapabilitiesController],
|
||||
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
||||
providers: [
|
||||
AdminGuard,
|
||||
CaService,
|
||||
@@ -19,6 +21,8 @@ import { FederationAuthGuard } from './server/index.js';
|
||||
FederationClientService,
|
||||
QuerySourceService,
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
],
|
||||
exports: [
|
||||
CaService,
|
||||
@@ -27,6 +31,8 @@ import { FederationAuthGuard } from './server/index.js';
|
||||
FederationClientService,
|
||||
QuerySourceService,
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
],
|
||||
})
|
||||
export class FederationModule {}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Unit tests for FederationScopeService (FED-M3-04).
|
||||
*
|
||||
* Coverage:
|
||||
* - resource allowlist deny
|
||||
* - excluded resource deny
|
||||
* - invalid scope deny
|
||||
* - invalid requested limit deny
|
||||
* - native RBAC deny as subjectUserId
|
||||
* - scope/native filter intersection for personal and team rows
|
||||
* - native RBAC personal deny wins over scope include_personal allow/default
|
||||
* - max_rows_per_query cap
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FederationScopeService, type FederationNativeRbacEvaluator } from '../scope.service.js';
|
||||
import type { FederationContext } from '../federation-context.js';
|
||||
|
||||
const GRANT_ID = 'grant-1';
|
||||
const PEER_ID = 'peer-1';
|
||||
const SUBJECT_USER_ID = 'user-1';
|
||||
|
||||
function makeContext(scope: Record<string, unknown>): FederationContext {
|
||||
return {
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
function makeNativeRbac(
|
||||
result: Awaited<ReturnType<FederationNativeRbacEvaluator['evaluateReadAccess']>>,
|
||||
): FederationNativeRbacEvaluator {
|
||||
return {
|
||||
evaluateReadAccess: vi.fn().mockResolvedValue(result),
|
||||
};
|
||||
}
|
||||
|
||||
describe('FederationScopeService', () => {
|
||||
let service: FederationScopeService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new FederationScopeService();
|
||||
});
|
||||
|
||||
it('allows a granted resource and returns a capped query filter', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({
|
||||
resources: ['tasks'],
|
||||
filters: { tasks: { include_teams: ['team-1', 'team-3'], include_personal: true } },
|
||||
max_rows_per_query: 50,
|
||||
}),
|
||||
resource: 'tasks',
|
||||
requestedLimit: 500,
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
filter: {
|
||||
resource: 'tasks',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: true,
|
||||
teamIds: ['team-1'],
|
||||
limit: 50,
|
||||
maxRowsPerQuery: 50,
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).toHaveBeenCalledWith({
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
resource: 'tasks',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults absent resource filters to native RBAC personal and team visibility', async () => {
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: ['notes'], max_rows_per_query: 100 }),
|
||||
resource: 'notes',
|
||||
nativeRbac: makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
filter: {
|
||||
includePersonal: true,
|
||||
teamIds: ['team-1', 'team-2'],
|
||||
limit: 100,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('honors include_personal false even when native RBAC allows personal rows', async () => {
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({
|
||||
resources: ['memory'],
|
||||
filters: { memory: { include_personal: false } },
|
||||
max_rows_per_query: 25,
|
||||
}),
|
||||
resource: 'memory',
|
||||
nativeRbac: makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
filter: {
|
||||
includePersonal: false,
|
||||
teamIds: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leak personal rows when scope allows personal but native RBAC denies personal', async () => {
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({
|
||||
resources: ['tasks'],
|
||||
filters: { tasks: { include_personal: true } },
|
||||
max_rows_per_query: 25,
|
||||
}),
|
||||
resource: 'tasks',
|
||||
nativeRbac: makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: false, teamIds: ['team-1'] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
filter: {
|
||||
includePersonal: false,
|
||||
teamIds: ['team-1'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not widen native RBAC when scope includes teams the user cannot access', async () => {
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({
|
||||
resources: ['tasks'],
|
||||
filters: { tasks: { include_teams: ['team-2'], include_personal: false } },
|
||||
max_rows_per_query: 25,
|
||||
}),
|
||||
resource: 'tasks',
|
||||
nativeRbac: makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: ['team-1'] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
filter: {
|
||||
includePersonal: false,
|
||||
teamIds: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('denies invalid grant scope before RBAC evaluation', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: [], max_rows_per_query: 100 }),
|
||||
resource: 'tasks',
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'invalid_scope',
|
||||
stage: 'scope_parse',
|
||||
statusCode: 400,
|
||||
grantId: GRANT_ID,
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
resource: 'tasks',
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies unsupported resource names before RBAC evaluation', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
||||
resource: 'unknown_resource',
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'invalid_resource',
|
||||
stage: 'resource_allowlist',
|
||||
statusCode: 403,
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies resources explicitly present in excluded_resources before allowlist miss', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({
|
||||
resources: ['tasks'],
|
||||
excluded_resources: ['credentials'],
|
||||
max_rows_per_query: 100,
|
||||
}),
|
||||
resource: 'credentials',
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'resource_excluded',
|
||||
stage: 'resource_exclusion',
|
||||
statusCode: 403,
|
||||
resource: 'credentials',
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies supported resources that are not granted by scope', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
||||
resource: 'notes',
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'resource_not_granted',
|
||||
stage: 'resource_allowlist',
|
||||
statusCode: 403,
|
||||
resource: 'notes',
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies invalid requested row limits before RBAC evaluation', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
||||
resource: 'tasks',
|
||||
requestedLimit: 0,
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'invalid_limit',
|
||||
stage: 'row_cap',
|
||||
statusCode: 400,
|
||||
details: { requestedLimit: 0 },
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies when native RBAC rejects subjectUserId access to the resource', async () => {
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
||||
resource: 'tasks',
|
||||
nativeRbac: makeNativeRbac({
|
||||
allowed: false,
|
||||
reason: 'read:tasks denied',
|
||||
details: { permission: 'tasks:read' },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'native_rbac_denied',
|
||||
stage: 'native_rbac',
|
||||
statusCode: 403,
|
||||
message: 'read:tasks denied',
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
resource: 'tasks',
|
||||
details: { permission: 'tasks:read' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,4 +10,22 @@
|
||||
*/
|
||||
|
||||
export { FederationAuthGuard } from './federation-auth.guard.js';
|
||||
export { FederationScopeService } from './scope.service.js';
|
||||
export type { FederationContext } from './federation-context.js';
|
||||
export type {
|
||||
FederationNativeRbacAccess,
|
||||
FederationNativeRbacAllowedResult,
|
||||
FederationNativeRbacDeniedResult,
|
||||
FederationNativeRbacEvaluator,
|
||||
FederationNativeRbacRequest,
|
||||
FederationNativeRbacResult,
|
||||
FederationScopeAllowedResult,
|
||||
FederationScopeDeniedResult,
|
||||
FederationScopeDenyCode,
|
||||
FederationScopeDenyDetails,
|
||||
FederationScopeDenyReason,
|
||||
FederationScopeDenyStage,
|
||||
FederationScopeEvaluationInput,
|
||||
FederationScopeEvaluationResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from './scope.service.js';
|
||||
|
||||
272
apps/gateway/src/federation/server/scope.service.ts
Normal file
272
apps/gateway/src/federation/server/scope.service.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* FederationScopeService — M3 server-side scope enforcement pipeline.
|
||||
*
|
||||
* Pure trust-boundary service: it validates the grant scope, asks an injected
|
||||
* native RBAC evaluator what the subject user can read locally, intersects that
|
||||
* answer with the federation scope filters, and returns a query filter for the
|
||||
* verb controllers. The service performs no DB calls directly.
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
FEDERATION_RESOURCE_VALUES,
|
||||
type FederationResource,
|
||||
FederationScopeError,
|
||||
parseFederationScope,
|
||||
} from '../scope-schema.js';
|
||||
import type { FederationContext } from './federation-context.js';
|
||||
|
||||
const federationResourceSet: ReadonlySet<string> = new Set<string>(FEDERATION_RESOURCE_VALUES);
|
||||
|
||||
export type FederationScopeDenyStage =
|
||||
| 'scope_parse'
|
||||
| 'resource_allowlist'
|
||||
| 'resource_exclusion'
|
||||
| 'native_rbac'
|
||||
| 'row_cap';
|
||||
|
||||
export type FederationScopeDenyCode =
|
||||
| 'invalid_scope'
|
||||
| 'invalid_resource'
|
||||
| 'resource_not_granted'
|
||||
| 'resource_excluded'
|
||||
| 'native_rbac_denied'
|
||||
| 'invalid_limit';
|
||||
|
||||
export type FederationScopeDenyStatus = 400 | 403;
|
||||
|
||||
export interface FederationScopeDenyDetails {
|
||||
readonly [key: string]: string | number | boolean | readonly string[];
|
||||
}
|
||||
|
||||
export interface FederationScopeDenyReason {
|
||||
readonly code: FederationScopeDenyCode;
|
||||
readonly stage: FederationScopeDenyStage;
|
||||
readonly statusCode: FederationScopeDenyStatus;
|
||||
readonly message: string;
|
||||
readonly grantId: string;
|
||||
readonly peerId: string;
|
||||
readonly subjectUserId: string;
|
||||
readonly resource: string;
|
||||
readonly details?: FederationScopeDenyDetails;
|
||||
}
|
||||
|
||||
export interface FederationNativeRbacRequest {
|
||||
readonly grantId: string;
|
||||
readonly peerId: string;
|
||||
readonly subjectUserId: string;
|
||||
readonly resource: FederationResource;
|
||||
}
|
||||
|
||||
export interface FederationNativeRbacAccess {
|
||||
/** Whether this user may read personal rows for this resource. */
|
||||
readonly includePersonal: boolean;
|
||||
|
||||
/** Team IDs this user may read for this resource under native RBAC. */
|
||||
readonly teamIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface FederationNativeRbacAllowedResult {
|
||||
readonly allowed: true;
|
||||
readonly access: FederationNativeRbacAccess;
|
||||
}
|
||||
|
||||
export interface FederationNativeRbacDeniedResult {
|
||||
readonly allowed: false;
|
||||
readonly reason?: string;
|
||||
readonly details?: FederationScopeDenyDetails;
|
||||
}
|
||||
|
||||
export type FederationNativeRbacResult =
|
||||
| FederationNativeRbacAllowedResult
|
||||
| FederationNativeRbacDeniedResult;
|
||||
|
||||
export interface FederationNativeRbacEvaluator {
|
||||
evaluateReadAccess(request: FederationNativeRbacRequest): Promise<FederationNativeRbacResult>;
|
||||
}
|
||||
|
||||
export interface FederationScopeEvaluationInput {
|
||||
readonly context: FederationContext;
|
||||
readonly resource: string;
|
||||
readonly requestedLimit?: number;
|
||||
readonly nativeRbac: FederationNativeRbacEvaluator;
|
||||
}
|
||||
|
||||
export interface FederationScopeQueryFilter {
|
||||
readonly resource: FederationResource;
|
||||
readonly subjectUserId: string;
|
||||
readonly includePersonal: boolean;
|
||||
readonly teamIds: readonly string[];
|
||||
readonly limit: number;
|
||||
readonly maxRowsPerQuery: number;
|
||||
}
|
||||
|
||||
export interface FederationScopeAllowedResult {
|
||||
readonly allowed: true;
|
||||
readonly filter: FederationScopeQueryFilter;
|
||||
}
|
||||
|
||||
export interface FederationScopeDeniedResult {
|
||||
readonly allowed: false;
|
||||
readonly deny: FederationScopeDenyReason;
|
||||
}
|
||||
|
||||
export type FederationScopeEvaluationResult =
|
||||
| FederationScopeAllowedResult
|
||||
| FederationScopeDeniedResult;
|
||||
|
||||
function isFederationResource(resource: string): resource is FederationResource {
|
||||
return federationResourceSet.has(resource);
|
||||
}
|
||||
|
||||
function uniqueStrings(values: readonly string[]): readonly string[] {
|
||||
return Array.from(new Set<string>(values));
|
||||
}
|
||||
|
||||
function intersectTeamIds(
|
||||
nativeTeamIds: readonly string[],
|
||||
scopedTeamIds: readonly string[] | undefined,
|
||||
): readonly string[] {
|
||||
const uniqueNativeTeamIds = uniqueStrings(nativeTeamIds);
|
||||
|
||||
if (scopedTeamIds === undefined) {
|
||||
return uniqueNativeTeamIds;
|
||||
}
|
||||
|
||||
const nativeSet = new Set<string>(uniqueNativeTeamIds);
|
||||
return uniqueStrings(scopedTeamIds).filter((teamId: string): boolean => nativeSet.has(teamId));
|
||||
}
|
||||
|
||||
function makeDenyReason(params: {
|
||||
readonly code: FederationScopeDenyCode;
|
||||
readonly stage: FederationScopeDenyStage;
|
||||
readonly statusCode?: FederationScopeDenyStatus;
|
||||
readonly message: string;
|
||||
readonly context: FederationContext;
|
||||
readonly resource: string;
|
||||
readonly details?: FederationScopeDenyDetails;
|
||||
}): FederationScopeDeniedResult {
|
||||
return {
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: params.code,
|
||||
stage: params.stage,
|
||||
statusCode: params.statusCode ?? 403,
|
||||
message: params.message,
|
||||
grantId: params.context.grantId,
|
||||
peerId: params.context.peerId,
|
||||
subjectUserId: params.context.subjectUserId,
|
||||
resource: params.resource,
|
||||
...(params.details !== undefined ? { details: params.details } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FederationScopeService {
|
||||
async evaluateAccess(
|
||||
input: FederationScopeEvaluationInput,
|
||||
): Promise<FederationScopeEvaluationResult> {
|
||||
const { context, resource, requestedLimit, nativeRbac } = input;
|
||||
|
||||
let scope: ReturnType<typeof parseFederationScope>;
|
||||
try {
|
||||
scope = parseFederationScope(context.scope);
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof FederationScopeError
|
||||
? 'Federation grant scope is invalid'
|
||||
: 'Federation grant scope could not be parsed';
|
||||
const details = error instanceof Error ? { reason: error.message } : undefined;
|
||||
return makeDenyReason({
|
||||
code: 'invalid_scope',
|
||||
stage: 'scope_parse',
|
||||
statusCode: 400,
|
||||
message,
|
||||
context,
|
||||
resource,
|
||||
...(details !== undefined ? { details } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (!isFederationResource(resource)) {
|
||||
return makeDenyReason({
|
||||
code: 'invalid_resource',
|
||||
stage: 'resource_allowlist',
|
||||
message: 'Requested federation resource is not supported',
|
||||
context,
|
||||
resource,
|
||||
details: { supportedResources: FEDERATION_RESOURCE_VALUES },
|
||||
});
|
||||
}
|
||||
|
||||
if (scope.excluded_resources.includes(resource)) {
|
||||
return makeDenyReason({
|
||||
code: 'resource_excluded',
|
||||
stage: 'resource_exclusion',
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
context,
|
||||
resource,
|
||||
});
|
||||
}
|
||||
|
||||
if (!scope.resources.includes(resource)) {
|
||||
return makeDenyReason({
|
||||
code: 'resource_not_granted',
|
||||
stage: 'resource_allowlist',
|
||||
message: 'Requested federation resource is not granted by scope',
|
||||
context,
|
||||
resource,
|
||||
details: { grantedResources: scope.resources },
|
||||
});
|
||||
}
|
||||
|
||||
if (requestedLimit !== undefined && (!Number.isInteger(requestedLimit) || requestedLimit < 1)) {
|
||||
return makeDenyReason({
|
||||
code: 'invalid_limit',
|
||||
stage: 'row_cap',
|
||||
statusCode: 400,
|
||||
message: 'Requested row limit must be a positive integer',
|
||||
context,
|
||||
resource,
|
||||
details: { requestedLimit },
|
||||
});
|
||||
}
|
||||
|
||||
const nativeResult = await nativeRbac.evaluateReadAccess({
|
||||
grantId: context.grantId,
|
||||
peerId: context.peerId,
|
||||
subjectUserId: context.subjectUserId,
|
||||
resource,
|
||||
});
|
||||
|
||||
if (!nativeResult.allowed) {
|
||||
return makeDenyReason({
|
||||
code: 'native_rbac_denied',
|
||||
stage: 'native_rbac',
|
||||
message: nativeResult.reason ?? 'Subject user is not allowed to read this resource',
|
||||
context,
|
||||
resource,
|
||||
...(nativeResult.details !== undefined ? { details: nativeResult.details } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const scopeFilter = scope.filters?.[resource];
|
||||
const includePersonal =
|
||||
Boolean(scopeFilter?.include_personal ?? true) && nativeResult.access.includePersonal;
|
||||
const teamIds = intersectTeamIds(nativeResult.access.teamIds, scopeFilter?.include_teams);
|
||||
const limit = Math.min(requestedLimit ?? scope.max_rows_per_query, scope.max_rows_per_query);
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
filter: {
|
||||
resource,
|
||||
subjectUserId: context.subjectUserId,
|
||||
includePersonal,
|
||||
teamIds,
|
||||
limit,
|
||||
maxRowsPerQuery: scope.max_rows_per_query,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createPgliteDb,
|
||||
insights,
|
||||
missionTasks,
|
||||
missions,
|
||||
preferences,
|
||||
projects,
|
||||
runPgliteMigrations,
|
||||
teams,
|
||||
users,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
||||
import { FederationListQueryService } from '../list-query.service.js';
|
||||
|
||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
||||
resource: 'tasks',
|
||||
subjectUserId: 'user-1',
|
||||
includePersonal: true,
|
||||
teamIds: [],
|
||||
limit: 2,
|
||||
maxRowsPerQuery: 2,
|
||||
};
|
||||
|
||||
const SUBJECT_USER_ID = 'fed-m3-05-subject';
|
||||
const OTHER_USER_ID = 'fed-m3-05-other';
|
||||
const TEAM_ID = '05000000-0000-4000-8000-000000000001';
|
||||
const UNAUTHORIZED_TEAM_ID = '05000000-0000-4000-8000-000000000002';
|
||||
const PERSONAL_PROJECT_ID = '05000000-0000-4000-8000-000000000101';
|
||||
const TEAM_PROJECT_ID = '05000000-0000-4000-8000-000000000102';
|
||||
const UNAUTHORIZED_PROJECT_ID = '05000000-0000-4000-8000-000000000103';
|
||||
const PERSONAL_MISSION_ID = '05000000-0000-4000-8000-000000000201';
|
||||
const TEAM_MISSION_ID = '05000000-0000-4000-8000-000000000202';
|
||||
const UNAUTHORIZED_MISSION_ID = '05000000-0000-4000-8000-000000000203';
|
||||
const SUBJECT_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000301';
|
||||
const OTHER_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000302';
|
||||
const SUBJECT_PERSONAL_NOTE_ID = '05000000-0000-4000-8000-000000000303';
|
||||
const SUBJECT_UNAUTHORIZED_NOTE_ID = '05000000-0000-4000-8000-000000000304';
|
||||
const INSIGHT_ONE_ID = '05000000-0000-4000-8000-000000000401';
|
||||
const INSIGHT_TWO_ID = '05000000-0000-4000-8000-000000000402';
|
||||
const PREFERENCE_ONE_ID = '05000000-0000-4000-8000-000000000501';
|
||||
const PREFERENCE_TWO_ID = '05000000-0000-4000-8000-000000000502';
|
||||
|
||||
let dbHandle: DbHandle | undefined;
|
||||
|
||||
function makeService() {
|
||||
return new FederationListQueryService({} as Db);
|
||||
}
|
||||
|
||||
function makeDbService() {
|
||||
if (!dbHandle) {
|
||||
throw new Error('test DB not initialized');
|
||||
}
|
||||
return new FederationListQueryService(dbHandle.db);
|
||||
}
|
||||
|
||||
async function seedNotesFixture() {
|
||||
if (!dbHandle) {
|
||||
throw new Error('test DB not initialized');
|
||||
}
|
||||
|
||||
await dbHandle.db.insert(users).values([
|
||||
{
|
||||
id: SUBJECT_USER_ID,
|
||||
name: 'Federation Subject',
|
||||
email: `${SUBJECT_USER_ID}@example.test`,
|
||||
emailVerified: false,
|
||||
},
|
||||
{
|
||||
id: OTHER_USER_ID,
|
||||
name: 'Federation Other',
|
||||
email: `${OTHER_USER_ID}@example.test`,
|
||||
emailVerified: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(teams).values([
|
||||
{
|
||||
id: TEAM_ID,
|
||||
name: 'FED-M3-05 Team',
|
||||
slug: 'fed-m3-05-team',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
managerId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_TEAM_ID,
|
||||
name: 'FED-M3-05 Unauthorized Team',
|
||||
slug: 'fed-m3-05-unauthorized-team',
|
||||
ownerId: OTHER_USER_ID,
|
||||
managerId: OTHER_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(projects).values([
|
||||
{
|
||||
id: PERSONAL_PROJECT_ID,
|
||||
name: 'FED-M3-05 Personal Project',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
ownerType: 'user',
|
||||
},
|
||||
{
|
||||
id: TEAM_PROJECT_ID,
|
||||
name: 'FED-M3-05 Team Project',
|
||||
teamId: TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_PROJECT_ID,
|
||||
name: 'FED-M3-05 Unauthorized Project',
|
||||
teamId: UNAUTHORIZED_TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(missions).values([
|
||||
{
|
||||
id: PERSONAL_MISSION_ID,
|
||||
name: 'FED-M3-05 Personal Mission',
|
||||
projectId: PERSONAL_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: TEAM_MISSION_ID,
|
||||
name: 'FED-M3-05 Team Mission',
|
||||
projectId: TEAM_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_MISSION_ID,
|
||||
name: 'FED-M3-05 Unauthorized Mission',
|
||||
projectId: UNAUTHORIZED_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(missionTasks).values([
|
||||
{
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note on team mission',
|
||||
createdAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: OTHER_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
userId: OTHER_USER_ID,
|
||||
notes: 'other user note on team mission',
|
||||
createdAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||
missionId: PERSONAL_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note on personal mission',
|
||||
createdAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||
missionId: UNAUTHORIZED_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note outside grant-visible missions',
|
||||
createdAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
const memoryCreatedAt = new Date('2026-06-24T05:00:00.000Z');
|
||||
await dbHandle.db.insert(insights).values([
|
||||
{
|
||||
id: INSIGHT_ONE_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
content: 'first insight',
|
||||
source: 'agent',
|
||||
createdAt: memoryCreatedAt,
|
||||
updatedAt: memoryCreatedAt,
|
||||
},
|
||||
{
|
||||
id: INSIGHT_TWO_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
content: 'second insight',
|
||||
source: 'agent',
|
||||
createdAt: memoryCreatedAt,
|
||||
updatedAt: memoryCreatedAt,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(preferences).values([
|
||||
{
|
||||
id: PREFERENCE_ONE_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
key: 'fed-m3-05-pref-1',
|
||||
value: { enabled: true },
|
||||
createdAt: memoryCreatedAt,
|
||||
updatedAt: memoryCreatedAt,
|
||||
},
|
||||
{
|
||||
id: PREFERENCE_TWO_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
key: 'fed-m3-05-pref-2',
|
||||
value: { enabled: false },
|
||||
createdAt: memoryCreatedAt,
|
||||
updatedAt: memoryCreatedAt,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function stubRows(
|
||||
service: FederationListQueryService,
|
||||
...pages: Array<Array<Record<string, unknown>>>
|
||||
) {
|
||||
const mock = vi.fn();
|
||||
for (const page of pages) {
|
||||
mock.mockResolvedValueOnce(page);
|
||||
}
|
||||
(
|
||||
service as unknown as {
|
||||
listAllRows: (
|
||||
_filter: FederationScopeQueryFilter,
|
||||
_rowLimit: number,
|
||||
_cursor: unknown,
|
||||
) => Promise<Array<Record<string, unknown>>>;
|
||||
}
|
||||
).listAllRows = mock;
|
||||
return mock;
|
||||
}
|
||||
|
||||
describe('FederationListQueryService', () => {
|
||||
beforeAll(async () => {
|
||||
dbHandle = createPgliteDb(`memory://fed-m3-05-list-${Date.now()}`);
|
||||
await runPgliteMigrations(dbHandle);
|
||||
await seedNotesFixture();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await dbHandle?.close();
|
||||
dbHandle = undefined;
|
||||
});
|
||||
|
||||
it('denies sensitive resources in native RBAC for M3 list reads', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'credentials',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: 'credentials federation list access is not implemented in M3',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows personal memory reads without requiring team lookup', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'memory',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('applies the scope row cap and returns an opaque next cursor when truncated', async () => {
|
||||
const service = makeService();
|
||||
const listAllRows = stubRows(
|
||||
service,
|
||||
[
|
||||
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
|
||||
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
|
||||
{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') },
|
||||
],
|
||||
[{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
|
||||
);
|
||||
|
||||
const firstPage = await service.list({ filter: TASK_FILTER });
|
||||
|
||||
expect(firstPage).toEqual({
|
||||
items: [
|
||||
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
|
||||
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
|
||||
],
|
||||
truncated: true,
|
||||
nextCursor: expect.any(String),
|
||||
});
|
||||
|
||||
expect(listAllRows).toHaveBeenNthCalledWith(1, TASK_FILTER, 3, undefined);
|
||||
|
||||
const secondPage = await service.list({ filter: TASK_FILTER, cursor: firstPage.nextCursor });
|
||||
expect(secondPage).toEqual({
|
||||
items: [{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
|
||||
truncated: false,
|
||||
});
|
||||
expect(listAllRows).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
TASK_FILTER,
|
||||
3,
|
||||
expect.objectContaining({ id: '2' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid cursors instead of falling back to the first page', async () => {
|
||||
const service = makeService();
|
||||
stubRows(service, [{ id: '1' }]);
|
||||
|
||||
await expect(service.list({ filter: TASK_FILTER, cursor: 'not-base64-json' })).rejects.toThrow(
|
||||
'Invalid federation list cursor',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when a truncated page cannot encode a resumable cursor', async () => {
|
||||
const service = makeService();
|
||||
stubRows(service, [
|
||||
{ id: '2', createdAt: 'not-a-date' },
|
||||
{ id: '1', createdAt: 'not-a-date' },
|
||||
]);
|
||||
|
||||
await expect(service.list({ filter: { ...TASK_FILTER, limit: 1 } })).rejects.toThrow(
|
||||
'Federation list cursor cannot be encoded',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on unsupported resources instead of crashing pagination', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.list({
|
||||
filter: {
|
||||
...TASK_FILTER,
|
||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('Unsupported federation list resource');
|
||||
});
|
||||
|
||||
it('does not leak another user mission task notes through team-scoped note reads', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
const result = await service.list({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 10,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const ids = result.items.map((item) => item['id']);
|
||||
expect(ids).toEqual([SUBJECT_TEAM_NOTE_ID]);
|
||||
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
|
||||
});
|
||||
|
||||
it('does not return subject personal mission task notes when includePersonal is false', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
const result = await service.list({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 10,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.items.map((item) => item['id'])).not.toContain(SUBJECT_PERSONAL_NOTE_ID);
|
||||
});
|
||||
|
||||
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
const result = await service.list({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: true,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 10,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const ids = result.items.map((item) => item['id']);
|
||||
expect(ids).toContain(SUBJECT_PERSONAL_NOTE_ID);
|
||||
expect(ids).toContain(SUBJECT_TEAM_NOTE_ID);
|
||||
expect(ids).not.toContain(SUBJECT_UNAUTHORIZED_NOTE_ID);
|
||||
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
|
||||
});
|
||||
|
||||
it('paginates memory deterministically across insights and preferences', async () => {
|
||||
const service = makeDbService();
|
||||
const filter: FederationScopeQueryFilter = {
|
||||
resource: 'memory',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: true,
|
||||
teamIds: [],
|
||||
limit: 2,
|
||||
maxRowsPerQuery: 2,
|
||||
};
|
||||
|
||||
const firstPage = await service.list({ filter });
|
||||
const secondPage = await service.list({ filter, cursor: firstPage.nextCursor });
|
||||
const firstPageIds = firstPage.items.map((item) => item['id']);
|
||||
const secondPageIds = secondPage.items.map((item) => item['id']);
|
||||
const allIds = [...firstPageIds, ...secondPageIds];
|
||||
|
||||
expect(firstPage).toMatchObject({ truncated: true, nextCursor: expect.any(String) });
|
||||
expect(firstPageIds).toEqual([INSIGHT_TWO_ID, INSIGHT_ONE_ID]);
|
||||
expect(secondPageIds).toEqual([PREFERENCE_TWO_ID, PREFERENCE_ONE_ID]);
|
||||
expect(new Set(allIds).size).toBe(allIds.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'reflect-metadata';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
||||
import type {
|
||||
FederationScopeEvaluationResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../../scope.service.js';
|
||||
import { ListController } from '../list.controller.js';
|
||||
import type { FederationListQueryResult } from '../list-query.service.js';
|
||||
|
||||
const FEDERATION_CONTEXT = {
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
scope: { resources: ['tasks'], max_rows_per_query: 25 },
|
||||
};
|
||||
|
||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
||||
resource: 'tasks',
|
||||
subjectUserId: 'user-1',
|
||||
includePersonal: true,
|
||||
teamIds: ['team-1'],
|
||||
limit: 10,
|
||||
maxRowsPerQuery: 25,
|
||||
};
|
||||
|
||||
function makeRequest(): FastifyRequest {
|
||||
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
|
||||
}
|
||||
|
||||
function allowedScope(
|
||||
filter: FederationScopeQueryFilter = TASK_FILTER,
|
||||
): FederationScopeEvaluationResult {
|
||||
return { allowed: true, filter };
|
||||
}
|
||||
|
||||
function makeController(opts?: {
|
||||
scopeResult?: FederationScopeEvaluationResult;
|
||||
queryResult?: FederationListQueryResult;
|
||||
}) {
|
||||
const scope = {
|
||||
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
|
||||
};
|
||||
const query = {
|
||||
evaluateReadAccess: vi.fn(),
|
||||
list: vi.fn().mockResolvedValue(
|
||||
opts?.queryResult ?? {
|
||||
items: [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
controller: new ListController(scope as never, query as never),
|
||||
scope,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ListController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('declares POST /api/federation/v1/list/:resource protected only by FederationAuthGuard', () => {
|
||||
expect(Reflect.getMetadata('path', ListController)).toBe('api/federation/v1/list');
|
||||
expect(Reflect.getMetadata('path', ListController.prototype.list)).toBe(':resource');
|
||||
expect(Reflect.getMetadata('method', ListController.prototype.list)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata('__guards__', ListController)).toEqual([FederationAuthGuard]);
|
||||
});
|
||||
|
||||
it('runs AuthGuard context through ScopeService and returns local-source tagged rows', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
const response = await controller.list('tasks', makeRequest(), { limit: 10 });
|
||||
|
||||
expect(scope.evaluateAccess).toHaveBeenCalledWith({
|
||||
context: FEDERATION_CONTEXT,
|
||||
resource: 'tasks',
|
||||
requestedLimit: 10,
|
||||
nativeRbac: query,
|
||||
});
|
||||
expect(query.list).toHaveBeenCalledWith({ filter: TASK_FILTER, cursor: undefined });
|
||||
expect(response).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
_source: 'local',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves pagination metadata when row cap truncates the query layer result', async () => {
|
||||
const { controller } = makeController({
|
||||
queryResult: {
|
||||
items: [{ id: 'task-1' }],
|
||||
nextCursor: 'cursor-2',
|
||||
truncated: true,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await controller.list('tasks', makeRequest(), { cursor: 'cursor-1' });
|
||||
|
||||
expect(response).toEqual({
|
||||
items: [{ id: 'task-1', _source: 'local' }],
|
||||
nextCursor: 'cursor-2',
|
||||
_truncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a federation error envelope when auth guard context is missing', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(
|
||||
controller.list('tasks', {} as unknown as FastifyRequest, {}),
|
||||
).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'unauthorized',
|
||||
message: 'Federation context missing',
|
||||
},
|
||||
},
|
||||
status: 401,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
||||
const { controller, query } = makeController({
|
||||
scopeResult: {
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'resource_excluded',
|
||||
stage: 'resource_exclusion',
|
||||
statusCode: 403,
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'credentials',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(controller.list('credentials', makeRequest(), {})).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
expect(query.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed request body fields before querying storage', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(controller.list('tasks', makeRequest(), { cursor: 123 })).rejects.toMatchObject({
|
||||
response: { error: { code: 'invalid_request' } },
|
||||
status: 400,
|
||||
});
|
||||
await expect(controller.list('tasks', makeRequest(), { limit: false })).rejects.toMatchObject({
|
||||
response: { error: { code: 'invalid_request' } },
|
||||
status: 400,
|
||||
});
|
||||
await expect(controller.list('tasks', makeRequest(), { limit: 'abc' })).rejects.toMatchObject({
|
||||
response: { error: { code: 'invalid_request' } },
|
||||
status: 400,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
408
apps/gateway/src/federation/server/verbs/list-query.service.ts
Normal file
408
apps/gateway/src/federation/server/verbs/list-query.service.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Federation list query layer (FED-M3-05).
|
||||
*
|
||||
* Read-only DB adapter used by ListController after FederationAuthGuard and
|
||||
* FederationScopeService have established the subject user, allowed resource,
|
||||
* native-RBAC intersection, and row cap. Audit writes are intentionally
|
||||
* deferred to M4.
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
insights,
|
||||
isNotNull,
|
||||
lt,
|
||||
missionTasks,
|
||||
missions,
|
||||
or,
|
||||
preferences,
|
||||
projects,
|
||||
tasks,
|
||||
teamMembers,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import type {
|
||||
FederationNativeRbacEvaluator,
|
||||
FederationNativeRbacRequest,
|
||||
FederationNativeRbacResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../scope.service.js';
|
||||
import { DB } from '../../../database/database.module.js';
|
||||
|
||||
export interface FederationListQueryRequest {
|
||||
readonly filter: FederationScopeQueryFilter;
|
||||
readonly cursor?: string;
|
||||
}
|
||||
|
||||
export interface FederationListQueryResult<T extends object = Record<string, unknown>> {
|
||||
readonly items: T[];
|
||||
readonly nextCursor?: string;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
type CursorSource = 'insights' | 'preferences';
|
||||
const CURSOR_SOURCE = Symbol('federationCursorSource');
|
||||
|
||||
type RowObject = Record<string, unknown> & { readonly [CURSOR_SOURCE]?: CursorSource };
|
||||
|
||||
interface KeysetCursor {
|
||||
readonly createdAt: Date;
|
||||
readonly id: string;
|
||||
readonly source?: CursorSource;
|
||||
}
|
||||
|
||||
function encodeCursor(row: RowObject): string {
|
||||
const createdAt = row['createdAt'];
|
||||
const id = row['id'];
|
||||
if (!(createdAt instanceof Date) || typeof id !== 'string') {
|
||||
throw new Error('Federation list cursor cannot be encoded');
|
||||
}
|
||||
|
||||
const source = row[CURSOR_SOURCE];
|
||||
return Buffer.from(
|
||||
JSON.stringify({ createdAt: createdAt.toISOString(), id, ...(source ? { source } : {}) }),
|
||||
'utf8',
|
||||
).toString('base64url');
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string | undefined): KeysetCursor | undefined {
|
||||
if (cursor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new Error('cursor must be an object');
|
||||
}
|
||||
|
||||
const { createdAt, id, source } = parsed as {
|
||||
createdAt?: unknown;
|
||||
id?: unknown;
|
||||
source?: unknown;
|
||||
};
|
||||
if (typeof createdAt !== 'string' || typeof id !== 'string' || id.length === 0) {
|
||||
throw new Error('cursor is missing createdAt or id');
|
||||
}
|
||||
if (source !== undefined && source !== 'insights' && source !== 'preferences') {
|
||||
throw new Error('cursor source is invalid');
|
||||
}
|
||||
|
||||
const date = new Date(createdAt);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error('cursor createdAt is invalid');
|
||||
}
|
||||
|
||||
return { createdAt: date, id, ...(source ? { source } : {}) };
|
||||
} catch {
|
||||
throw new Error('Invalid federation list cursor');
|
||||
}
|
||||
}
|
||||
|
||||
function paginate<T extends RowObject>(rows: T[], limit: number): FederationListQueryResult<T> {
|
||||
const page = rows.slice(0, limit);
|
||||
const hasMore = rows.length > limit;
|
||||
const nextCursor = hasMore ? encodeCursor(page[page.length - 1] ?? {}) : undefined;
|
||||
|
||||
return {
|
||||
items: page,
|
||||
truncated: hasMore,
|
||||
...(nextCursor !== undefined ? { nextCursor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function markCursorSource<T extends RowObject>(row: T, source: CursorSource): T {
|
||||
Object.defineProperty(row, CURSOR_SOURCE, {
|
||||
value: source,
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function sortRows(rows: RowObject[]): RowObject[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
const aTime = a['createdAt'] instanceof Date ? a['createdAt'].getTime() : 0;
|
||||
const bTime = b['createdAt'] instanceof Date ? b['createdAt'].getTime() : 0;
|
||||
if (aTime !== bTime) {
|
||||
return bTime - aTime;
|
||||
}
|
||||
return String(b['id'] ?? '').localeCompare(String(a['id'] ?? ''));
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FederationListQueryService implements FederationNativeRbacEvaluator {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async evaluateReadAccess(
|
||||
request: FederationNativeRbacRequest,
|
||||
): Promise<FederationNativeRbacResult> {
|
||||
if (request.resource === 'credentials' || request.resource === 'api_keys') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `${request.resource} federation list access is not implemented in M3`,
|
||||
details: { resource: request.resource },
|
||||
};
|
||||
}
|
||||
|
||||
if (request.resource === 'memory') {
|
||||
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
|
||||
}
|
||||
|
||||
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
|
||||
return { allowed: true, access: { includePersonal: true, teamIds } };
|
||||
}
|
||||
|
||||
async list<T extends RowObject = RowObject>(
|
||||
request: FederationListQueryRequest,
|
||||
): Promise<FederationListQueryResult<T>> {
|
||||
const cursor = decodeCursor(request.cursor);
|
||||
const rows = await this.listAllRows(request.filter, request.filter.limit + 1, cursor);
|
||||
return paginate(rows as T[], request.filter.limit);
|
||||
}
|
||||
|
||||
private async listAllRows(
|
||||
filter: FederationScopeQueryFilter,
|
||||
rowLimit: number,
|
||||
cursor: KeysetCursor | undefined,
|
||||
): Promise<RowObject[]> {
|
||||
switch (filter.resource) {
|
||||
case 'tasks':
|
||||
return this.listTasks(filter, rowLimit, cursor);
|
||||
case 'notes':
|
||||
return this.listNotes(filter, rowLimit, cursor);
|
||||
case 'memory':
|
||||
return this.listMemory(filter, rowLimit, cursor);
|
||||
case 'credentials':
|
||||
case 'api_keys':
|
||||
return [];
|
||||
default:
|
||||
throw new Error(`Unsupported federation list resource: ${String(filter.resource)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, subjectUserId));
|
||||
|
||||
return rows.map((row) => row.teamId);
|
||||
}
|
||||
|
||||
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
|
||||
const clauses = [];
|
||||
if (filter.includePersonal) {
|
||||
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
|
||||
}
|
||||
if (filter.teamIds.length > 0) {
|
||||
clauses.push(
|
||||
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
|
||||
);
|
||||
}
|
||||
|
||||
if (clauses.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
|
||||
if (projectIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({ id: missions.id })
|
||||
.from(missions)
|
||||
.where(inArray(missions.projectId, [...projectIds]));
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async listTasks(
|
||||
filter: FederationScopeQueryFilter,
|
||||
rowLimit: number,
|
||||
cursor: KeysetCursor | undefined,
|
||||
): Promise<RowObject[]> {
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
const clauses = [];
|
||||
|
||||
if (projectIds.length > 0) {
|
||||
clauses.push(inArray(tasks.projectId, projectIds));
|
||||
}
|
||||
if (missionIds.length > 0) {
|
||||
clauses.push(inArray(tasks.missionId, missionIds));
|
||||
}
|
||||
|
||||
if (clauses.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const scopeClause = clauses.length === 1 ? clauses[0] : or(...clauses);
|
||||
const cursorClause = cursor
|
||||
? or(
|
||||
lt(tasks.createdAt, cursor.createdAt),
|
||||
and(eq(tasks.createdAt, cursor.createdAt), lt(tasks.id, cursor.id)),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: tasks.id,
|
||||
title: tasks.title,
|
||||
description: tasks.description,
|
||||
status: tasks.status,
|
||||
priority: tasks.priority,
|
||||
projectId: tasks.projectId,
|
||||
missionId: tasks.missionId,
|
||||
assignee: tasks.assignee,
|
||||
tags: tasks.tags,
|
||||
dueDate: tasks.dueDate,
|
||||
metadata: tasks.metadata,
|
||||
createdAt: tasks.createdAt,
|
||||
updatedAt: tasks.updatedAt,
|
||||
})
|
||||
.from(tasks)
|
||||
.where(and(scopeClause, cursorClause))
|
||||
.orderBy(desc(tasks.createdAt), desc(tasks.id))
|
||||
.limit(rowLimit);
|
||||
|
||||
return sortRows(rows as RowObject[]);
|
||||
}
|
||||
|
||||
private async listNotes(
|
||||
filter: FederationScopeQueryFilter,
|
||||
rowLimit: number,
|
||||
cursor: KeysetCursor | undefined,
|
||||
): Promise<RowObject[]> {
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
|
||||
if (missionIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
||||
// Team visibility can narrow the mission set, but it must never widen the
|
||||
// query to other users' mission task notes.
|
||||
const scopeClause = and(
|
||||
eq(missionTasks.userId, filter.subjectUserId),
|
||||
inArray(missionTasks.missionId, missionIds),
|
||||
);
|
||||
const cursorClause = cursor
|
||||
? or(
|
||||
lt(missionTasks.createdAt, cursor.createdAt),
|
||||
and(eq(missionTasks.createdAt, cursor.createdAt), lt(missionTasks.id, cursor.id)),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: missionTasks.id,
|
||||
missionId: missionTasks.missionId,
|
||||
taskId: missionTasks.taskId,
|
||||
status: missionTasks.status,
|
||||
content: missionTasks.notes,
|
||||
createdAt: missionTasks.createdAt,
|
||||
updatedAt: missionTasks.updatedAt,
|
||||
})
|
||||
.from(missionTasks)
|
||||
.where(and(scopeClause, cursorClause, isNotNull(missionTasks.notes)))
|
||||
.orderBy(desc(missionTasks.createdAt), desc(missionTasks.id))
|
||||
.limit(rowLimit);
|
||||
|
||||
return sortRows(rows.filter((row) => row.content !== '') as RowObject[]);
|
||||
}
|
||||
|
||||
private async listMemory(
|
||||
filter: FederationScopeQueryFilter,
|
||||
rowLimit: number,
|
||||
cursor: KeysetCursor | undefined,
|
||||
): Promise<RowObject[]> {
|
||||
if (!filter.includePersonal) {
|
||||
return [];
|
||||
}
|
||||
if (cursor && cursor.source === undefined) {
|
||||
throw new Error('Invalid federation list cursor');
|
||||
}
|
||||
|
||||
const rows: RowObject[] = [];
|
||||
|
||||
// Memory spans two physical tables. To keep pagination deterministic and
|
||||
// resumable without a SQL UNION, M3 emits a fixed block order: all insights
|
||||
// first, then preferences. The opaque cursor records which table produced
|
||||
// the boundary row, so the next page never re-applies one table's keyset to
|
||||
// the other table (which could duplicate/skip rows at equal timestamps).
|
||||
if (cursor?.source !== 'preferences') {
|
||||
const insightCursorClause = cursor
|
||||
? or(
|
||||
lt(insights.createdAt, cursor.createdAt),
|
||||
and(eq(insights.createdAt, cursor.createdAt), lt(insights.id, cursor.id)),
|
||||
)
|
||||
: undefined;
|
||||
const insightRows = await this.db
|
||||
.select({
|
||||
id: insights.id,
|
||||
kind: insights.source,
|
||||
content: insights.content,
|
||||
category: insights.category,
|
||||
relevanceScore: insights.relevanceScore,
|
||||
metadata: insights.metadata,
|
||||
createdAt: insights.createdAt,
|
||||
updatedAt: insights.updatedAt,
|
||||
})
|
||||
.from(insights)
|
||||
.where(and(eq(insights.userId, filter.subjectUserId), insightCursorClause))
|
||||
.orderBy(desc(insights.createdAt), desc(insights.id))
|
||||
.limit(rowLimit);
|
||||
|
||||
rows.push(...(insightRows as RowObject[]).map((row) => markCursorSource(row, 'insights')));
|
||||
}
|
||||
|
||||
const remaining = rowLimit - rows.length;
|
||||
if (remaining <= 0) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const preferenceCursorClause =
|
||||
cursor?.source === 'preferences'
|
||||
? or(
|
||||
lt(preferences.createdAt, cursor.createdAt),
|
||||
and(eq(preferences.createdAt, cursor.createdAt), lt(preferences.id, cursor.id)),
|
||||
)
|
||||
: undefined;
|
||||
const preferenceRows = await this.db
|
||||
.select({
|
||||
id: preferences.id,
|
||||
kind: preferences.category,
|
||||
key: preferences.key,
|
||||
value: preferences.value,
|
||||
source: preferences.source,
|
||||
mutable: preferences.mutable,
|
||||
createdAt: preferences.createdAt,
|
||||
updatedAt: preferences.updatedAt,
|
||||
})
|
||||
.from(preferences)
|
||||
.where(and(eq(preferences.userId, filter.subjectUserId), preferenceCursorClause))
|
||||
.orderBy(desc(preferences.createdAt), desc(preferences.id))
|
||||
.limit(remaining);
|
||||
|
||||
rows.push(
|
||||
...(preferenceRows as RowObject[]).map((row) => markCursorSource(row, 'preferences')),
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
147
apps/gateway/src/federation/server/verbs/list.controller.ts
Normal file
147
apps/gateway/src/federation/server/verbs/list.controller.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Federation list verb (FED-M3-05).
|
||||
*
|
||||
* POST /api/federation/v1/list/:resource
|
||||
*
|
||||
* Pipeline: FederationAuthGuard attaches the active grant context, then
|
||||
* FederationScopeService enforces grant scope + native RBAC intersection, then
|
||||
* the read-only query layer returns capped rows tagged with `_source`. Read
|
||||
* audit-log writes are deferred to M4; this controller does not persist request
|
||||
* or response bodies.
|
||||
*/
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import {
|
||||
FederationInvalidRequestError,
|
||||
FederationScopeViolationError,
|
||||
FederationUnauthorizedError,
|
||||
SOURCE_LOCAL,
|
||||
tagWithSource,
|
||||
type FederationListResponse,
|
||||
type SourceTag,
|
||||
} from '@mosaicstack/types';
|
||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
||||
import '../federation-context.js';
|
||||
import { FederationScopeService } from '../scope.service.js';
|
||||
import { FederationListQueryService } from './list-query.service.js';
|
||||
|
||||
interface FederationListRequestBody {
|
||||
readonly limit?: unknown;
|
||||
readonly cursor?: unknown;
|
||||
}
|
||||
|
||||
type FederatedRow = Record<string, unknown> & SourceTag;
|
||||
|
||||
function parseLimit(body: FederationListRequestBody | undefined): number | undefined {
|
||||
if (body?.limit === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed =
|
||||
typeof body.limit === 'number'
|
||||
? body.limit
|
||||
: typeof body.limit === 'string' && body.limit.trim().length > 0
|
||||
? Number(body.limit)
|
||||
: Number.NaN;
|
||||
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new HttpException(
|
||||
new FederationInvalidRequestError(
|
||||
'Federation list limit must be a positive integer',
|
||||
).toEnvelope(),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseCursor(body: FederationListRequestBody | undefined): string | undefined {
|
||||
if (body?.cursor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof body.cursor === 'string') {
|
||||
return body.cursor;
|
||||
}
|
||||
throw new HttpException(
|
||||
new FederationInvalidRequestError('Federation list cursor must be a string').toEnvelope(),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
@Controller('api/federation/v1/list')
|
||||
@UseGuards(FederationAuthGuard)
|
||||
export class ListController {
|
||||
constructor(
|
||||
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
|
||||
@Inject(FederationListQueryService) private readonly query: FederationListQueryService,
|
||||
) {}
|
||||
|
||||
@Post(':resource')
|
||||
async list(
|
||||
@Param('resource') resource: string,
|
||||
@Req() request: FastifyRequest,
|
||||
@Body() body?: FederationListRequestBody,
|
||||
): Promise<FederationListResponse<FederatedRow>> {
|
||||
if (!request.federationContext) {
|
||||
throw new HttpException(
|
||||
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const requestedLimit = parseLimit(body);
|
||||
const cursor = parseCursor(body);
|
||||
const scopeResult = await this.scope.evaluateAccess({
|
||||
context: request.federationContext,
|
||||
resource,
|
||||
requestedLimit,
|
||||
nativeRbac: this.query,
|
||||
});
|
||||
|
||||
if (!scopeResult.allowed) {
|
||||
const ErrorClass =
|
||||
scopeResult.deny.statusCode === 400
|
||||
? FederationInvalidRequestError
|
||||
: FederationScopeViolationError;
|
||||
throw new HttpException(
|
||||
new ErrorClass(scopeResult.deny.message, scopeResult.deny).toEnvelope(),
|
||||
scopeResult.deny.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<FederationListQueryService['list']>>;
|
||||
try {
|
||||
result = await this.query.list({ filter: scopeResult.filter, cursor });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.message === 'Invalid federation list cursor') {
|
||||
throw new HttpException(
|
||||
new FederationInvalidRequestError('Federation list cursor is invalid').toEnvelope(),
|
||||
400,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const response: FederationListResponse<FederatedRow> = {
|
||||
items: tagWithSource(result.items, SOURCE_LOCAL),
|
||||
};
|
||||
if (result.nextCursor !== undefined) {
|
||||
response.nextCursor = result.nextCursor;
|
||||
}
|
||||
if (result.truncated) {
|
||||
response._truncated = true;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
61
docs/fleet/proposals/mosaic-platform-prd/DEBATE-FINDINGS.md
Normal file
61
docs/fleet/proposals/mosaic-platform-prd/DEBATE-FINDINGS.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Debate Findings & Dispositions — Mosaic Platform PRD
|
||||
|
||||
> **Convener:** mos-claude-1 · **Date:** 2026-07-09 · **Panel:** 9 personas × 2 rounds (8 Claude lenses + independent Codex runtime), 20 agents, ~1.05M tokens
|
||||
> **Artifacts:** `jarvis-brain:docs/scratchpads/mosaic-platform-prd-debate/` (THREAD.md — full transcript · SYNTHESIS.md — Principal-Engineer close-out)
|
||||
> **Mandate (Jason, 2026-07-09):** "debate and make judgment calls." D1–D12 were held fixed; the panel attacked only the implementing structure. Dispositions below are the convener's judgment calls, folded into the sibling docs in this directory. Items marked **OPEN — Jason** need his call at ratification.
|
||||
|
||||
## How to read this
|
||||
|
||||
Every synthesis finding (SYNTHESIS.md §1, items 1–24) is dispositioned here. **Accepted** findings are folded into the PRDs/YAML as inline fixes or "Debate-accepted deltas" rows; this file is the traceability record. Severity labels are the panel's (P0 blocker → P3 note).
|
||||
|
||||
## P0 findings — all accepted, folded inline
|
||||
|
||||
| # | Finding | Disposition |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Storage-authority contradiction (X2 "relational + flat-file backends" vs Q "Postgres sole record") | **Accepted.** X2 rewritten: Postgres authoritative for all product entities; flat-file backend is a derived, regenerated, **read-only projection**. |
|
||||
| 2 | Phase-1 Jarvis executes external PA ops before the relay exists (J2 vs NS-11) | **Accepted.** J2 split: **J2a** workspace-internal entities (phase 1) / **J2b** external integrations (phase 2, `depends_on: [P2]`). Phase-1 has no external credential path at all. |
|
||||
| 3 | `phase` vs `depends_on` disagree; dispatcher obedience undefined | **Accepted.** Phases encoded as real DAG edges (`X2 depends_on [J2a, P2]`, J2b gate above); sandbox dispatch-test added to ratification checklist (README Gate Zero §). |
|
||||
| 4 | Four load-bearing upstream artifacts unverified (memory subsystem, Hermes-MCP tool equivalents, push pipeline, wake/event router) | **Accepted.** README gains **Gate Zero** (pre-ratification artifact audit); presumed-MISSING rows get goal cards now: **M1** (memory subsystem), **J6** (event/wake router), **K3** (push pipeline). Parity map's MCP row re-pointed at concrete deliverables. |
|
||||
| 5 | X-R4 migration manifest wrong about its own source tree (phantom `tickets.json`, unlisted dirs, six divergent memory stores, live Vikunja sync unmapped) | **Accepted.** X-R4 rewritten: migrator stage 1 = machine-generated source census (incl. untracked paths + all memory stores), per-path disposition, any `unknown` blocks; re-point list generated from `tools/sync_*.py`; Vikunja disposition line added to X-R6. |
|
||||
|
||||
## P1 findings — all accepted
|
||||
|
||||
| # | Finding | Disposition (folded as deltas) |
|
||||
| --- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 6 | Events have no provider join key; re-point duplicates the calendar | **Accepted.** Provider-sourced events migrate **from the provider**; flat files supply only brain-native events. DST/recurrence fixture is the AC (X-R6 delta). |
|
||||
| 7 | Approval pipeline has no state machine (double-execute / approve≠execute) | **Accepted.** P deltas: CAS on one durable row, terminal states, consumed-event dedupe table (shared substrate with bridge + wake dedupe), payload persisted at request time, poll/ack outcomes, per-capability retry class, staleness bound, `approved_unexecuted` alarm, re-surface = re-**prepare** with machine diff. |
|
||||
| 8 | "Cannot bypass by construction" false: agent-readable vault creds + host-resident creds outside the relay | **Accepted.** Credentials gateway-only + scoped capability tokens; P1 gains host credential inventory with continuous scheduled scan in the health floor; clean-host AC passes with empty exemption list. |
|
||||
| 9 | Silent auto-deny steady state (fail-closed TTL + single surface + unprovisioned push) | **Accepted.** K gains homeserver ops + monitored push (K3); CLI approval surface ships **with** P2; delivered/seen tracking + TTL/2 escalation; `denied` / `expired_seen` / `expired_unseen` distinct terminal states. |
|
||||
| 10 | Prompt injection → durable memory; wake turns add system-role injection | **Accepted.** J2 write-side trust rule (transitive `source_trust=external`; standing-instruction-shaped content needs user ratification before retrievable); wake turns templated with provenance-tagged data fields. |
|
||||
| 11 | Exactly-one-Jarvis has no fencing incl. the Matrix send path | **Accepted.** New **J-R16** workspace lease `(workspace_id, epoch)` CAS row in product Postgres; epoch on every write; pre-send lease re-check; takeover notice; degraded = mute-with-notice. NS-10 amended: one main agent per **workspace**. |
|
||||
| 12 | Matrix principal resolution undefined (Codex #2, unanswered in R2) | **Accepted.** K/P delta: immutable Matrix user ID + bridge provenance + workspace membership → product principal; unlinked/bridged-unlinked identities read-only, cannot approve/butt-in/trigger external writes. All four Codex fixtures = deny + audit. |
|
||||
| 13 | Policy evaluation time undefined (Codex #7, unanswered in R2) | **Accepted.** Immutable policy snapshot on every prepared action/card; execution revalidates or fails `policy_changed`; delegated effects gated by grant **intersection**. |
|
||||
|
||||
## P2/P3 findings — accepted (see per-PRD delta sections)
|
||||
|
||||
14 Hermes evidence snapshot **before** stop (machine gate) · 15 Q1 crash barriers + `external_refs` unique-index table (one mechanism, three consumers) · 16 rollback honestly scoped (transport-only, point-of-no-return = first native card) + bounded day-30 review with three recorded outcomes · 17 human-attention budget (rate limits, quotas, deferrable flag, away state) · 18 self-referential-loop containment (provenance labels, source-grounded retrieval preference, retrieval eval gate ≥50 queries / ≥90% baseline recall@5 + negative queries, tombstones, priority budget, day-1 trend telemetry) · 19 butt-in exclusive lease + structured control-plane API + break-glass doctrine · 20 default-closed capability gating with `unclassified` third state · 21 per-agent atomic approval-routing cutover table · 22 `needs-decision` card lifecycle (immutable spec + typed amendments; `ratified_by` authorizes dispatch, never merge) · 23 retention class per durable table; dedupe pruning checkpoint-coupled · 24 AC-NS-8 made measurable (distinct quota pools pinned in profile; TTFT p95 ≤ 1.2× baseline, ≥30 interleaved turns).
|
||||
|
||||
**All accepted.** None conflicts with D1–D12.
|
||||
|
||||
## Open disagreements — convener judgment calls
|
||||
|
||||
| § | Question | Call |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 3.1 | Gate architecture: tiered blocking pack (Moonshot+Ops+ML-Cal) vs 4 artifact-existence machine gates + pass/fail-free pre-registered dossier (Contrarian) | **Adopted the agreed floor now** (4 machine gates on irreversible transitions, pre-registration, priced amendments, day-1 emission — folded into X). Superstructure choice is **OPEN — Jason**. Convener recommendation: **Contrarian's dossier + dumb gates** — the tier demonstrably gamed itself within one debate round (13+ claims vs cap 10, slot-riding, zero demotions); simple machine gates don't degrade under pressure. |
|
||||
| 3.2 | Audit schema: additive-only typed schema (Coder-Data) vs six-field mandatory envelope + typed payloads + pinned checked-in queries (Contrarian) | **Adopted: minimal envelope + schema-on-read** (folded into P1 delta). Rationale: preserves "can't add data later" essentials without a god-schema by committee; an additive typed layer can be grown later where query pain proves it. |
|
||||
| 3.3 | Unclassified capability: reject at call time vs version-scoped activation hold vs capability-scoped hold | **Adopted: capability-scoped hold** (Contrarian R2#6a, synthesis editor concurs) — gate the capability, ship the version; security patches are never pinned behind classification. |
|
||||
| 3.4 | Away mode: fail-closed expiry labeled `expired_during_away` vs suppress preparation while away | **Adopted: suppress preparation** of non-deferrable requests while away + audited suppressed-preparations list swept on return. Cleaner than labeling corpses; nothing expires that was never surfaced. |
|
||||
| 3.5 | Memory-exclusion scope for measurement artifacts | **Adopted: normative/parametric split** (Contrarian R2#4) — rules the agent is scored against stay retrievable; thresholds/seeds/drill timings/canary templates are excluded. |
|
||||
| 3.6 | Statistical instruments | **Adopted:** deterministic named crash barriers as the gate; residual randomized soak is **trace-directed**, not wall-clock-uniform. |
|
||||
| 3.7 | K2 scope narrowing by Hermes traffic audit | **Adopted as a Gate Zero action:** run the audit pre-ratification; platforms with live traffic become the must-have subset gating X3. |
|
||||
|
||||
## Process rules adopted (README)
|
||||
|
||||
- **Gate Zero** — pre-ratification upstream-artifact audit (`present @ SHA` or MISSING → goal card).
|
||||
- **Conflict register** — spec contradictions block the requirement, not the mission ("alert, don't auto-resolve" promoted to specs).
|
||||
- **DoD line** on every goal card (runbook, health-floor alerts, AGENTS.md).
|
||||
- **Silent-roster rule** — a panel member's silent round records their open findings as open items, never consensus (Codex's R2 silence on #2/#7 is the instance; both were folded as P1 items 12–13 above, explicitly not consensus-resolved).
|
||||
|
||||
## Addendum — logging & telemetry (Jason, 2026-07-09, post-debate)
|
||||
|
||||
Requirement raised outside the panel, folded in the same pass: Mosaic Stack must support **inbound error reporting/logging** from agents and installs, plus **optional anonymous agentic-efficiency telemetry** — no IP or PII capture, opt-in. The P0 of this capability already exists: **MALS** (Mosaic Agent Log System, FastAPI+Postgres, `~/src/mals`), currently dark because its k3s migration landed without an Ingress (tracked: infrastructure #135). Folded as new **workstream L** (L1 restore MALS · L2 Mosaic-native ingestion · L3 anonymous telemetry) + standing objective **NS-14**. Day-1 trial-metric emission (finding 18) targets MALS until W3 panels exist.
|
||||
@@ -0,0 +1,58 @@
|
||||
# PRD — Backlog Provider Sync Adapters · Workstream Q
|
||||
|
||||
> **Status:** DRAFT for ratification · **Goals:** Q1–Q3 · **Doctrine:** NS-12 (ratified D3)
|
||||
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md`.
|
||||
|
||||
## Mission
|
||||
|
||||
Users choose where they _see and touch_ work — Gitea, GitHub, a local kanban — while the **Mosaic Backlog on native Postgres stays the sole record and dispatch engine** (upholds ASM-1; NS-3/NS-4/NS-5 guarantees never depend on an external provider). Providers attach as bidirectional sync adapters.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Adapter interface + Gitea (Q1)
|
||||
|
||||
| ID | Requirement |
|
||||
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Q-R1 | A `BacklogProviderAdapter` interface: map card ⇄ external item (create/update/close/comment/label), with stable external-id linkage stored on the card. |
|
||||
| Q-R2 | Sync is bidirectional and conflict-safe: native record wins on divergence; external edits arrive as proposed mutations (applied if non-conflicting, else surfaced). |
|
||||
| Q-R3 | Claims, TTLs, depends_on DAG, and dispatch state live **only** in the native record; adapters project them (e.g. as labels/comments) but never own them. |
|
||||
| Q-R4 | Gitea adapter first (webhook + API), configured per workspace: repo mapping, label conventions, direction (mirror-out / mirror-in / full). |
|
||||
| Q-R5 | Adapter enable/disable is workspace configuration; zero adapters is a fully supported mode. |
|
||||
|
||||
### GitHub (Q2)
|
||||
|
||||
| ID | Requirement |
|
||||
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Q-R6 | Same interface, GitHub Issues backend. Existing `packages/cli-tools` platform detection informs but does not implement this (that is dev tooling, not product runtime). |
|
||||
|
||||
### Local kanban (Q3)
|
||||
|
||||
| ID | Requirement |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Q-R7 | A webUI kanban board over the native backlog (no external provider needed) — the "local kanban" choice. Builds on W3's card views and/or the existing `KanbanBoard` component upgraded from demo-grade to live data. |
|
||||
|
||||
## Debate-accepted deltas (2026-07-09) — normative
|
||||
|
||||
| ID | Requirement |
|
||||
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Q-R8 | **External-ref linkage is one shared mechanism:** a unique-indexed `external_refs(entity_id, system, external_id)` table serves adapter linkage (Q-R1), migration idempotency (X-R4 anti-join), and re-point verification (X-R6) — built once, three consumers. Idempotent upsert; a violated unique index is a converge signal, never an overwrite. |
|
||||
| Q-R9 | **Crash-safe external creates:** the adapter writes a `pending-link` row **before** any external create and embeds a deterministic card-id marker in the created item, so a crash between create and link-back is recovered by scan, never by duplicate creation. |
|
||||
| Q-R10 | **Echo-loop guard:** both directions carry revision counters; adapter-authored external edits are tagged (marker/actor) and skipped on read-back. A sync cycle that would re-import its own write is a hard test failure. |
|
||||
| Q-R11 | The sync engine is a **level-triggered reconciler** over desired-vs-observed state (same doctrine as J6), not a webhook-only event chase: webhooks accelerate convergence, the reconciler guarantees it. Missed webhooks are a latency event, not a correctness event. |
|
||||
| Q-R12 | Card spec immutability (J-R20) projects cleanly: the mirrored issue body is the pinned spec revision; amendments append as **ordered provider comments**, never body rewrites, so external watchers see the same amendment history as the native record. |
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. A card created by Jarvis (J3) appears as a Gitea issue within one sync interval; closing the issue in Gitea marks the card for review, not silent closure; dispatch/claims never round-trip through Gitea.
|
||||
2. Killing the adapter mid-mission: dispatch continues unaffected (record is native); on restart, sync converges without duplicates.
|
||||
3. The same mission can be mirrored to Gitea and viewed on the local kanban simultaneously without state divergence.
|
||||
4. **Named crash barriers** at every external-call boundary (`before_external_create`, `after_create_before_link`, `after_link_before_ack`): kill the adapter at each; zero duplicate external items, zero orphaned cards. CI rule: a new external call site without a named barrier + kill test **fails the build**. Residual randomized soak is trace-directed (§3.6 disposition).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- External provider AS the backlog (vetoed — "truly swappable backends" option declined 2026-07-09).
|
||||
- Two-way sync of claims/TTL semantics (external systems can't express them; projection only).
|
||||
|
||||
## Assumptions
|
||||
|
||||
- ASSUMPTION: the delivery fleet's _engineering_ PR/issue flow on the stack repo itself continues to use `cli-tools`/Gitea directly — workstream Q is the product feature for user workspaces, not a replacement for the dev workflow.
|
||||
@@ -0,0 +1,83 @@
|
||||
# PRD — Hermes Decommission & Tenant-1 Migration · Workstream X
|
||||
|
||||
> **Status:** DRAFT for ratification · **Goals:** X1–X3 · **Doctrine:** NS-13, ASM-8 (Hermes untouched until verified parity)
|
||||
> Ratified direction (D2, 2026-07-09): Mosaic absorbs **all four** Hermes functions — messaging bridge, task board, permission relay, multi-platform reach.
|
||||
> **Debate pass 2026-07-09:** panel findings folded — this PRD took the heaviest rewrite (storage authority, migration census, memory-store hygiene, honest rollback scope). See `DEBATE-FINDINGS.md`.
|
||||
|
||||
## Deployment scope (D12/ASM-9)
|
||||
|
||||
The trial runs in the **homelab**. Hermes and the primitive-era stack (`mos-claude.service`, jarvis-brain boards) live in the **USC/web1 environment**, which is untouched during the trial. This workstream therefore lands in two stages: **X-in-homelab** (prove parity where the fleet is native — mainly K/P/Q verification plus tenant-1 migration) and **X-at-USC** (post-trial adoption: apply the parity checklist to web1, migrate Mos-on-web1 to `mosaic-agent@orchestrator`, then decommission Hermes there, with `/src/infrastructure` GitOps updates in the same delivery set).
|
||||
|
||||
**Trial go/no-go (D12/ASM-9 gate):** the homelab→USC promotion is **owner-judgment**, not an automated metric. The stage gate is: _Jason instantiates and operates the split-agent stack in the homelab and is satisfied with its operation._ Only on that explicit sign-off does X-at-USC begin. The capability ACs (AC-NS-8…11) are the evidence Jason weighs; they inform the decision but do not auto-trigger USC deployment.
|
||||
|
||||
## Mission
|
||||
|
||||
Retire Hermes entirely. Mosaic becomes the platform for transport (Matrix connector), task board (native backlog + webUI/adapters), approvals (permission relay), and multi-platform reach (mautrix bridges). In the same arc, Jason's jarvis-brain flat-file data migrates into the product as **tenant #1**, making the product's PA feature set the dogfooded default.
|
||||
|
||||
## Parity map (what replaces what)
|
||||
|
||||
| Hermes function | Mosaic replacement | Workstream |
|
||||
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| Messaging bridge (Discord/Telegram/…) | Matrix connector + mautrix bridges | K1, K2 |
|
||||
| Kanban / task board | Native Mosaic Backlog + webUI board + provider adapters | A\*, Q, W3 |
|
||||
| Permission relay (`permissions_*`) | Guard-rails engine + approval queue (Matrix + webUI) | P1–P3 |
|
||||
| Cross-platform user reach | mautrix bridges (agents speak Matrix only) | K2 |
|
||||
| Hermes MCP tools in agent sessions | **Per-tool equivalence table** (Gate Zero artifact): approvals → P2, board ops → Q1/A\*, messaging → K1 — not a generic "gateway API" gesture | P2, Q1, K1 |
|
||||
|
||||
## Requirements
|
||||
|
||||
### Parity checklist + cutover plan (X1)
|
||||
|
||||
| ID | Requirement |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| X-R1 | A written, testable parity checklist per row above; each item verified in production before its Hermes counterpart is disabled. The checklist is seeded from a **pre-trial usage audit**: per-MCP-tool and per-capability call counts over a trailing window, plus an inventory of Hermes-held provider callbacks/webhooks and secrets. Parity rows with `n < 5` real invocations in the window cannot be "verified by traffic" — they get **scheduled drills in weeks 1–3** of the observation window instead of silent green. |
|
||||
| X-R1a | **Approval-routing cutover is per-agent atomic:** a cutover table states, per agent, the single moment its `permissions_*` path flips from Hermes to the P relay. No agent ever has two live approval paths; no approval window where neither path is live. |
|
||||
| X-R2 | Cutover is staged with rollback at every stage; Hermes runs untouched until AC-NS-11 is verified (ASM-8). **Rollback is honestly scoped (debate #16): it restores _transport_ (Hermes services + MCP registrations) — board/approval state created natively during the trial does NOT back-migrate.** The **point of no return is the first native-only card**; the runbook says so explicitly, and the abort path (below) is written before cutover, not during an incident. |
|
||||
| X-R3 | The Matrix charter's live-cutover rules apply: stated window, announce before/after, rollback ready. |
|
||||
|
||||
### Tenant-1 migration (X2)
|
||||
|
||||
**Framing (ratified 2026-07-09, storage authority clarified by debate P0 #1):** jarvis-brain **is the P0 (prototype) Mosaic Stack** — its flat-file data layer is the zeroth implementation of what the product does properly. Migration is therefore _P0 → proper Mosaic Stack_. **Native Postgres is the sole authoritative store for every product entity** (consistent with workstream Q's "sole record" doctrine and NS-3/NS-4/NS-5). A flat-file backend, where offered, is a **derived, regenerated, read-only projection** — the same relationship generated views have to JSON in the P0 today — never a co-equal write target. Two distinct data classes migrate — they are not the same destination and neither is frozen:
|
||||
|
||||
- **(a) PA data** (projects, tasks, events, tickets, knowledge) → product entities in the Jason workspace (Project/Task/Event/KnowledgeEntry), authoritative in Postgres; flat-file export available as a read-only projection.
|
||||
- **(b) Agent memory & operational knowledge** (runbooks, digests, scratchpads, OpenBrain thoughts) → the **enhanced memory subsystem (goal M1: vector DB + memory service)**. This flow stays **live and writable** throughout — it was never Hermes and must not be frozen by the PA cutover.
|
||||
|
||||
| ID | Requirement |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| X-R4 | **Stage 1 of the migrator is a machine-generated source census, not a hand-written file list** (debate P0 #5 — the hand list was already wrong about its own tree). The census walks the live repo (tracked + untracked), inventories every data path — `data/**`, all memory stores (`memory/`, `memories/`, `memory_store*`, `memory.md`, `brain.jsonl`, `jarvis.db`, digests), scratchpads, notes, prior-generation artifacts — and assigns each a disposition: `migrate-as-PA(entity type)` / `migrate-as-memory(M1 class)` / `derived-regenerate` / `retire-with-history`. **Any path dispositioned `unknown` blocks the run.** Stage 2 migrates per-disposition, preserving source ids in metadata; idempotency via anti-join on the shared `external_refs` table (Q-R8); named crash barriers at each phase boundary. A **field-map table** covers the full `brain.py` query surface (status, progress, due, priority, domain, notes, staleness) → product entity fields, so AC 2 is checkable field-by-field. |
|
||||
| X-R5 | Dry-run mode with a diffable report **generated from the census** (counts per disposition, per-entity field mapping, unmapped-field list — must be empty or explicitly waived); Jason ratifies the report before the real run (canonical-data gate — this is the one migration step that is his call). |
|
||||
| X-R6 | External sync jobs (GLPI, Google Calendar, ICS, Gmail, **Vikunja — disposition decided here: re-point or retire, not silently dropped**) each get a **written per-integration transition protocol**: freeze flat-file sync job → verify product integration live → re-point → verify → retire old job. **Provider-sourced events migrate FROM the provider, not from flat files** (flat files supply only brain-native events) — the provider join key is authoritative, so re-pointing cannot duplicate the calendar (debate #6). Calendar fixture (Codex): a DST-crossing recurring event with one moved and one cancelled occurrence, plus an all-day event, round-trips with zero duplicates and correct local times. |
|
||||
| X-R7 | Agent memory/operational knowledge (b) is migrated into the M1 memory subsystem **before** any jarvis-brain retirement; the memory write path stays continuously available (no read-only freeze of an active substrate). The census classifies every memory item: `ratified` / `superseded` / `draft` / `rejected` / `debate-artifact` / `protocol-normative` / `protocol-parametric`. **Parametric measurement artifacts (thresholds, seeds, drill timings, canary templates) are excluded from embedding** (§3.5 disposition — rules the agent is scored against stay retrievable; the knobs do not). Superseded/rejected items get **tombstones**, and supersession triggers re-embedding of affected summaries. **Retirement gate: a retrieval eval — ≥50 representative queries, ≥90% of baseline recall@5, plus negative queries (rejected/superseded content must NOT surface) — passes against M1 before any flat-file store goes read-only.** After cutover: write paths to retired stores are killed and a CI lint fails any reintroduction. Only once **both** (a) and (b) are migrated and verified is the jarvis-brain repo retired read-only (history preserved); generated views and brain.py retire. This closes the P0 prototype. |
|
||||
|
||||
### Decommission (X3)
|
||||
|
||||
| ID | Requirement |
|
||||
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| X-R8 | **Pre-stop evidence snapshot is a machine gate:** before Hermes stops, a checksummed snapshot of its state (board items, pending approvals, bridge registrations, per-tool usage counts) is captured and stored with the trial artifacts. The drill schedule (X-R1) and the credential-revocation report both **cite the snapshot checksum** — no snapshot, no stop. Then: services stopped, disabled, and removed from infra (GitOps: `/src/infrastructure` updated in the same delivery set); credentials revoked — the revocation report cites a **final scan** showing zero live references; MCP registrations removed from agent runtimes. |
|
||||
| X-R9 | **Bounded day-30 review** between stop and removal, pre-registered before cutover (dossier: what will be measured, panels cited, drill results attached — see W-R15/L1 for where the metrics live, `stack_version`-segmented so mid-window upgrades don't blur rates). The review records exactly one of three outcomes: **promote** (remove Hermes), **extend with named blockers** (each blocker a card with an owner), or **abort** (execute the pre-written abort runbook; transport-only rollback per X-R2). "Insufficient data" is a recordable outcome that forces extend — never a shrug into promote. Any in-window regression flips back per X-R2. |
|
||||
|
||||
## Machine gates on irreversible transitions (debate §3.1 agreed floor)
|
||||
|
||||
Four **artifact-existence gates** — dumb, checkable, ungameable — sit on the irreversible transitions. Each is "the artifact exists and passes its check", not a scored rubric:
|
||||
|
||||
1. **Pre-stop snapshot** exists with valid checksum (X-R8) — gates Hermes stop.
|
||||
2. **Census with zero `unknown` rows** exists (X-R4) — gates the PA migration run.
|
||||
3. **Retrieval eval pass record** exists (X-R7) — gates memory-store retirement.
|
||||
4. **All parity rows green-or-drilled** (X-R1: verified by traffic or by scheduled drill; no silent low-n green) — gates Hermes removal at day-30.
|
||||
|
||||
The gate _superstructure_ beyond this floor (tiered blocking pack vs pre-registered dossier) is **OPEN — Jason** at ratification; convener recommendation in `DEBATE-FINDINGS.md` §3.1.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. AC-NS-11: with Hermes stopped, no fleet or main-agent capability regresses.
|
||||
2. `python tools/brain.py today`'s information content is fully answerable by Jarvis from the product workspace post-X2, verified field-by-field against the X-R4 field-map table.
|
||||
3. Zero references to Hermes MCP tools in any active agent runtime config after X3.
|
||||
4. The X-R6 calendar fixture (DST-crossing recurrence, moved + cancelled occurrence, all-day event) round-trips with zero duplicates.
|
||||
5. The X-R7 retrieval eval passes against M1 before any memory store goes read-only; negative queries return no superseded/rejected content.
|
||||
6. All four machine gates above have their artifacts on record before their respective transitions execute.
|
||||
|
||||
## Sequencing note
|
||||
|
||||
X depends on the longest chains (K1→K2, P2, Q1, J2a→J2b, **M1** — X2 cannot complete class (b) without the memory subsystem existing). Dependencies are real DAG edges in `north-star-additions.yaml` (`X2 depends_on [J2a, P2, M1]`), not prose phases (debate P0 #3). Expected order of value delivery: J1–J4 (Jarvis on existing transport interim) → K1/J5 (Matrix room) → P2, W1–W3, Q1 in parallel → X1 checklist → X2 migration → K2 bridges → X3 decommission.
|
||||
|
||||
- ASSUMPTION (interim transport): until K1 lands, Jarvis may run against the tmux connector (CLI/`agent send`) rather than standing up any Discord channel — keeps D1 (Matrix-first, no #jarvis Discord) intact.
|
||||
@@ -0,0 +1,89 @@
|
||||
# PRD — HMI Main Agent ("Jarvis") · Workstream J
|
||||
|
||||
> **Status:** DRAFT for ratification · **Source of truth once landed:** NORTH_STAR.yaml goals J1–J6
|
||||
> **Depends on upstream:** H2 (system-type profiles), A3a (card lifecycle), B1 (supervisor tick), F4/K1 (Matrix connector)
|
||||
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md` for dispositions.
|
||||
|
||||
## Mission
|
||||
|
||||
Every Mosaic **workspace** gets exactly one always-on human-machine-interface agent — default alias **Jarvis**, unit `mosaic-agent@main.service` — that owns the human relationship: conversation, idea development, schedule, email, tasks, knowledge. It delegates all engineering/research/ops work to the orchestrator (**Mos**, `mosaic-agent@orchestrator.service`) through the Mosaic Backlog, and reports fleet status to the user without ever interrupting the orchestrator.
|
||||
|
||||
Jarvis is a **Level-0 orchestrator**: it accomplishes its own work through _delegation and subagents_, never by executing coding/infra tasks itself. PA mutations (tasks/events/knowledge) are direct API calls; everything heavier is either a spawned subagent (research, drafting, analysis) or a backlog card handed to Mos (engineering/infra/fleet). This keeps the main agent's context conversational and light.
|
||||
|
||||
This solves the observed failure mode: a busy orchestrator that can't respond, accumulates conversational context rot, and derails over time. Post-split, the orchestrator's context is execution-only.
|
||||
|
||||
Because Jarvis and Mos are **separate agents with separate model capacity** (D11: Jarvis on Opus, Mos on Fable; independent inference quota), orchestrator load cannot degrade conversational latency — the isolation in AC-NS-8 is a capacity guarantee, not merely a separate process.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Persona & runtime (J1)
|
||||
|
||||
| ID | Requirement |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| J-R1 | Jarvis is provisioned from the personal-assistant persona baseline via system-type profiles (H2/H3); alias, model tier, host, and channel are profile fields, not code. |
|
||||
| J-R2 | Default model tier **Opus** (ratified D11); the orchestrator's tier is independent. Always-_available_ ≠ always-_billed_: Opus is provisioned 24/7 but cost is per-interaction — an idle Jarvis (no user turn in flight) incurs no model spend, so "always-on" carries no standing token bill. |
|
||||
| J-R3 | Jarvis survives reboot under systemd (`mosaic-agent@main`), participates in the fleet heartbeat protocol, and is counted in the supervisor's health floor. |
|
||||
| J-R4 | Persona customization is update-surviving per H4 (override layer wins on merge). |
|
||||
| J-R16 | **Workspace lease (exactly-one fencing):** Jarvis acquires `workspace_lease(workspace_id, epoch)` — a CAS row in product Postgres — before processing any turn. Every PA write, card, and approval carries the epoch; stale-epoch writes are rejected server-side; the connector re-checks the lease immediately before every outbound send. Takeover posts an in-room/in-channel epoch notice. Degraded mode (lease unobtainable) = **mute-with-notice**, never conversational-while-unfenced. Clean shutdown releases the lease. This primitive also excludes homelab/USC split-brain during adoption. |
|
||||
| J-R17 | Per-agent spend metering with a daily budget alarm for `mosaic-agent@main`; a main-agent crash-loop is a distinct, escalated supervisor condition (not a generic restart count). |
|
||||
| J-R18 | **Resume protocol (promoted from open item):** resume context is reconstructed from authoritative queries (board, heartbeats, workspace entities), with narrative summary layered on top; a session-start divergence check flags contradictions between narrative and authoritative state. Jarvis is never re-instantiated from its own lossy summaries alone. |
|
||||
|
||||
### PA toolchain (J2a workspace-internal · J2b external)
|
||||
|
||||
**Split (debate P0 #2):** phase-1 Jarvis operates only on workspace-internal entities — **no external-write credential path exists** until the permission relay (P2) is live. External integrations arrive in phase 2 as J2b, `depends_on: [J2a, P2]`. Test: in a phase-1 deployment, `email:send` is _impossible_ (no credential provisioned), not merely unapproved.
|
||||
|
||||
| ID | Requirement |
|
||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| J-R5 | (J2a) Jarvis executes personal-assistant mutations **directly** in the user's workspace via the product API: tasks, events/calendar, knowledge entries, ideas. No delegation for PA ops (ratified D4). |
|
||||
| J-R6 | (J2b) External PA integrations (email, external calendars, helpdesk) are workspace-scoped integrations; **raw credentials are held exclusively by the gateway** — Jarvis receives scoped capability tokens, never provider secrets; actions flagged `requires_approval` route through the permission relay (workstream P). |
|
||||
| J-R7 | Until tenant-1 migration (X2) completes, Jarvis may read/write the jarvis-brain flat files as a transitional adapter; the adapter is deleted after the **last verified X-R6 re-point** (not at a nominal "X2 cutover" date). A per-phase, per-entity-type source-of-truth table in the J1 profile states which store is authoritative at every moment. |
|
||||
| J-R19 | **Write-side trust rule:** externally-sourced content (email bodies, bridged messages, webhook payloads) written into workspace entities or memory inherits `source_trust=external` **transitively through summarization**. Standing-instruction-shaped external content requires explicit user ratification before it becomes retrievable. This closes the injection→durable-memory channel. |
|
||||
|
||||
### Delegation contract (J3)
|
||||
|
||||
| ID | Requirement |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| J-R8 | The Jarvis→Mos handoff is **only** via Mosaic Backlog cards: goal, acceptance criteria, priority, depends_on, advisory budget. Never via chat messages to the orchestrator. |
|
||||
| J-R9 | Jarvis translates conversation outcomes into card sets; ambiguity is resolved with the user _before_ card creation — the orchestrator receives only decision-complete work. |
|
||||
| J-R10 | Card authorship is attributed (author=main-agent, ratified-by=user where applicable) for audit. |
|
||||
| J-R11 | Authority line: Mos holds all execution and merge authority (NS-4). Jarvis relays the user's GO/NO-GO gates as card state, and never acquires fleet mutation, merge, or dispatch rights. `ratified_by=user` authorizes **dispatch only** — it never substitutes for the reviewer-of-record merge gate. |
|
||||
| J-R20 | Card sets are drafted then **published atomically** with client idempotency keys (no partial card sets on crash). Card spec is **immutable after publish**; changes arrive as typed, ordered amendments with a revision counter; reviewer sign-offs pin the spec revision; scope-expanding amendments re-enter ratification. |
|
||||
| J-R21 | **`needs-decision` lifecycle:** a worker hitting genuine ambiguity sets `needs-decision(question, options)` on the card **with a durable checkpoint** (pushed branch + card note) — resume after days is a re-dispatch, not a context continuation. Jarvis relays the question to the user and writes the answer back as an amendment. This is the sanctioned clarification path; J-R8's no-chat rule stands. |
|
||||
| J-R22 | Jarvis-authored cards draw from a **priority budget** (quota per priority class per window) — priority inflation by the card author degrades the field for the whole fleet and is structurally capped, not policed by review. |
|
||||
|
||||
### Passive observability (J4)
|
||||
|
||||
| ID | Requirement |
|
||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| J-R12 | Jarvis answers "what's the fleet doing" from read-only sources: heartbeat files, `mosaic fleet ps` JSON, backlog card states, CI status. Zero messages to the orchestrator for status. |
|
||||
| J-R13 | Jarvis proactively surfaces to the user: blocked cards, failed CI on user-ratified missions, approval requests pending, budget advisories. (PDA-friendly phrasing per SOUL.md.) Delivered via the **J6 event/wake router** — per-agent polling is forbidden. |
|
||||
| J-R23 | **Event/wake router (J6):** one shared, level-triggered reconciler over durable state (heartbeats, card states, approval queue) wakes Jarvis on state _change_ with hysteretic per-condition suppression (wake once, then only on change or declared backoff; suppression survives restarts). Wake turns are **templated** — fixed instruction frame, workspace data only in delimited, provenance-tagged data fields (closes the injection→system-role channel). Idempotent on source event id via the shared consumed-events table. Router is in the health floor; its cost model and latency bound are stated in the J6 card. Reconciles J-R2: an idle Jarvis costs nothing _because waking is event-driven, not poll-driven_. |
|
||||
|
||||
### Channel (J5)
|
||||
|
||||
| ID | Requirement |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| J-R14 | **Phase 2 (target channel):** Jarvis's conversation lives in a dedicated Matrix room on the self-hosted homeserver via `OrchestratorConnector(matrix)` (K1 = f4 Phase 2). Matrix-first: no Discord channel is created for Jarvis (ratified D1). |
|
||||
| J-R14a | **Phase 1 (interim channel, ratified):** Jarvis runs on the **tmux/CLI connector** — the f4 Phase-1 default connector. The operator launches the `mosaic-agent@main` tmux session and issues `/remote-control` to grant interactive access; this is the day-one conversation surface. No Discord, no Matrix dependency in Phase 1 (keeps D1 intact and unblocks J1–J4 before K1 lands). |
|
||||
| J-R15 | Multi-platform user reach arrives via mautrix bridges (K2); Jarvis's code path is Matrix-only (from Phase 2 onward). |
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. AC-NS-8 **(made measurable)**: distinct credential/quota pools for Jarvis and Mos are pinned in the J1 profile; scripted suite of ≥30 interleaved turns under full orchestrator load; TTFT p95 ≤ 1.2× idle baseline with bootstrap CI; orchestrator receives zero conversational traffic.
|
||||
2. AC-NS-9: a conversationally-agreed mission round-trips (cards → drained → completed → reported by Jarvis) with no chat handoff.
|
||||
3. Kill the orchestrator mid-conversation: Jarvis conversation is unaffected; Jarvis reports the outage from heartbeat state. (Directly exercises the separate-capacity guarantee.)
|
||||
4. `!sys`-equivalent admin verbs work in Jarvis's active channel — the tmux/CLI session in Phase 1, the Matrix room in Phase 2 (status/logs/clear/restart of the main agent).
|
||||
5. **Phase-1 channel:** operator launches the `mosaic-agent@main` tmux session, issues `/remote-control`, and holds a full conversation with Jarvis over CLI with no Matrix/Discord dependency.
|
||||
6. **Fencing:** start a second `mosaic-agent@main` by hand — it fails to acquire the workspace lease, posts a notice, and stays mute; zero duplicate writes or cards reach the workspace (J-R16).
|
||||
7. **Phase-1 credential surface:** audit of a phase-1 install finds no external-provider credential readable by the Jarvis runtime (J2a/J2b split holds by construction).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Jarvis executing code/infra changes (that is Mos + fleet).
|
||||
- Horizontal sharding of the main agent (rejected in the Matrix charter: split-brain).
|
||||
- Per-workspace fleets (post-MVP per ASM-6).
|
||||
|
||||
## Open items (for Mos's planner)
|
||||
|
||||
- ~~Context hygiene / resume protocol~~ — promoted to J-R18 by the 2026-07-09 debate pass.
|
||||
- Reconcile the old `apps/api` matrix-bot-sdk workspace bridge with the F4 connector design (one Matrix stack, not two). NOTE (verified 2026-07-09): no matrix dependency remains in `apps/api` on `origin/main` — this item is likely already moot; confirm before K1 build.
|
||||
@@ -0,0 +1,98 @@
|
||||
# PRD — Permission Relay · Workstream P
|
||||
|
||||
> **Status:** DRAFT for ratification · **Goals:** P1–P3
|
||||
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md`. The relay was the panel's densest target; the deltas below are normative.
|
||||
> **Design origin (historical):** `docs/3-architecture/guard-rails-capability-permissions.md` — the "prepare freely, execute with approval" snapshot. **Not present on `origin/main`** (survives only in the stale `/src/mosaic-stack` clone), so its essential model is folded into this PRD below; **this document is the authoritative, self-contained spec for P.**
|
||||
> **Replaces:** Hermes `permissions_list_open` / `permissions_respond` relay (Hermes exit prerequisite, NS-13)
|
||||
|
||||
## Mission
|
||||
|
||||
A human-in-the-loop approval mechanism for agent actions: any capability listed as `requires_approval` is prepared by the agent, queued, and executed only after an explicit human approve — from the Matrix room or the webUI. Today this exists only as a bare `applyGuardRails()` method; Hermes currently fills the gap and must be replaced before decommission.
|
||||
|
||||
## Design model (folded in — the authoritative spec, since the origin snapshot is off-main)
|
||||
|
||||
**Doctrine — "prepare freely, execute with approval":** an agent may plan, draft, and stage any action without friction; only the _committing_ step of a `requires_approval` capability blocks on a human decision.
|
||||
|
||||
**Permission levels (least→most):** `read` → `organize` → `draft` → `execute` → `admin`. A capability grant names a level; `requires_approval` gates the transition into `execute`/`admin` for the capabilities a workspace marks sensitive.
|
||||
|
||||
**Grant shape:** `resource:action` (e.g. `email:send`, `git:push_main`, `dns:update`), scoped per workspace and per agent-persona, stored as configuration (profile field) so a user tightens/loosens without a code change.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Guard-rails engine (P1)
|
||||
|
||||
| ID | Requirement |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| P-R1 | Capabilities are `resource:action` grants (e.g. `email:send`, `git:push_main`, `dns:update`) with permission levels (read / organize / draft / execute / admin) per the existing design doc. |
|
||||
| P-R2 | Each integration declares its `requires_approval` list; grants are workspace-scoped and per-agent-persona. |
|
||||
| P-R3 | Enforcement sits in the gateway/API dispatch path — an agent cannot bypass it by construction; bypass attempts are audited and denied. |
|
||||
| P-R4 | Policy is configuration (profile field), honoring the configurability pillar: a user can tighten/loosen per capability without code change. |
|
||||
|
||||
### Approval queue + chat approvals (P2)
|
||||
|
||||
| ID | Requirement |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| P-R5 | A pending approval is a durable queue record: requesting agent, capability, human-readable intent summary, prepared payload reference, TTL. |
|
||||
| P-R6 | Approve/deny from the Matrix room (message action or reply verb); the requesting agent is notified of the outcome and proceeds/aborts. |
|
||||
| P-R7 | Timeout = deny (fail-closed), with **per-capability TTLs** and distinct terminal states: `denied` / `expired_seen` / `expired_unseen` — agents must not reason about an expiry as a human "no". Deny and timeout leave the system unchanged. |
|
||||
| P-R8 | Full audit trail: who approved what, when, from which surface (AC-NS-10). |
|
||||
| P-R9 | The main agent (Jarvis) surfaces pending approvals conversationally (J-R13) but approval authority is the human's — Jarvis never auto-approves. |
|
||||
|
||||
### webUI surface (P3)
|
||||
|
||||
| ID | Requirement |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| P-R10 | Pending-approval queue view in `apps/web` with one-click approve/deny, filterable per workspace/agent (depends W3 dashboard shell). |
|
||||
|
||||
## Debate-accepted deltas (2026-07-09) — normative
|
||||
|
||||
### State machine & delivery (extends P-R5–P-R7)
|
||||
|
||||
| ID | Requirement |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| P-R11 | Approval lifecycle is a **CAS state machine on one durable row**: `pending → approved \| denied \| expired_seen \| expired_unseen`, all terminal. Concurrent surfaces (Matrix, webUI, CLI, TTL reaper) contend via compare-and-swap; exactly one wins. |
|
||||
| P-R12 | The **prepared payload is persisted in the record at request time** — never a reference to requesting-agent process state (the agent may be dead when approval lands). Outcome delivery is **poll/ack**, not push-only; `approved_unexecuted > N min` raises an alarm. |
|
||||
| P-R13 | A **consumed-event dedupe table** (shared substrate with bridged-message and wake-turn dedupe — built once, three consumers) makes approval consumption idempotent under Matrix at-least-once replay. Dedupe retention is **checkpoint-coupled**: events older than the durable sync token are dropped before lookup, so pruning never reopens the replay window. |
|
||||
| P-R14 | Each capability declares `retry: safe \| at-most-once`; a prepare→execute **staleness bound** rejects execution of stale payloads. Re-surfacing an expired item **re-prepares** (new linked record with a rendered machine diff against the original) — never re-queues a stale payload. |
|
||||
| P-R15 | **CLI approval surface** (`mosaic approvals list\|approve\|deny`) ships **with P2** as must-have — the newest infra (Matrix) is never the only approval path. Per-request delivered/seen tracking; TTL/2 escalation via a second path. |
|
||||
|
||||
### Identity & policy (closes Codex #2/#7)
|
||||
|
||||
| ID | Requirement |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| P-R16 | **Principal resolution:** every inbound approve/deny maps to a product principal via immutable Matrix user ID + bridge provenance + workspace membership. Unlinked identities and bridged puppets without an account link converse read-only and **cannot approve, butt-in, or trigger external writes**. Fixtures: bridged puppet, renamed user, invited non-admin, removed-member-with-lagging-room-membership — all deny + audit with reason. |
|
||||
| P-R17 | **Policy snapshot:** every prepared action and card stores an immutable snapshot (profile id/version, grants evaluated). Execution **revalidates against current policy** or fails with explicit `policy_changed`. Profile changes are audited with schema validation + dry-run impact report. Delegated work (subagent, Jarvis-authored card) executes under the **intersection** of originator and executor grants. |
|
||||
|
||||
### Credential boundary (makes P-R3 true)
|
||||
|
||||
| ID | Requirement |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| P-R18 | Raw provider credentials are held **exclusively by the gateway**; agents receive scoped capability tokens; the gateway injects secrets server-side. If the agent runtime can read the provider secret, P-R3 is decoration. |
|
||||
| P-R19 | P1 delivers a **fleet-host credential inventory**: every host-resident credential (SSH keys, kubeconfigs, tool tokens) classified `moved-behind-gateway` or `explicitly-exempt(reason, owner, expiry)`. Enforced by a **continuous scheduled scan** of agent-readable paths (alert on unclassified), registered in the health floor. The clean-host install AC passes with an **empty exemption list**. Break-glass is one standing exempt row (owner: operator; audited post-hoc). |
|
||||
|
||||
### Gating defaults & load (closes default-open + human-overdraw)
|
||||
|
||||
| ID | Requirement |
|
||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| P-R20 | **Default-closed inversion:** `execute`/`admin` capabilities on external integrations require approval **unless** workspace-allowlisted. Third state `unclassified`: a capability with no classification **rejects** at the gate. Classification happens at integration-version activation, scoped to the **capability, not the version** — a security patch bundling one new capability ships same-day; only the new capability rejects until classified. Manifest-less capabilities reject + integrity alert. |
|
||||
| P-R21 | Rate limits + `max_pending_per_agent_per_capability`; queue depth and human decision latency are exported metrics with thresholds (rubber-stamping guard). Capabilities carry a `deferrable` flag; a declared **away state** pauses deferrable TTLs, batches pings, and **suppresses preparation** of non-deferrable requests (suppressed list swept, priority-ordered and paginated, on return). |
|
||||
| P-R22 | Approval summaries render **machine-extracted payload facts unconditionally** (recipient, amount, target host — no tunable similarity threshold); agent prose is secondary. **Canary approvals** are gateway-generated, short-circuited at the gateway (never executable), immediately disclosed after decision, timing seeded outside agent-readable stores; the gated criterion is machine-flag correctness — human catch rate is reported with binomial CI, non-blocking. |
|
||||
| P-R23 | Every durable table introduced by P declares a **retention class**, linted in CI; pruning jobs are health-floor registered. Minimal mandatory audit envelope: `ts, workspace, actor, correlation_id, stack_version, schema_version` — payloads are typed free-form with pinned, checked-in queries (debate §3.2 disposition). |
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. AC-NS-10 end-to-end: a `requires_approval` action executes only post-approve; deny/timeout paths verified unchanged + audited.
|
||||
2. Approval round-trip from a phone Matrix client (Element) in under 3 taps — **and** the same round-trip via `mosaic approvals` CLI with the homeserver stopped.
|
||||
3. With Hermes stopped, permission flow fully served by Mosaic (feeds AC-NS-11).
|
||||
4. Crash-consistency: kill the gateway at each named barrier (`before_persist`, `after_approve_before_execute`, `after_execute_before_ack`); zero double-executions, zero lost approvals across the suite.
|
||||
5. All four principal-resolution fixtures (P-R16) deny + audit; policy-change race (P-R17) fails `policy_changed`, never executes under stale grants.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Automated quality gates (coordinator/CI approvals) — different system, already exists.
|
||||
- Fine-grained LLM output moderation — out of scope; this governs _actions_.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- ASSUMPTION: the durable queue rides the native Postgres storage service (same substrate as the backlog), not a new datastore.
|
||||
- ASSUMPTION: routine delivery operations already hard-gated as no-confirmation (push/merge per Mosaic contract) are NOT routed through the relay — the relay is for `requires_approval` capabilities only, so it does not reintroduce routine confirmation prompts.
|
||||
@@ -0,0 +1,64 @@
|
||||
# PRD — webUI Fleet Control · Workstream W (realizes F6)
|
||||
|
||||
> **Status:** DRAFT for ratification · **Goals:** W1–W3 · **Upstream anchor:** `PRD-fleet-suite.md` Phase F6 ("webUI hooks — stable JSON contract + terminate/attach(butt-in) surface")
|
||||
> Confirmed gap: zero xterm/pty/tmux code in `apps/web` on either the old snapshot or `origin/main`.
|
||||
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md`.
|
||||
|
||||
## Mission
|
||||
|
||||
The user can pop in on **any** agentic tmux session from the web, and get a full top-down view of the system — fleet roster, health, work in flight, spend — without touching a terminal. This is the product surface for "user has ability to pop in on any agent session; full top-down view available."
|
||||
|
||||
## Requirements
|
||||
|
||||
### Attach service (W1)
|
||||
|
||||
| ID | Requirement |
|
||||
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| W-R1 | A gateway service exposes per-agent session streams over WebSocket: **watch** (read-only pane view, cannot type) and **butt-in** (interactive takeover), mirroring the existing CLI verbs `mosaic agent watch/attach`. |
|
||||
| W-R2 | Authz is workspace-scoped through the product auth stack (BetterAuth/Authentik); watch and butt-in are separate grants; butt-in may be `requires_approval` per workspace policy (workstream P). |
|
||||
| W-R3 | Every attach (watch or butt-in) is audited: who, which agent, when, duration. |
|
||||
| W-R4 | Butt-in visibly flags the session to the agent runtime and other viewers (no silent takeover). |
|
||||
| W-R5 | Contract is stable JSON + streaming frames per F6's "stable JSON contract" requirement, so TUI/CLI and webUI share it. |
|
||||
|
||||
### Web terminal (W2)
|
||||
|
||||
| ID | Requirement |
|
||||
| ---- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| W-R6 | xterm.js view in `apps/web` wired to W1: session list → click → live pane; toggle watch↔butt-in per grants. |
|
||||
| W-R7 | Reconnect-safe (network blips resume the stream), mobile-usable read-only view. |
|
||||
|
||||
### Top-down dashboard (W3)
|
||||
|
||||
| ID | Requirement |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| W-R8 | Fleet dashboard: roster with per-agent state (systemd + tmux + heartbeat join, as `fleet ps` provides), current card/task, last activity, drift/boot-enable warnings. |
|
||||
| W-R9 | Work-in-flight view: backlog cards by state with depends_on DAG rendering; advisory spend per card (NS-2/NS-5). |
|
||||
| W-R10 | Operator controls: PAUSE kill-switch (NS-8), per-agent terminate (killswitch service), queue pause/resume — each gated + audited; destructive controls confirm. |
|
||||
| W-R11 | Existing widget framework (`AgentStatusWidget`, `OrchestratorEventsWidget`, SSE proxy routes) is the starting point, upgraded to the fleet contract rather than rebuilt. |
|
||||
|
||||
## Debate-accepted deltas (2026-07-09) — normative
|
||||
|
||||
| ID | Requirement |
|
||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| W-R12 | **Butt-in is an exclusive lease** with explicit, visible takeover: at most one interactive holder per session at any instant; a second client must take the lease and both parties see the transfer. Input frames are sequenced and deduped so a reconnect never double-sends; a heartbeat/idle timeout closes both the lease and its audit span — audit spans always terminate (extends W-R1/W-R3/W-R4). |
|
||||
| W-R13 | **Structured control plane:** PAUSE, terminate, restart, approve/deny, and queue operations are typed API verbs with RBAC and audit — never bytes typed into a tmux pane. Raw terminal input via butt-in is **rescue-only** and a separately-grantable permission from the control verbs. |
|
||||
| W-R14 | **Break-glass doctrine (documented, not prevented):** SSH + `tmux attach` on the fleet host bypasses W1 and P **by design**; it is inventoried under P-R19, audited post-hoc from host logs, and never treated as a product path. PAUSE additionally has an **on-host file/CLI actuator** so a dead gateway can never lock the operator out of the control that fixes the gateway. |
|
||||
| W-R15 | W3 ships the **trial metric panels** as workspace-scoped product views over product storage: canary machine-flag correctness, approval decision latency, card priority distribution, wake→ack rate, agent-authored memory retrieval fraction, human interaction load, fixed-input probe stability. The X-R9 trial evidence pack cites these panels; until W3 exists, day-1 emission targets MALS (L1). Workspace isolation fixture: workspace-2 admin sees zero workspace-1 rows. |
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. From a browser (desktop + phone), the user watches a live coder-agent pane read-only, then butt-ins with the right grant, types a message, detaches; agent session continues; audit log shows both.
|
||||
2. Dashboard reflects an agent crash within one heartbeat interval; PAUSE flip halts dispatch within one tick (AC-NS-5) from the UI.
|
||||
3. A user without butt-in grant can watch but cannot type (enforced server-side).
|
||||
4. Two clients contend for butt-in: exactly one holds the lease at any instant, the takeover is visible to both, and after a forced reconnect the input stream shows zero duplicated or interleaved frames (W-R12).
|
||||
5. With the gateway stopped, the operator PAUSEs the fleet via the on-host actuator; the bypass appears in the post-hoc audit (W-R14).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Replacing tmux as the session substrate (tmux remains the transport; web is a view).
|
||||
- Cross-host federation of the dashboard (rides the existing federation workstream later, per upstream note "Phase 5 rides federation").
|
||||
|
||||
## Assumptions
|
||||
|
||||
- ASSUMPTION: pty bridging terminates at the gateway on the fleet host (web1), not in `apps/web`; Next.js only speaks WebSocket to the gateway.
|
||||
- ASSUMPTION: the jarvis-brain dashboard's node-pty/xterm work (`dashboard/server/terminal.ts`) serves as reference implementation only; code is not ported wholesale into the multi-tenant product without the authz layer above.
|
||||
62
docs/fleet/proposals/mosaic-platform-prd/README.md
Normal file
62
docs/fleet/proposals/mosaic-platform-prd/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Mosaic Platform PRD — Jarvis HMI + Hermes Decommission (DRAFT for ratification)
|
||||
|
||||
**Date:** 2026-07-09 · **Author:** proto-Jarvis session with Jason · **Status:** Decisions D1–D12 **ratified** (fixed inputs); implementing PRD structure **DRAFT** — refined 2026-07-09 post-review, then stress-tested by a 9-persona × 2-round debate panel the same day; all 24 panel findings dispositioned and folded (see `DEBATE-FINDINGS.md`; one item **OPEN — Jason**: gate superstructure, §3.1)
|
||||
**Target home:** `mosaicstack/stack` → `docs/fleet/` (NORTH_STAR.yaml additions + per-phase PRDs)
|
||||
|
||||
> **For the homelab orchestrator:** D1–D12 below are settled constraints, not open questions — do not reopen them. What is under review is only the _implementation_ (workstreams, goals, sequencing) that realizes them.
|
||||
> **Execution:** hand to the **homelab orchestrator** as orchestrated missions once ratified (D12). Land in `docs/fleet/` from `origin/main` — the `/src/mosaic-stack` clone on web1 is 5 months stale and must not be the base. The USC/web1 environment is out of scope for the trial; its cutover (workstream X applied to web1's Hermes + mos-claude) is a post-trial phase.
|
||||
|
||||
## Ratified decisions (Jason, 2026-07-09)
|
||||
|
||||
| # | Decision |
|
||||
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| D1 | Jarvis conversation channel is **Matrix-first** — no #jarvis Discord channel is ever created. |
|
||||
| D2 | Mosaic absorbs **all four** Hermes functions before decommission: messaging bridge, Kanban/task board, permission relay, multi-platform reach. |
|
||||
| D3 | Task handoff: **native Mosaic Backlog is the record; Gitea/GitHub/local-kanban attach as bidirectional sync adapters** (upholds ASM-1). |
|
||||
| D4 | Jarvis executes **PA ops directly** (email, calendar, tasks, knowledge, tickets, research); all code/infra/fleet work is delegated to Mos via the backlog. |
|
||||
| D5 | Mosaic Stack is a **product from day one** — multi-user, Authentik tenancy, per-workspace isolation. |
|
||||
| D6 | Multi-platform reach via **Matrix + mautrix bridges** (telegram/signal/whatsapp/slack/discord); agents only ever speak Matrix. |
|
||||
| D7 | webUI builds on the existing `mosaicstack/stack` monorepo (`apps/web`), realizing the already-scoped F6 phase. |
|
||||
| D8 | **PRD first, then Mos runs it** as orchestrated missions. |
|
||||
| D9 | jarvis-brain flat-file data **migrates into the product as tenant #1** (workspace = Jason); brain.py/flat files retire after cutover. |
|
||||
| D10 | PRD form: **extend NORTH_STAR.yaml + per-phase docs in docs/fleet/** (NS-1 compliant). |
|
||||
| D11 | Jarvis runs **Opus**; Fable stays exclusive to Mos per the standing cost directive. Model tier is a persona/profile field. |
|
||||
| D12 | **Trial in the homelab** (the proper mosaic-fleet deployment, built by the homelab agents from `origin/main`), NOT at USC. The USC/web1 environment runs the primitive-era implementation (`mos-claude.service`, Hermes, jarvis-brain boards) and adopts only after the homelab trial validates. Jason relays this PRD to the homelab agent for implementation. |
|
||||
|
||||
## Artifacts in this draft
|
||||
|
||||
| File | Content |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `north-star-additions.yaml` | Proposed NORTH_STAR.yaml merge: NS-10…NS-14, workstreams J/K/W/P/Q/X + **M** (memory subsystem) + **L** (logging/telemetry), goal cards with DAG |
|
||||
| `PRD-jarvis-main-agent.md` | Workstream J — the HMI main agent |
|
||||
| `PRD-permission-relay.md` | Workstream P — human-in-the-loop approvals |
|
||||
| `PRD-webui-fleet-control.md` | Workstream W — tmux pop-in + top-down view (realizes F6) |
|
||||
| `PRD-backlog-providers.md` | Workstream Q — provider sync adapters |
|
||||
| `PRD-hermes-decommission.md` | Workstream X — parity checklist, tenant-1 migration, cutover |
|
||||
| `DEBATE-FINDINGS.md` | 2026-07-09 debate pass — all 24 findings dispositioned, open-disagreement judgment calls, process rules |
|
||||
|
||||
Workstreams **M** (M1 enhanced memory subsystem) and **L** (L1 restore MALS · L2 Mosaic-native log ingestion · L3 anonymous agentic telemetry, NS-14) carry no standalone PRD doc: M1's scope is defined by its consumers (X-R7 retrieval eval, J memory rules) and L's by the MALS lineage (`~/src/mals`, infrastructure #135); both live as goal cards in the YAML.
|
||||
|
||||
Workstream K (Matrix connector + mautrix bridges) intentionally has no new PRD doc: it extends the existing `docs/fleet/f4-matrix-connector.md`; its deltas are captured as K-goals in the YAML additions and referenced from the J/P/X PRDs.
|
||||
|
||||
## Relationship to existing upstream work
|
||||
|
||||
- Fleet CLI, persona library, system-type profiles (H1–H4), supervisor/dispatch (B), native backlog (A): **already exist or in flight upstream — not re-specified here.**
|
||||
- `f4-matrix-connector.md`: K1 = its Phase 2 implementation (verified present on `origin/main`; Phase 1 already ships the **tmux-default connector** that serves the P1 CLI channel per J-R14a). K2 (mautrix bridges) is additive infra.
|
||||
- F6 (webUI hooks) in `PRD-fleet-suite.md`: realized by workstream W (verified present on `origin/main`).
|
||||
- `docs/3-architecture/guard-rails-capability-permissions.md`: original design snapshot for workstream P — **not present on `origin/main`** (lives only in the stale `/src/mosaic-stack` clone). Its essential model is therefore folded into `PRD-permission-relay.md`, which is now the **self-contained authoritative spec** for P; the old path is cited as historical origin only.
|
||||
|
||||
## Gate Zero — pre-ratification checklist (debate P0 #3/#4, §3.7)
|
||||
|
||||
Ratification does not proceed on presumption. Before D-level sign-off, run and record:
|
||||
|
||||
1. **Upstream-artifact audit.** Every artifact this PRD load-bears on is verified `present @ <SHA>` on `origin/main` or marked **MISSING → goal card**. Presumed-missing rows already carded by this draft: **M1** (enhanced memory subsystem — nothing on main provides vector DB + memory service today), **J6** (event/wake router), **K3** (Matrix push pipeline + homeserver ops). If an audit finds one of these actually exists, retire the card and pin the SHA; if it finds _another_ gap, card it — no silent presumption in either direction.
|
||||
2. **Hermes traffic audit** (§3.7): per-platform bridge traffic + per-MCP-tool call counts over a trailing window. Platforms with live traffic become the must-have K2 subset gating X3; zero-traffic platforms become post-trial nice-to-haves. Feeds the X-R1 parity checklist and its low-n drill list.
|
||||
3. **Sandbox dispatch-test** of the DAG: load `north-star-additions.yaml` into a sandbox backlog and verify the dispatcher's actual claim order respects every `depends_on` edge (phases are documentation; edges are law).
|
||||
|
||||
## Process rules adopted from the debate pass (normative for this PRD's execution)
|
||||
|
||||
- **Conflict register:** a discovered contradiction between spec documents **blocks the affected requirement** (not the whole mission) until a register entry records the resolution. "Alert, don't auto-resolve" is promoted from data conflicts to spec conflicts. A CI lint greps for register references in amended docs.
|
||||
- **DoD line on every goal card:** each card states its definition-of-done additions — runbook updated, health-floor alert registered, AGENTS.md touched — so operational debt can't silently accrue card-by-card.
|
||||
- **Silent-roster rule:** in any panel/review round, a member's silence records their open findings as **open items, never consensus** (instance: Codex's unanswered principal-resolution and policy-evaluation-time findings were folded as P-R16/P-R17, explicitly not consensus-resolved).
|
||||
- **Immutable spec + typed amendments** (J-R20) applies to these PRD docs themselves post-ratification: changes arrive as amendments with a revision counter, and sign-offs pin the revision.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Proposed additions to docs/fleet/NORTH_STAR.yaml — DRAFT (Jason ratification pending, 2026-07-09)
|
||||
#
|
||||
# Merge these entries into the existing NORTH_STAR.yaml sections, then regenerate
|
||||
# NORTH_STAR.md via renderNorthStarMarkdown (packages/mosaic/src/commands/fleet.ts).
|
||||
# Ids chosen to avoid collision with existing workstreams A–H and goals.
|
||||
|
||||
standing_objectives:
|
||||
- id: NS-10
|
||||
text: >-
|
||||
Every Mosaic workspace runs exactly one always-on HMI main agent (default
|
||||
alias "Jarvis", unit mosaic-agent@main) that owns all human conversation
|
||||
and user-level personal-assistant work (ideas, schedule, email, tasks,
|
||||
knowledge) and delegates engineering/research/ops missions to the
|
||||
orchestrator as Mosaic Backlog cards. It is a Level-0 orchestrator:
|
||||
it accomplishes work through delegation and subagents, never by executing
|
||||
coding/infra tasks itself, and it runs on model capacity separate from the
|
||||
orchestrator so its conversational latency is isolated from fleet load. The
|
||||
main agent never executes fleet work itself and never interrupts the
|
||||
orchestrator for status. Exactly-one-per-workspace is enforced by a
|
||||
workspace lease (J-R16), not by convention.
|
||||
- id: NS-11
|
||||
text: >-
|
||||
Irreversible or externally-visible agent actions pass a human-in-the-loop
|
||||
permission relay (approve/deny from chat or webUI) governed by
|
||||
per-capability guard rails; prepare freely, execute with approval.
|
||||
- id: NS-12
|
||||
text: >-
|
||||
The Mosaic Backlog remains the sole backlog of record; external providers
|
||||
(Gitea, GitHub, local kanban, …) attach as bidirectional sync adapters,
|
||||
never as the record.
|
||||
- id: NS-13
|
||||
text: >-
|
||||
Hermes is fully decommissioned once Mosaic reaches verified parity on
|
||||
transport (Matrix connector), task board (native backlog + webUI),
|
||||
permission relay, and multi-platform reach (mautrix bridges).
|
||||
- id: NS-14
|
||||
text: >-
|
||||
Mosaic Stack accepts inbound structured error/log reporting from every
|
||||
agent and install (MALS-lineage logging service); agentic-efficiency
|
||||
telemetry is optional, opt-in, and anonymous by construction — no IP or
|
||||
PII is captured or derivable from the ingestion path.
|
||||
|
||||
success_criteria:
|
||||
- id: AC-NS-8
|
||||
text: >-
|
||||
A user converses with the main agent in its channel while the
|
||||
orchestrator is under full load; because the main agent runs on separate
|
||||
model capacity (distinct credential/quota pools pinned in the J1
|
||||
profile), its response latency is unaffected and the orchestrator
|
||||
receives zero conversational traffic. Measured, not vibes: scripted
|
||||
suite, >=30 interleaved turns, TTFT p95 <= 1.2x idle baseline with
|
||||
bootstrap CI.
|
||||
- id: AC-NS-9
|
||||
text: >-
|
||||
A mission agreed in the main-agent conversation appears as a backlog card
|
||||
set with acceptance criteria, is drained by the orchestrator without
|
||||
chat-level handoff, and its completion is reported back to the user by the
|
||||
main agent from board/heartbeat state alone.
|
||||
- id: AC-NS-10
|
||||
text: >-
|
||||
An action listed as requires_approval executes only after an explicit
|
||||
human approve from Matrix or webUI; deny and timeout paths leave the
|
||||
system unchanged and audited.
|
||||
- id: AC-NS-11
|
||||
text: >-
|
||||
With Hermes stopped, no fleet or main-agent capability regresses
|
||||
(transport, board, approvals, multi-platform reach all served by Mosaic).
|
||||
|
||||
workstreams:
|
||||
- id: J
|
||||
title: HMI main agent ("Jarvis") — persona, PA toolchain, delegation contract
|
||||
- id: K
|
||||
title: Connectors & multi-platform reach — F4 Matrix implementation + mautrix bridges
|
||||
- id: W
|
||||
title: webUI fleet control — tmux pop-in, top-down view (realizes F6)
|
||||
- id: P
|
||||
title: Permission relay — capability guard rails + human approval queue
|
||||
- id: Q
|
||||
title: Backlog provider sync adapters — Gitea/GitHub/local kanban
|
||||
- id: X
|
||||
title: Hermes decommission & tenant-1 migration
|
||||
- id: M
|
||||
title: Enhanced memory subsystem — vector DB + memory service + provenance/tombstones (Gate Zero MISSING artifact)
|
||||
- id: L
|
||||
title: Logging & telemetry — MALS-lineage inbound error reporting + optional anonymous efficiency telemetry
|
||||
|
||||
goals:
|
||||
# J — HMI main agent
|
||||
- id: J1
|
||||
title: Main-agent persona + profile — instantiate personal-assistant system type as mosaic-agent@main (alias Jarvis), model tier a profile field (Opus default)
|
||||
phase: 1
|
||||
priority: must-have
|
||||
depends_on: [H2]
|
||||
# J2 split (debate P0 #2): phase-1 Jarvis must not hold an external-write
|
||||
# credential path before the permission relay (P2) exists — NS-11 by DAG.
|
||||
- id: J2a
|
||||
title: PA toolchain (workspace-internal) — tasks, events, knowledge, ideas executed directly against the product API in the user's workspace; no external credentials provisioned
|
||||
phase: 1
|
||||
priority: must-have
|
||||
depends_on: [J1]
|
||||
- id: J2b
|
||||
title: PA toolchain (external integrations) — email, external calendars, helpdesk via workspace-scoped integrations; credentials gateway-held; requires_approval routes through the relay
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: [J2a, P2]
|
||||
- id: J3
|
||||
title: Delegation contract — main agent authors mission cards (goal, acceptance criteria, budget advisory) onto the backlog; orchestrator drains; no chat-level handoff
|
||||
phase: 1
|
||||
priority: must-have
|
||||
depends_on: [J1, A3a]
|
||||
- id: J4
|
||||
title: Passive fleet observability — main agent answers status from heartbeats, fleet ps JSON, and board state; zero orchestrator interrupts
|
||||
phase: 1
|
||||
priority: must-have
|
||||
depends_on: [J1, B1]
|
||||
- id: J5
|
||||
title: Main-agent Matrix room via OrchestratorConnector(matrix)
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: [J1, K1]
|
||||
- id: J6
|
||||
title: Shared event/wake router — level-triggered reconciler over durable state (heartbeats, cards, approvals) with hysteretic per-condition suppression; templated provenance-tagged wake turns; per-agent polling forbidden (Gate Zero MISSING artifact; J-R13 depends on it)
|
||||
phase: 1
|
||||
priority: must-have
|
||||
depends_on: [J1]
|
||||
|
||||
# K — connectors & reach (extends f4-matrix-connector.md)
|
||||
- id: K1
|
||||
title: Matrix connector implementation — CS-API client factory per f4 Phase 2, self-hosted homeserver
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: []
|
||||
# K2 scope (debate §3.7): a pre-ratification Hermes traffic audit narrows the
|
||||
# must-have bridge subset to platforms with live traffic; only that subset
|
||||
# gates X3 — remaining bridges are genuinely optional post-X3.
|
||||
- id: K2
|
||||
title: mautrix bridge deployment (telegram/signal/whatsapp/slack/discord) as GitOps-managed infra; agents speak only Matrix; must-have subset = platforms carrying live Hermes traffic per Gate Zero audit
|
||||
phase: 3
|
||||
priority: should-have
|
||||
depends_on: [K1]
|
||||
- id: K3
|
||||
title: Push pipeline (Sygnal or equivalent) + homeserver ops — monitored delivery checks, health-floor registration, rehearsed backup/restore (Gate Zero MISSING artifact; P2 phone-approval AC depends on it)
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: [K1]
|
||||
|
||||
# W — webUI fleet control (realizes F6)
|
||||
- id: W1
|
||||
title: Gateway pty/tmux attach service — read-only watch and interactive butt-in verbs, workspace-scoped authz, audit log
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: []
|
||||
- id: W2
|
||||
title: xterm.js session view in apps/web wired to W1 (watch + butt-in)
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: [W1]
|
||||
- id: W3
|
||||
title: Top-down fleet dashboard — roster, heartbeats, cards in flight, advisory spend, PAUSE control
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: [B1]
|
||||
|
||||
# P — permission relay
|
||||
- id: P1
|
||||
title: Capability guard-rails engine — resource:action grants, permission levels, requires_approval list per integration
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: []
|
||||
- id: P2
|
||||
title: Approval queue + approve/deny from the Matrix room (timeout = deny; full audit)
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: [P1, K1]
|
||||
- id: P3
|
||||
title: Approval surface in webUI (pending queue, one-click approve/deny)
|
||||
phase: 3
|
||||
priority: should-have
|
||||
depends_on: [P1, W3]
|
||||
|
||||
# Q — backlog provider sync adapters
|
||||
- id: Q1
|
||||
title: Provider adapter interface + Gitea adapter (bidirectional card↔issue sync; native backlog stays record)
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: [A2]
|
||||
- id: Q2
|
||||
title: GitHub adapter
|
||||
phase: 3
|
||||
priority: should-have
|
||||
depends_on: [Q1]
|
||||
- id: Q3
|
||||
title: Local kanban surface — webUI board view over the native backlog (no external provider required)
|
||||
phase: 2
|
||||
priority: should-have
|
||||
depends_on: [A2, W3]
|
||||
|
||||
# X — Hermes decommission & tenant-1 migration
|
||||
- id: X1
|
||||
title: Hermes parity checklist + cutover plan (transport, board, approvals, reach) with rollback
|
||||
phase: 3
|
||||
priority: must-have
|
||||
depends_on: [K1, P2, Q1]
|
||||
# jarvis-brain is the P0 (prototype) Mosaic Stack; migration targets the proper
|
||||
# stack storage layer. Storage authority (debate P0 #1): Postgres is
|
||||
# authoritative for ALL product entities; the flat-file backend is a derived,
|
||||
# regenerated, READ-ONLY projection — never a second writable store.
|
||||
- id: X2
|
||||
title: 'Tenant-1 migration — P0 (jarvis-brain) into the proper Mosaic Stack: (a) PA data (projects/tasks/events/knowledge) into the Jason workspace, (b) agent memory/runbooks into the enhanced memory subsystem M1 (kept live, not frozen); jarvis-brain retires read-only only after both are verified'
|
||||
phase: 3
|
||||
priority: must-have
|
||||
depends_on: [J2a, P2, M1]
|
||||
- id: X3
|
||||
title: Hermes decommission — stop and remove Hermes services after AC-NS-11 verified; pre-stop evidence snapshot (logs/config/callback inventory + checksum) is a machine gate
|
||||
phase: 4
|
||||
priority: must-have
|
||||
depends_on: [X1, X2, K2]
|
||||
|
||||
# M — enhanced memory subsystem (Gate Zero: cited by X2(b) but not built by
|
||||
# any prior workstream — this card closes that gap)
|
||||
- id: M1
|
||||
title: Enhanced memory subsystem — vector DB + memory service with mandatory provenance (human/agent/external), source-grounded retrieval preference, tombstones + re-embedding on source update, normative/parametric corpus split
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: []
|
||||
|
||||
# L — logging & telemetry (Jason 2026-07-09; NS-14). MALS (~/src/mals) is the
|
||||
# P0 of this capability; restoring it is infra work tracked as
|
||||
# infrastructure#135 (k3s migration landed without an Ingress).
|
||||
- id: L1
|
||||
title: MALS restored & exposed — k3s Ingress for mals.mosaicstack.dev, health verified, authenticated write smoke-tested (infrastructure#135); trial day-1 metric emission targets MALS until W3 panels exist
|
||||
phase: 1
|
||||
priority: must-have
|
||||
depends_on: []
|
||||
- id: L2
|
||||
title: Mosaic-native log ingestion — gateway/API endpoint for structured inbound error reporting from agents and installs (MALS-compatible schema; levels, categories, trace ids), workspace-scoped keys
|
||||
phase: 2
|
||||
priority: must-have
|
||||
depends_on: [L1]
|
||||
- id: L3
|
||||
title: Anonymous agentic-efficiency telemetry — opt-in, aggregate-only, no IP/PII captured or derivable at ingestion (NS-14); feeds W3 trend panels
|
||||
phase: 3
|
||||
priority: should-have
|
||||
depends_on: [L2, W3]
|
||||
|
||||
assumptions:
|
||||
- id: ASM-5
|
||||
vetoable: true
|
||||
text: >-
|
||||
The main agent initially runs on the homelab fleet host alongside the
|
||||
orchestrator under mosaic-agent@main.service; host placement is a config
|
||||
field, not a code assumption. Host co-location does NOT imply shared
|
||||
inference: Jarvis (Opus) and Mos (Fable) hold separate model capacity/quota
|
||||
so orchestrator load cannot degrade conversational latency (AC-NS-8).
|
||||
- id: ASM-6
|
||||
vetoable: true
|
||||
text: >-
|
||||
Product multi-tenancy at MVP means self-hosted installs with multiple
|
||||
workspaces per install (Authentik OIDC); per-tenant isolated FLEETS
|
||||
(agents per workspace) are post-MVP.
|
||||
- id: ASM-7
|
||||
vetoable: true
|
||||
text: >-
|
||||
mautrix bridges are deployed as infrastructure (GitOps), not as Mosaic
|
||||
application code; Mosaic's only conversational protocol is Matrix.
|
||||
- id: ASM-8
|
||||
vetoable: true
|
||||
text: >-
|
||||
During migration (before X3), Hermes remains running untouched; no
|
||||
Hermes-dependent capability is removed until its Mosaic replacement is
|
||||
verified in production.
|
||||
- id: ASM-9
|
||||
vetoable: true
|
||||
text: >-
|
||||
The trial environment is the homelab fleet deployment (D12). Environments
|
||||
running the primitive-era implementation (USC/web1: mos-claude.service,
|
||||
Hermes, jarvis-brain boards) are untouched during the trial; workstream X
|
||||
executes there as a post-trial adoption phase, environment by
|
||||
environment.
|
||||
60
docs/scratchpads/462-fed-m3-04-scope-service.md
Normal file
60
docs/scratchpads/462-fed-m3-04-scope-service.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Scratchpad — FED-M3-04 Scope Service
|
||||
|
||||
## Objective
|
||||
|
||||
Implement `apps/gateway/src/federation/server/scope.service.ts` for the M3 inbound federation scope-enforcement pipeline.
|
||||
|
||||
## Scope / Constraints
|
||||
|
||||
- Task: FED-M3-04, issue #462.
|
||||
- Branch: `feat/federation-m3-scope-service` from `origin/main` @ 0.0.48.
|
||||
- Pure service: no direct DB access; native RBAC/data access is injected per evaluation call.
|
||||
- Reuse `parseFederationScope` from M2-03.
|
||||
- Workers do not edit `docs/federation/TASKS.md` per repo AGENTS.md.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Resource allowlist and `excluded_resources` enforced.
|
||||
2. Native RBAC evaluated as `subjectUserId` through an injected evaluator.
|
||||
3. Scope filter intersection supports `include_teams` and `include_personal` without widening native RBAC.
|
||||
4. `max_rows_per_query` caps requested limits.
|
||||
5. Service returns `{ allowed: true, filter }` or a structured deny reason usable by M4 audit.
|
||||
6. Unit tests cover every deny path.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Inspect existing federation scope/schema/auth guard contracts.
|
||||
2. Add pure `FederationScopeService` plus typed result/filter/deny interfaces.
|
||||
3. Add focused unit tests for happy paths, filter intersection, row cap, and deny paths.
|
||||
4. Export/register service for future verb controllers.
|
||||
5. Run situational tests, baseline gates, code review, then PR.
|
||||
|
||||
## Budget
|
||||
|
||||
- Provided model tier: sonnet.
|
||||
- Estimate from task row: 10K tokens.
|
||||
- Working cap assumption: keep implementation focused to FED-M3-04 surfaces only.
|
||||
|
||||
## Progress
|
||||
|
||||
- Intake complete; dirty base worktree avoided by creating isolated worktree at `/home/jarvis/src/mosaic-mono-v1-fed-m3-04`.
|
||||
- Project PRD and federation task spec reviewed.
|
||||
- Added `FederationScopeService` with structured allow/deny result types and injected native RBAC evaluator contract.
|
||||
- Added unit coverage for happy path, row cap, filter intersection, and every deny path.
|
||||
- Exported/registered the service for upcoming M3 verb controllers.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/__tests__/scope.service.spec.ts` — pass (10 tests before review update; 11 tests after adding include_personal no-leak coverage).
|
||||
- `pnpm build` — pass (23 successful tasks).
|
||||
- `pnpm typecheck` — pass (41 successful tasks; re-run after review update).
|
||||
- `pnpm lint` — pass (23 successful tasks; re-run after review update).
|
||||
- `pnpm format:check` — pass (re-run after review update).
|
||||
- `pnpm test` — pass after starting local `postgres`/`valkey` and running `pnpm --filter @mosaicstack/db db:push` for the DB-backed cross-user isolation suite (41 successful tasks; gateway 477 passed / 11 skipped).
|
||||
- Code review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings.
|
||||
- Security review: `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — risk none, 0 findings.
|
||||
|
||||
## Risks / Blockers
|
||||
|
||||
- Issue #462 is already closed in provider output; likely milestone tracking mismatch. Will still reference #462 in PR body unless orchestrator redirects.
|
||||
- Local full-test setup required `docker compose up -d postgres valkey` + `db:push`; containers were stopped with `docker compose down` after verification.
|
||||
33
docs/scratchpads/561-python-is-python3.md
Normal file
33
docs/scratchpads/561-python-is-python3.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Issue #561 — Bare python on agent hosts
|
||||
|
||||
## Objective
|
||||
|
||||
Make the durable bootstrap/provisioning guidance ensure agent hosts provide a bare `python` command that resolves to Python 3.
|
||||
|
||||
## Scope
|
||||
|
||||
- Add Debian/Ubuntu `python-is-python3` to agent-host prerequisites in bootstrap docs.
|
||||
- Check for actual OS package provisioning scripts and update only if an existing agent-host package install path exists.
|
||||
- Do not touch live host state.
|
||||
- Do not update `docs/TASKS.md`; repo guidance says workers read it but never modify it.
|
||||
|
||||
## Recon
|
||||
|
||||
- Issue #561 confirms repeated `python: command not found` failures from fleet agents that emit `python foo.py`.
|
||||
- `guides/BOOTSTRAP.md` and `packages/mosaic/framework/guides/BOOTSTRAP.md` are the source and packaged framework copies of the bootstrap guide.
|
||||
- Targeted repo sweep found no agent-host Debian package provisioning script. Existing `apt-get install` hits are CI/test helper paths or unrelated deployment docs.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add a host prerequisite section to both bootstrap guide copies.
|
||||
2. Include `python-is-python3` in the Debian/Ubuntu package list with an issue comment.
|
||||
3. Note the non-Debian equivalent as a `/usr/bin/python -> python3` symlink.
|
||||
4. Validate markdown/diff, run shell syntax checks where applicable, run required review, commit, queue guard, and push.
|
||||
|
||||
## Validation Log
|
||||
|
||||
- `rg` recon: no existing agent-host Debian package provisioning script; only CI/test helper `apt-get install` paths and unrelated deployment docs.
|
||||
- `git diff --check`: passed.
|
||||
- `bash -n packages/mosaic/framework/install.sh tools/install.sh packages/mosaic/framework/tools/bootstrap/init-project.sh packages/mosaic/framework/tools/_scripts/mosaic-bootstrap-repo`: passed. No touched shell scripts.
|
||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`: approved, 0 findings.
|
||||
- `pnpm format:check`: initially blocked because `node_modules` was absent and `prettier` was unavailable; `pnpm install --frozen-lockfile` initially hit an invalid `/root` pnpm store path. Reran install with `--store-dir /home/hermes/agent-work/.pnpm-store`, then `pnpm format:check` passed.
|
||||
52
docs/scratchpads/FED-M3-05-list-verb.md
Normal file
52
docs/scratchpads/FED-M3-05-list-verb.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# FED-M3-05 — Federation List Verb Scratchpad
|
||||
|
||||
## Objective
|
||||
|
||||
Implement `POST /api/federation/v1/list/:resource`.
|
||||
|
||||
## Scope
|
||||
|
||||
- Wire `FederationAuthGuard` → `FederationScopeService` → read-only list query layer.
|
||||
- Apply `max_rows_per_query` row cap and return pagination metadata when truncated.
|
||||
- Tag returned rows with `_source: "local"`.
|
||||
- Keep audit writes deferred to M4.
|
||||
- No request/response body persistence.
|
||||
|
||||
## Base / branch
|
||||
|
||||
- Branch: `feat/federation-m3-verb-list`
|
||||
- Base: `main` after M3-04 scope service merged via PR #672 (`c739256a`).
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- Added `ListController` under `apps/gateway/src/federation/server/verbs/`.
|
||||
- Added `FederationListQueryService` as the read-only query layer and native RBAC evaluator.
|
||||
- Query resources supported in M3 list path:
|
||||
- `tasks`: project/mission scoped tasks visible through personal/team project access.
|
||||
- `notes`: non-empty `mission_tasks.notes` rows visible through personal/team mission access.
|
||||
- `memory`: user-owned `insights` and `preferences` rows.
|
||||
- `credentials` / `api_keys`: denied by native RBAC in M3 even if present in scope; sensitive-resource implementation is not part of FED-M3-05.
|
||||
- Cursor pagination uses an opaque base64url keyset cursor over `(createdAt, id)`; DB reads fetch at most `limit + 1` rows per resource query.
|
||||
- Reviewer isolation fix: `mission_tasks.notes` rows are always constrained by `missionTasks.userId = subjectUserId` and accessible mission IDs; team scope narrows missions but never widens to other users' mission task notes.
|
||||
- Follow-up review fix: memory listing now uses deterministic table-block pagination (`insights` first, then `preferences`) with cursor source metadata, so one table's cursor is never applied to the other.
|
||||
- Follow-up hardening: missing auth-guard context returns a structured federation `unauthorized` envelope; unsupported resources and non-encodable truncated cursors throw instead of silently crashing/truncating.
|
||||
|
||||
## Tests
|
||||
|
||||
- `pnpm --filter @mosaicstack/gateway test -- list.controller.spec.ts list-query.service.spec.ts` — PASS (16 tests, including PGlite regression coverage for team-scoped notes isolation, unauthorized mission notes exclusion, `includePersonal: false`, deterministic memory pagination, missing context envelope, unsupported resource, and cursor encode failure).
|
||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
||||
- `pnpm format:check` — PASS.
|
||||
- `pnpm typecheck` — PASS (41/41 turbo tasks).
|
||||
- `pnpm lint` — PASS (23/23 turbo tasks).
|
||||
- `pnpm --filter @mosaicstack/gateway test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup cannot connect to local PostgreSQL on `localhost:5433`. New list tests pass; failure is outside FED-M3-05.
|
||||
|
||||
## Review evidence
|
||||
|
||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS after follow-up remediation; approve, no findings.
|
||||
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS after follow-up remediation; risk level none, no findings.
|
||||
- Security-review note: read-path audit logging remains intentionally deferred to M4 per orchestrator clarification and FED-M3-05 scope.
|
||||
|
||||
## Risks / follow-up
|
||||
|
||||
- Read-path audit logging remains intentionally deferred to M4.
|
||||
@@ -15,6 +15,22 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
|
||||
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
||||
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
||||
|
||||
## Agent Host Prerequisites
|
||||
|
||||
Agent hosts must provide the Python runtime shape that runtime agents and
|
||||
Mosaic automation assume is present.
|
||||
|
||||
For Debian/Ubuntu hosts:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
# #561: bare python invocations from agents must resolve.
|
||||
sudo apt-get install -y python3 python-is-python3
|
||||
```
|
||||
|
||||
For non-Debian hosts, install the equivalent Python 3 runtime and ensure
|
||||
`/usr/bin/python` resolves to `python3` (for example, via a managed symlink).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -15,6 +15,22 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
|
||||
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
||||
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
||||
|
||||
## Agent Host Prerequisites
|
||||
|
||||
Agent hosts must provide the Python runtime shape that runtime agents and
|
||||
Mosaic automation assume is present.
|
||||
|
||||
For Debian/Ubuntu hosts:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
# #561: bare python invocations from agents must resolve.
|
||||
sudo apt-get install -y python3 python-is-python3
|
||||
```
|
||||
|
||||
For non-Debian hosts, install the equivalent Python 3 runtime and ensure
|
||||
`/usr/bin/python` resolves to `python3` (for example, via a managed symlink).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -87,6 +87,10 @@ message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
|
||||
|
||||
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
|
||||
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
|
||||
- `auto-submit-drafts.sh` — watchdog that flushes stable unsubmitted prompt
|
||||
drafts on a coordinator pane (default target `mos-claude`); run it as a
|
||||
long-lived process alongside the coordinator session.
|
||||
- `agent-send.test.sh` — regression + grammar lock for `agent-send.sh`.
|
||||
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
|
||||
|
||||
## Distribution
|
||||
|
||||
80
packages/mosaic/framework/tools/tmux/auto-submit-drafts.sh
Executable file
80
packages/mosaic/framework/tools/tmux/auto-submit-drafts.sh
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# auto-submit-drafts.sh — watchdog for Claude Code panes that receive channel
|
||||
# messages but leave them as unsubmitted prompt drafts. Intended for Mos only.
|
||||
set -uo pipefail
|
||||
|
||||
TARGET="${1:-mos-claude}"
|
||||
INTERVAL="${INTERVAL:-2}"
|
||||
STABLE_SECONDS="${STABLE_SECONDS:-4}"
|
||||
LOG_PREFIX="[auto-submit-drafts:$TARGET]"
|
||||
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
|
||||
prompt_text() {
|
||||
tmux capture-pane -t "$TARGET" -p 2>/dev/null | python3 -c '
|
||||
import sys, re
|
||||
lines = sys.stdin.read().splitlines()
|
||||
idx = None
|
||||
for i in range(len(lines)-1, -1, -1):
|
||||
if "❯" in lines[i]:
|
||||
idx = i
|
||||
break
|
||||
if idx is None:
|
||||
raise SystemExit
|
||||
parts = []
|
||||
after = lines[idx].split("❯", 1)[1]
|
||||
parts.append(after)
|
||||
for line in lines[idx+1:]:
|
||||
# Stop at Claude Code separator/border lines.
|
||||
if "─" in line or "╰" in line or "╭" in line:
|
||||
break
|
||||
s = line.replace("\u00a0", " ")
|
||||
s = re.sub(r"[\x00-\x1f\x7f]", "", s).strip()
|
||||
if s:
|
||||
parts.append(s)
|
||||
text = " ".join(parts).replace("\u00a0", " ")
|
||||
text = re.sub(r"[\x00-\x1f\x7f]", "", text).strip()
|
||||
print(text)
|
||||
'
|
||||
}
|
||||
|
||||
while true; do
|
||||
if ! tmux has-session -t "$TARGET" 2>/dev/null; then
|
||||
echo "$LOG_PREFIX target missing; waiting" >&2
|
||||
sleep "$INTERVAL"
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
continue
|
||||
fi
|
||||
|
||||
current="$(prompt_text || true)"
|
||||
now="$(date +%s)"
|
||||
|
||||
if [[ -z "$current" ]]; then
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
sleep "$INTERVAL"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$current" != "$last_prompt" ]]; then
|
||||
last_prompt="$current"
|
||||
first_seen="$now"
|
||||
sleep "$INTERVAL"
|
||||
continue
|
||||
fi
|
||||
|
||||
age=$(( now - first_seen ))
|
||||
if (( age >= STABLE_SECONDS )); then
|
||||
echo "$LOG_PREFIX submitting stable draft after ${age}s: ${current:0:120}" >&2
|
||||
tmux send-keys -t "$TARGET" C-j
|
||||
sleep 0.8
|
||||
tmux send-keys -t "$TARGET" C-m
|
||||
sleep 2
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
else
|
||||
sleep "$INTERVAL"
|
||||
fi
|
||||
done
|
||||
@@ -77,10 +77,20 @@ snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g'
|
||||
|
||||
# 1) Paste the body as a bracketed paste so multi-line content does not submit
|
||||
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
|
||||
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b __mosaic_send -
|
||||
# Buffer name MUST be unique per invocation: concurrent senders on the shared
|
||||
# tmux server race a fixed name (load overwrites load, -d deletes underneath),
|
||||
# cross-delivering or dropping messages — bit the fleet on the 2026-07-09
|
||||
# simultaneous restart (briefs swapped between sessions).
|
||||
BUF="__mosaic_send_$$_$(date +%s%N)"
|
||||
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
|
||||
# -p = bracketed paste when the client supports it; fall back if not.
|
||||
"${tmux_cmd[@]}" paste-buffer -d -p -b __mosaic_send -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
||||
|| "${tmux_cmd[@]}" paste-buffer -d -b __mosaic_send -t "$EFFECTIVE_TARGET"
|
||||
"${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
||||
|| "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET" \
|
||||
|| "${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null
|
||||
# ^ -d deletes the buffer only on a SUCCESSFUL paste; if both attempts fail
|
||||
# (e.g. the target vanished since the liveness check), delete explicitly —
|
||||
# named buffers are exempt from tmux's buffer-limit eviction, so orphans
|
||||
# would otherwise accumulate forever.
|
||||
sleep 0.5
|
||||
|
||||
# 2) Submit, then verify; flush with another Enter if it is still a draft.
|
||||
|
||||
@@ -47,4 +47,32 @@ if capture_default | grep -qF "agent socket hello"; then
|
||||
fail "agent-send.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
# Concurrency: parallel senders on one server must not cross-deliver or drop.
|
||||
# Locks the unique-per-invocation paste buffer (a fixed buffer name raced:
|
||||
# load overwrote load, -d deleted underneath — messages swapped between panes).
|
||||
CONC_N=5
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'bash --noprofile --norc -i'
|
||||
done
|
||||
pids=()
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=conc-$i" -m "CONCPAYLOAD-${i}-END" >/dev/null &
|
||||
pids+=($!)
|
||||
done
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || fail "concurrent send-message.sh invocation exited non-zero"
|
||||
done
|
||||
sleep 0.2
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p)
|
||||
printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${i}-END" \
|
||||
|| fail "concurrent send dropped payload for pane conc-$i"
|
||||
for j in $(seq 1 "$CONC_N"); do
|
||||
[ "$j" = "$i" ] && continue
|
||||
if printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${j}-END"; then
|
||||
fail "concurrent send cross-delivered payload $j to pane conc-$i"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo "ok - named tmux socket send tools"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
acquireRestartLock,
|
||||
addAgentToRoster,
|
||||
buildAgentSendCommand,
|
||||
buildAgentWatchAttachCommand,
|
||||
@@ -45,6 +46,8 @@ import {
|
||||
removeAgentFromRoster,
|
||||
resolveFleetPaths,
|
||||
resolvePresetFilename,
|
||||
restartLockPath,
|
||||
RESTART_LOCK_STALE_MS,
|
||||
RUNTIME_ACCEPTABLE_COMMANDS,
|
||||
serializeRosterToYaml,
|
||||
VERIFY_DEFAULT_TIMEOUT_MS,
|
||||
@@ -678,6 +681,364 @@ describe('fleet command construction', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('waits for an in-flight restart to clear before relaunching (re-entry guard)', async () => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
|
||||
// Simulate another `mosaic fleet restart` process mid-teardown: a fresh lock
|
||||
// (recent timestamp, so it is NOT treated as stale) already held.
|
||||
const lockPath = restartLockPath(home);
|
||||
await mkdir(dirname(lockPath), { recursive: true });
|
||||
await writeFile(lockPath, `4242\n${Date.now()}\n`);
|
||||
|
||||
const events: string[] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
events.push(`run:${args[args.length - 1]}`);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
// The injected sleep stands in for time passing while we wait; the in-flight
|
||||
// restart "finishes" (releases its lock) after the first poll.
|
||||
let sleeps = 0;
|
||||
const sleepFn: SleepFn = async () => {
|
||||
sleeps += 1;
|
||||
events.push(`sleep:${sleeps}`);
|
||||
await rm(lockPath, { force: true });
|
||||
};
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
|
||||
|
||||
// It must have waited at least once before issuing any systemctl restart.
|
||||
expect(sleeps).toBeGreaterThan(0);
|
||||
const firstSleep = events.findIndex((e) => e.startsWith('sleep:'));
|
||||
const firstRun = events.findIndex((e) => e.startsWith('run:'));
|
||||
expect(firstSleep).toBeGreaterThanOrEqual(0);
|
||||
expect(firstRun).toBeGreaterThan(firstSleep);
|
||||
|
||||
// And it still performs the full restart once the lock clears.
|
||||
expect(events).toContain('run:mosaic-tmux-holder.service');
|
||||
expect(events).toContain('run:mosaic-agent@coder0.service');
|
||||
|
||||
// The lock is released after the restart completes.
|
||||
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('breaks a stale restart lock and proceeds without waiting', async () => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
|
||||
// A lock left behind by a crashed owner: timestamp older than the stale window.
|
||||
const lockPath = restartLockPath(home);
|
||||
await mkdir(dirname(lockPath), { recursive: true });
|
||||
await writeFile(lockPath, `4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\n`);
|
||||
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const sleepFn = vi.fn<SleepFn>(async () => {});
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
|
||||
|
||||
// Stale lock is broken immediately — no waiting.
|
||||
expect(sleepFn).not.toHaveBeenCalled();
|
||||
expect(calls).toEqual([
|
||||
['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
|
||||
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
|
||||
]);
|
||||
// The stale lock is gone once the restart completes.
|
||||
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('releases the restart lock so a subsequent restart is not blocked', async () => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const sleepFn = vi.fn<SleepFn>(async () => {});
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
|
||||
|
||||
// Two sequential restarts both run fully and neither has to wait.
|
||||
expect(sleepFn).not.toHaveBeenCalled();
|
||||
expect(calls).toEqual([
|
||||
['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
|
||||
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
|
||||
['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
|
||||
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
|
||||
]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('guards the single-agent restart path behind the in-flight restart lock', async () => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
|
||||
// A full restart is mid-flight (lock held); a single-agent restart re-enters.
|
||||
const lockPath = restartLockPath(home);
|
||||
await mkdir(dirname(lockPath), { recursive: true });
|
||||
await writeFile(lockPath, `4242\n${Date.now()}\n`);
|
||||
|
||||
const events: string[] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
events.push(`run:${args[args.length - 1]}`);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
let sleeps = 0;
|
||||
const sleepFn: SleepFn = async () => {
|
||||
sleeps += 1;
|
||||
events.push(`sleep:${sleeps}`);
|
||||
await rm(lockPath, { force: true });
|
||||
};
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart', 'coder0']);
|
||||
|
||||
// The single-agent restart waits for the in-flight restart before acting.
|
||||
expect(sleeps).toBeGreaterThan(0);
|
||||
const firstSleep = events.findIndex((e) => e.startsWith('sleep:'));
|
||||
const firstRun = events.findIndex((e) => e.startsWith('run:'));
|
||||
expect(firstSleep).toBeGreaterThanOrEqual(0);
|
||||
expect(firstRun).toBeGreaterThan(firstSleep);
|
||||
// Only the named agent is restarted; the holder is untouched.
|
||||
expect(events).toContain('run:mosaic-agent@coder0.service');
|
||||
expect(events).not.toContain('run:mosaic-tmux-holder.service');
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let a timed-out owner drop a lock another restart broke and re-owned', async () => {
|
||||
const home = await tempDir();
|
||||
const runDir = join(home, 'fleet', 'run');
|
||||
await mkdir(runDir, { recursive: true });
|
||||
const lockPath = restartLockPath(home);
|
||||
const tokenOf = async (): Promise<string> => {
|
||||
const raw = await readFile(lockPath, 'utf8');
|
||||
return raw.split('\n')[2]?.trim() ?? '';
|
||||
};
|
||||
const sleepFn = vi.fn<SleepFn>(async () => {});
|
||||
|
||||
// R1 acquires the lock and begins a restart that then hangs.
|
||||
const r1 = await acquireRestartLock(home, sleepFn);
|
||||
const tokenR1 = await tokenOf();
|
||||
expect(tokenR1).not.toBe('');
|
||||
|
||||
// The hung R1 leaves a stale lock: rewrite its timestamp into the past while
|
||||
// preserving R1's token — exactly the on-disk state a stuck owner leaves.
|
||||
await writeFile(lockPath, `4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\n${tokenR1}\n`);
|
||||
|
||||
// R2 re-enters, sees the stale lock, and atomically takes ownership.
|
||||
const r2 = await acquireRestartLock(home, sleepFn);
|
||||
const tokenR2 = await tokenOf();
|
||||
expect(tokenR2).not.toBe(tokenR1);
|
||||
expect(sleepFn).not.toHaveBeenCalled();
|
||||
|
||||
// R1 finally finishes and releases. It must NOT delete R2's lock — otherwise
|
||||
// a third restart (R3) could acquire and interleave with R2 still running.
|
||||
await r1.release();
|
||||
expect(await tokenOf()).toBe(tokenR2);
|
||||
|
||||
// R2 releases cleanly and the lock is gone.
|
||||
await r2.release();
|
||||
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('lets only one of several concurrent breakers proceed past a stale lock', async () => {
|
||||
const home = await tempDir();
|
||||
const lockPath = restartLockPath(home);
|
||||
await mkdir(dirname(lockPath), { recursive: true });
|
||||
|
||||
// A stale lock left by a crashed owner: every concurrent re-entrant restart
|
||||
// will judge it stale and try to break it at the same instant. Breaking must
|
||||
// NOT grant ownership — only the atomic re-create may — so exactly one
|
||||
// contender can ever hold the lock at a time. (The v2 fix wrote our own token
|
||||
// during the break and read it back, so two breakers each saw their own token
|
||||
// and BOTH proceeded; this guards that regression.)
|
||||
await writeFile(
|
||||
lockPath,
|
||||
`4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\nstale-owner-token\n`,
|
||||
);
|
||||
|
||||
// Yielding sleep so a waiting contender lets the current owner finish and
|
||||
// release before it re-contends, instead of spinning the microtask queue.
|
||||
const sleepFn: SleepFn = async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
};
|
||||
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const tokens: string[] = [];
|
||||
const tokenOf = async (): Promise<string> => {
|
||||
const raw = await readFile(lockPath, 'utf8');
|
||||
return raw.split('\n')[2]?.trim() ?? '';
|
||||
};
|
||||
|
||||
// One "restart" = acquire the lock, do work in the critical section, release.
|
||||
const restartOnce = async (): Promise<void> => {
|
||||
const guard = await acquireRestartLock(home, sleepFn);
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
// Record the token we own while we hold it, then yield to interleave with
|
||||
// any other contender that might (wrongly) believe it owns the lock too.
|
||||
tokens.push(await tokenOf());
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
active -= 1;
|
||||
await guard.release();
|
||||
};
|
||||
|
||||
try {
|
||||
// Three breakers race the single stale lock simultaneously.
|
||||
await Promise.all([restartOnce(), restartOnce(), restartOnce()]);
|
||||
|
||||
// Mutual exclusion held: never two owners at once despite concurrent breaks.
|
||||
expect(maxActive).toBe(1);
|
||||
// Each acquire owned with its own distinct token — no two ever shared it.
|
||||
expect(new Set(tokens).size).toBe(3);
|
||||
// The lock is fully released at the end.
|
||||
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('lets exactly one of two breakers take over a stale lock while the other waits', async () => {
|
||||
const home = await tempDir();
|
||||
const lockPath = restartLockPath(home);
|
||||
await mkdir(dirname(lockPath), { recursive: true });
|
||||
|
||||
// A single stale lock both contenders will judge stale at the same instant.
|
||||
// Every transition runs under the registry mutex, so only one may take the
|
||||
// lock over; the other must observe a now-fresh owner and WAIT/re-evaluate
|
||||
// rather than also taking over. (A content-blind clobber let both believe
|
||||
// they owned it — this asserts the mutex-gated CAS takeover instead.)
|
||||
await writeFile(
|
||||
lockPath,
|
||||
`4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\nstale-owner-token\n`,
|
||||
);
|
||||
|
||||
// Barrier the winner holds against until the loser has observed the lock
|
||||
// fresh and waited at least once — forcing the exact interleaving where one
|
||||
// proceeds while the other waits, deterministically rather than by timing.
|
||||
let resolveLoserWaited: () => void = () => {};
|
||||
const loserWaited = new Promise<void>((res) => {
|
||||
resolveLoserWaited = res;
|
||||
});
|
||||
let sleeps = 0;
|
||||
const sleepFn: SleepFn = async () => {
|
||||
sleeps += 1;
|
||||
resolveLoserWaited();
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
};
|
||||
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const tokens: string[] = [];
|
||||
const tokenOf = async (): Promise<string> => {
|
||||
const raw = await readFile(lockPath, 'utf8');
|
||||
return raw.split('\n')[2]?.trim() ?? '';
|
||||
};
|
||||
|
||||
let firstOwner = true;
|
||||
const restartOnce = async (): Promise<void> => {
|
||||
const guard = await acquireRestartLock(home, sleepFn);
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
tokens.push(await tokenOf());
|
||||
if (firstOwner) {
|
||||
// Winner: keep holding the lock until the loser has waited once, so the
|
||||
// loser is guaranteed to see a FRESH owner (not the stale one) and back
|
||||
// off — proving it could not also take over.
|
||||
firstOwner = false;
|
||||
await loserWaited;
|
||||
} else {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
}
|
||||
active -= 1;
|
||||
await guard.release();
|
||||
};
|
||||
|
||||
try {
|
||||
// Exactly two breakers race the single stale lock.
|
||||
await Promise.all([restartOnce(), restartOnce()]);
|
||||
|
||||
// Mutual exclusion: never two owners at once (if both took over the stale
|
||||
// lock, this would be 2).
|
||||
expect(maxActive).toBe(1);
|
||||
// Both eventually owned, each with its own distinct token.
|
||||
expect(new Set(tokens).size).toBe(2);
|
||||
// The loser observed the winner's fresh lock and waited — it did NOT also
|
||||
// take over the stale lock.
|
||||
expect(sleeps).toBeGreaterThanOrEqual(1);
|
||||
// The lock is fully released at the end.
|
||||
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('attempts every agent and the holder during fleet stop even when an agent stop fails', async () => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access, chmod, copyFile, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
access,
|
||||
chmod,
|
||||
copyFile,
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
stat,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { homedir, hostname, userInfo } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -533,6 +544,295 @@ export function buildFleetServiceCommand(action: FleetServiceAction, agentName?:
|
||||
return ['systemctl', '--user', action, service];
|
||||
}
|
||||
|
||||
/** Poll interval (ms) while waiting for an in-flight restart's lock to clear. */
|
||||
export const RESTART_LOCK_POLL_INTERVAL_MS = 250;
|
||||
/**
|
||||
* Maximum time (ms) a re-entrant restart waits for the in-flight restart to
|
||||
* finish before it breaks the lock and proceeds anyway. A bound is required so
|
||||
* a crashed holder of the lock can never deadlock the fleet permanently.
|
||||
*/
|
||||
export const RESTART_LOCK_MAX_WAIT_MS = 30_000;
|
||||
/**
|
||||
* Age (ms) past which a restart lock is treated as stale (its owner died
|
||||
* without releasing it) and is broken immediately rather than waited on.
|
||||
*/
|
||||
export const RESTART_LOCK_STALE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Resolves the path of the cross-process restart lock for a given Mosaic home.
|
||||
* Kept strictly under `<mosaicHome>/fleet/run` (not the heartbeat env override)
|
||||
* so the lock is scoped to the same fleet the restart acts on.
|
||||
*/
|
||||
export function restartLockPath(mosaicHome: string): string {
|
||||
return join(mosaicHome, 'fleet', 'run', 'restart.lock');
|
||||
}
|
||||
|
||||
/** A held restart lock; `release()` removes the lock file iff we still own it. */
|
||||
interface RestartGuard {
|
||||
release(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Lock-file contents: pid (informational), timestamp, and a unique owner token. */
|
||||
function formatRestartLockContent(token: string): string {
|
||||
return `${process.pid}\n${Date.now()}\n${token}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the owner token (line 3) from a lock file, or null if the file is
|
||||
* missing/unreadable/tokenless. The token is what makes release and break
|
||||
* ownership-safe: a process only ever acts on a lock whose token matches its own.
|
||||
*/
|
||||
async function readRestartLockToken(lockPath: string): Promise<string | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(lockPath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const token = raw.split('\n')[2]?.trim();
|
||||
return token ? token : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a lock's contents are stale: older than RESTART_LOCK_STALE_MS,
|
||||
* or unparseable (a corrupt or partially written lock left by a crashed owner).
|
||||
*/
|
||||
function isRestartLockContentStale(raw: string, now: number): boolean {
|
||||
const stampLine = raw.split('\n')[1] ?? '';
|
||||
const stamp = Number.parseInt(stampLine.trim(), 10);
|
||||
if (!Number.isFinite(stamp)) {
|
||||
return true;
|
||||
}
|
||||
return now - stamp >= RESTART_LOCK_STALE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Path of the short-lived registry mutex that guards EVERY transition of the
|
||||
* restart lock (acquire, release, takeover). Held only across a few filesystem
|
||||
* ops — never across the restart itself — so contention clears in microseconds.
|
||||
*/
|
||||
function restartMutexPath(lockPath: string): string {
|
||||
return `${lockPath}.mutex`;
|
||||
}
|
||||
|
||||
/** Brief back-off between registry-mutex acquisition attempts (held microseconds). */
|
||||
const RESTART_MUTEX_RETRY_MS = 20;
|
||||
|
||||
/**
|
||||
* Staleness for the internal mutex / reclaim locks, judged by the file's mtime
|
||||
* rather than its CONTENT. `open(path, 'wx')` creates the inode (with a fresh
|
||||
* mtime) before any token/timestamp is written into it, so a content-based check
|
||||
* would momentarily see that empty file as corrupt-and-stale and could reap a
|
||||
* lock another contender is still acquiring. mtime is set atomically at creation,
|
||||
* so a just-created lock always reads as live; only a lock whose holder died and
|
||||
* stopped touching it ages past the threshold. These locks are never held across
|
||||
* the restart itself (only a couple of filesystem ops), so any mtime this old can
|
||||
* belong only to a dead holder.
|
||||
*/
|
||||
async function isRestartLockPathStale(path: string, now: number): Promise<boolean> {
|
||||
try {
|
||||
const info = await stat(path);
|
||||
return now - info.mtimeMs >= RESTART_LOCK_STALE_MS;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return false; // Gone, not stale — the caller will re-contend.
|
||||
}
|
||||
return false; // Can't stat — treat as live and back off rather than reap.
|
||||
}
|
||||
}
|
||||
|
||||
/** Path of the reclaim lock that serializes reaping of a crashed-holder mutex. */
|
||||
function restartReclaimPath(mutexPath: string): string {
|
||||
return `${mutexPath}.reclaim`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reap a registry mutex left behind by a process that CRASHED mid-transition —
|
||||
* one whose file has aged past RESTART_LOCK_STALE_MS. Because the mutex is held
|
||||
* only for a couple of filesystem ops (no sleeps, never across the restart), a
|
||||
* mutex this old can only belong to a dead holder.
|
||||
*
|
||||
* The reap removes the dead mutex but never CREATES/holds it — acquisition stays
|
||||
* the single `open('wx')` create in {@link acquireRestartMutex}, so exactly one
|
||||
* contender wins ownership no matter how the reap and acquires interleave. The
|
||||
* removal is made conditional by a dedicated reclaim lock: while it is held the
|
||||
* dead mutex is stable (its dead holder will never touch it, and no other
|
||||
* reclaimer can race), so re-reading it and removing it only if it is STILL stale
|
||||
* is a true compare — a live holder's fresh mutex is never removed. This closes
|
||||
* the reclaim race a content-blind rename-and-restore left open (a third
|
||||
* contender slipping into the gap while a fresh mutex was moved aside).
|
||||
*/
|
||||
async function reclaimStaleRestartMutex(mutexPath: string): Promise<void> {
|
||||
const reclaimPath = restartReclaimPath(mutexPath);
|
||||
let handle: Awaited<ReturnType<typeof open>>;
|
||||
try {
|
||||
handle = await open(reclaimPath, 'wx');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw err;
|
||||
}
|
||||
// Someone is already reclaiming. If their reclaim lock is itself stale by
|
||||
// mtime, its holder crashed mid-reap (the lock spans only a stat + unlink,
|
||||
// microseconds) — clear it so a later pass can retry. Otherwise a live
|
||||
// reclaimer has it; back off. Either way we do not reap the mutex this pass.
|
||||
if (await isRestartLockPathStale(reclaimPath, Date.now())) {
|
||||
await unlink(reclaimPath).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Re-check the mutex UNDER the reclaim lock and remove it only if it is STILL
|
||||
// stale by mtime. A live holder's mutex is fresh and is left untouched; a dead
|
||||
// holder's mutex is stable here (its holder is gone and no other reclaimer can
|
||||
// race us), so this re-check is authoritative.
|
||||
if (await isRestartLockPathStale(mutexPath, Date.now())) {
|
||||
await unlink(mutexPath).catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
await unlink(reclaimPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the registry mutex, BLOCKING (with brief back-offs) until held, and
|
||||
* return a token-gated release. This is the single point of mutual exclusion for
|
||||
* the restart lock: acquire, release, and stale/timeout takeover all run under it,
|
||||
* so "read the lock, then mutate it" is atomic — no acquirer, releaser, or breaker
|
||||
* can ever interleave with another. A mutex left by a crashed holder is reclaimed
|
||||
* once it ages past the stale threshold.
|
||||
*/
|
||||
async function acquireRestartMutex(
|
||||
mutexPath: string,
|
||||
token: string,
|
||||
): Promise<RestartGuard['release']> {
|
||||
for (;;) {
|
||||
let handle: Awaited<ReturnType<typeof open>>;
|
||||
try {
|
||||
handle = await open(mutexPath, 'wx');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw err;
|
||||
}
|
||||
// Staleness is judged by mtime, not content, so a mutex that exists but has
|
||||
// not yet had its token written (the open-before-write window) reads as live
|
||||
// and is never wrongly reaped.
|
||||
if (!(await isRestartLockPathStale(mutexPath, Date.now()))) {
|
||||
// A live holder has it — it will be gone in microseconds. Back off briefly.
|
||||
await new Promise((resolve) => setTimeout(resolve, RESTART_MUTEX_RETRY_MS));
|
||||
continue;
|
||||
}
|
||||
await reclaimStaleRestartMutex(mutexPath);
|
||||
continue;
|
||||
}
|
||||
// We created the mutex. Populate it with our token; if writing fails, clean up
|
||||
// our own file so we never leak an empty mutex that a peer would have to reap.
|
||||
try {
|
||||
await handle.writeFile(formatRestartLockContent(token));
|
||||
await handle.close();
|
||||
} catch (err) {
|
||||
await handle.close().catch(() => {});
|
||||
await unlink(mutexPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
return async (): Promise<void> => {
|
||||
if ((await readRestartLockToken(mutexPath)) !== token) return;
|
||||
await unlink(mutexPath).catch(() => {});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the fleet restart lock, serializing concurrent `mosaic fleet restart`
|
||||
* invocations across processes. Each restart tears the tmux holder (and the
|
||||
* agent sessions inside it) down and back up; without this guard a re-entrant
|
||||
* restart relaunches agents against a half-torn-down holder, which fails and
|
||||
* tight-loops. A re-entrant caller waits for the in-flight restart to release
|
||||
* the lock (clean shutdown settled) before proceeding, breaks a stale lock left
|
||||
* by a crashed owner, and after RESTART_LOCK_MAX_WAIT_MS breaks the lock to
|
||||
* avoid a permanent deadlock.
|
||||
*
|
||||
* Correctness rests on a single invariant: EVERY transition of the lock — taking
|
||||
* a free lock, taking over a stale/timed-out one, and releasing — happens under
|
||||
* the registry mutex. Because the check ("is the lock free / stale / fresh?") and
|
||||
* the mutation that follows it both run while the mutex is held, they are atomic:
|
||||
* no other acquirer, releaser, or breaker can slip in between. That is what makes
|
||||
* takeover a true compare-and-swap rather than a content-blind clobber — a normal
|
||||
* `open('wx')` acquirer cannot create a fresh lock in a gap, and the original
|
||||
* owner's `release()` (also mutex-gated and token-checked) cannot drop a lock a
|
||||
* breaker already took over. So no interleaving lets two restarts both own the
|
||||
* lock and run concurrently.
|
||||
*/
|
||||
export async function acquireRestartLock(
|
||||
mosaicHome: string,
|
||||
sleepFn: SleepFn,
|
||||
): Promise<RestartGuard> {
|
||||
const token = randomUUID();
|
||||
const lockPath = restartLockPath(mosaicHome);
|
||||
const mutexPath = restartMutexPath(lockPath);
|
||||
await mkdir(dirname(lockPath), { recursive: true });
|
||||
const release = async (): Promise<void> => {
|
||||
// Mutex-gated and token-gated: only remove the lock if it is still ours. If
|
||||
// another caller took it over (after a stale/timeout break) the token no
|
||||
// longer matches and we leave their lock intact.
|
||||
const releaseMutex = await acquireRestartMutex(mutexPath, token);
|
||||
try {
|
||||
if ((await readRestartLockToken(lockPath)) === token) {
|
||||
await unlink(lockPath).catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
await releaseMutex();
|
||||
}
|
||||
};
|
||||
const deadline = Date.now() + RESTART_LOCK_MAX_WAIT_MS;
|
||||
for (;;) {
|
||||
let owned = false;
|
||||
const releaseMutex = await acquireRestartMutex(mutexPath, token);
|
||||
try {
|
||||
// Read and (if appropriate) mutate the lock atomically under the mutex.
|
||||
let current: string | null = null;
|
||||
let absent = false;
|
||||
try {
|
||||
current = await readFile(lockPath, 'utf8');
|
||||
} catch (readErr) {
|
||||
if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
absent = true;
|
||||
} else {
|
||||
current = null; // Unreadable/corrupt: treat as stale.
|
||||
}
|
||||
}
|
||||
const now = Date.now();
|
||||
if (absent) {
|
||||
// Lock is free — take it.
|
||||
await writeFile(lockPath, formatRestartLockContent(token));
|
||||
owned = true;
|
||||
} else {
|
||||
const stale = current === null || isRestartLockContentStale(current, now);
|
||||
const timedOut = now >= deadline;
|
||||
if (stale || timedOut) {
|
||||
process.stderr.write(
|
||||
stale
|
||||
? 'Breaking stale fleet restart lock.\n'
|
||||
: `Timed out after ${RESTART_LOCK_MAX_WAIT_MS}ms waiting for the in-flight fleet ` +
|
||||
'restart; breaking the lock.\n',
|
||||
);
|
||||
// Takeover is just an overwrite — safe because we hold the mutex, so no
|
||||
// acquirer or releaser can touch the lock between our read and this write.
|
||||
await writeFile(lockPath, formatRestartLockContent(token));
|
||||
owned = true;
|
||||
}
|
||||
// else: a fresh restart owns it — wait below and re-evaluate.
|
||||
}
|
||||
} finally {
|
||||
await releaseMutex();
|
||||
}
|
||||
if (owned) {
|
||||
return { release };
|
||||
}
|
||||
await sleepFn(RESTART_LOCK_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the systemctl --user enable command for a given unit.
|
||||
* Used by the install auto-enable step to persist units across reboots.
|
||||
@@ -1172,6 +1472,7 @@ export function isSendAccepted(capturedOutput: string): SendVerifyResult {
|
||||
|
||||
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
|
||||
const runner = deps.runner ?? runCommand;
|
||||
const sleepFn = deps.sleepFn ?? defaultSleep;
|
||||
const paths = resolveFleetPaths(deps.mosaicHome);
|
||||
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
|
||||
|
||||
@@ -1285,9 +1586,22 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
.command(`${action} [agent]`)
|
||||
.description(`${action} the fleet holder or one agent`)
|
||||
.action(async (agent?: string) => {
|
||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
if (agent) {
|
||||
getRosterAgent(roster, agent);
|
||||
// Single-agent restart is guarded too: it can race a full restart that
|
||||
// is tearing the shared holder down.
|
||||
if (action === 'restart') {
|
||||
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
||||
try {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
} finally {
|
||||
await guard.release();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
return;
|
||||
}
|
||||
@@ -1298,6 +1612,21 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (action === 'restart') {
|
||||
// Serialize the holder+agents teardown/relaunch behind the restart lock
|
||||
// so a re-entrant restart waits for clean shutdown before relaunching,
|
||||
// instead of racing a half-torn-down holder into a tight loop.
|
||||
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
||||
try {
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
}
|
||||
} finally {
|
||||
await guard.release();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
|
||||
161
tools/install.sh
161
tools/install.sh
@@ -16,6 +16,10 @@
|
||||
# --framework Install/upgrade framework only (skip npm CLI)
|
||||
# --cli Install/upgrade npm CLI only (skip framework)
|
||||
# --ref <branch> Git ref for framework archive (default: main)
|
||||
# --dev Build CLI + gateway FROM SOURCE at --ref instead of the
|
||||
# registry @latest. Zero registry writes — packs local
|
||||
# tarballs and installs them globally. Use to test a branch
|
||||
# end-to-end before cutting a release.
|
||||
# --yes Accept all defaults; headless/non-interactive install
|
||||
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
|
||||
# --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line
|
||||
@@ -27,6 +31,7 @@
|
||||
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
|
||||
# MOSAIC_NO_COLOR — disable colour (set to 1)
|
||||
# MOSAIC_REF — git ref for framework (default: main)
|
||||
# MOSAIC_DEV — equivalent to --dev (set to 1)
|
||||
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
@@ -43,6 +48,7 @@ FLAG_CLI=true
|
||||
FLAG_NO_AUTO_LAUNCH=false
|
||||
FLAG_YES=false
|
||||
FLAG_UNINSTALL=false
|
||||
FLAG_DEV=false
|
||||
GIT_REF="${MOSAIC_REF:-main}"
|
||||
|
||||
# MOSAIC_ASSUME_YES env var acts the same as --yes
|
||||
@@ -50,12 +56,18 @@ if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
|
||||
FLAG_YES=true
|
||||
fi
|
||||
|
||||
# MOSAIC_DEV env var acts the same as --dev
|
||||
if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
|
||||
FLAG_DEV=true
|
||||
fi
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--check) FLAG_CHECK=true; shift ;;
|
||||
--framework) FLAG_CLI=false; shift ;;
|
||||
--cli) FLAG_FRAMEWORK=false; shift ;;
|
||||
--ref) GIT_REF="${2:-main}"; shift 2 ;;
|
||||
--dev) FLAG_DEV=true; shift ;;
|
||||
--yes|-y) FLAG_YES=true; shift ;;
|
||||
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
||||
--uninstall) FLAG_UNINSTALL=true; shift ;;
|
||||
@@ -72,6 +84,17 @@ CLI_PKG="${SCOPE}/mosaic"
|
||||
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
|
||||
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
|
||||
|
||||
# In dev (build-from-source) mode the gateway is installed globally from a
|
||||
# locally-built tarball. Tell the wizard / gateway-config stage NOT to overwrite
|
||||
# it with the registry @latest build (honored by gatewayConfigStage).
|
||||
if [[ "$FLAG_DEV" == "true" ]]; then
|
||||
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
|
||||
fi
|
||||
|
||||
# Shared monorepo checkout (populated on demand by ensure_monorepo).
|
||||
WORK_DIR=""
|
||||
EXTRACTED_DIR=""
|
||||
|
||||
# ─── uninstall path ───────────────────────────────────────────────────────────
|
||||
# Shell-level uninstall for when the CLI is broken or not available.
|
||||
# Handles: framework directory, npm CLI package, npmrc scope line.
|
||||
@@ -239,6 +262,99 @@ framework_version() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Download + extract the monorepo archive at $GIT_REF exactly once per run.
|
||||
# Sets the script-level EXTRACTED_DIR to the repo root. Reused by both the
|
||||
# framework install (Part 1) and the dev build-from-source path (Part 2).
|
||||
ensure_monorepo() {
|
||||
if [[ -n "$EXTRACTED_DIR" ]] && [[ -d "$EXTRACTED_DIR" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
require_cmd tar
|
||||
|
||||
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")"
|
||||
# shellcheck disable=SC2317
|
||||
cleanup_work() { [[ -n "$WORK_DIR" ]] && rm -rf "$WORK_DIR"; }
|
||||
trap cleanup_work EXIT
|
||||
|
||||
info "Downloading source from ${GIT_REF}…"
|
||||
if command -v curl &>/dev/null; then
|
||||
curl -fsSL "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
|
||||
elif command -v wget &>/dev/null; then
|
||||
wget -qO- "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
|
||||
else
|
||||
fail "curl or wget required to download source."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Gitea archives extract to <repo-name>/ inside the work dir
|
||||
EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
|
||||
if [[ -z "$EXTRACTED_DIR" ]] || [[ ! -d "$EXTRACTED_DIR" ]]; then
|
||||
fail "Could not locate extracted source in archive."
|
||||
ls -la "$WORK_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Build @mosaicstack/mosaic + @mosaicstack/gateway from source and install both
|
||||
# globally from locally-packed tarballs. ZERO registry writes. Workspace deps
|
||||
# (brain/config/db/…) are pulled from the registry at the versions pinned in
|
||||
# each package.json — `pnpm pack` rewrites `workspace:*` to those versions.
|
||||
install_cli_from_source() {
|
||||
local src="$EXTRACTED_DIR"
|
||||
local out_dir="$WORK_DIR/dist-tarballs"
|
||||
mkdir -p "$out_dir"
|
||||
|
||||
# pnpm via corepack (ships with Node >= 16.9; required by Node >= 20 preflight).
|
||||
# Pin to the repo's packageManager version so the build matches CI. Surface
|
||||
# corepack failures so the fresh-machine case gives an actionable error
|
||||
# instead of a bare "command not found".
|
||||
if ! command -v pnpm &>/dev/null; then
|
||||
info "Activating pnpm via corepack…"
|
||||
corepack enable 2>&1 | sed 's/^/ /' || warn "corepack enable failed — pnpm may need manual install."
|
||||
corepack prepare pnpm@10.6.2 --activate 2>&1 | sed 's/^/ /' \
|
||||
|| warn "corepack prepare failed — pnpm may need manual install."
|
||||
fi
|
||||
if ! command -v pnpm &>/dev/null; then
|
||||
fail "pnpm not available after corepack activation."
|
||||
echo " Install pnpm manually (https://pnpm.io/installation) and re-run with --dev."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "Installing workspace dependencies (pnpm install)…"
|
||||
( cd "$src" && pnpm install ) 2>&1 | sed 's/^/ /'
|
||||
|
||||
info "Building CLI + gateway from source…"
|
||||
( cd "$src" && pnpm --filter "@mosaicstack/mosaic..." --filter "@mosaicstack/gateway..." run build ) 2>&1 | sed 's/^/ /'
|
||||
|
||||
info "Packing local tarballs…"
|
||||
( cd "$src/packages/mosaic" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
|
||||
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
|
||||
|
||||
local cli_tgz gw_tgz
|
||||
cli_tgz="$(ls -1t "$out_dir"/mosaicstack-mosaic-*.tgz 2>/dev/null | head -1)"
|
||||
gw_tgz="$(ls -1t "$out_dir"/mosaicstack-gateway-*.tgz 2>/dev/null | head -1)"
|
||||
|
||||
if [[ ! -f "$cli_tgz" ]]; then
|
||||
fail "CLI tarball was not produced by pnpm pack."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$gw_tgz" ]]; then
|
||||
fail "Gateway tarball was not produced by pnpm pack."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Gateway first so it is present globally before the CLI's wizard runs (which
|
||||
# skips its own gateway install via MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1).
|
||||
info "Installing gateway from source tarball (global)…"
|
||||
npm install -g "$gw_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'
|
||||
|
||||
info "Installing CLI from source tarball (global)…"
|
||||
npm install -g "$cli_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'
|
||||
|
||||
ok "Installed from source: CLI $(installed_cli_version)"
|
||||
}
|
||||
|
||||
# ─── preflight ────────────────────────────────────────────────────────────────
|
||||
|
||||
require_cmd node
|
||||
@@ -282,25 +398,8 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
|
||||
warn "Framework not installed."
|
||||
fi
|
||||
else
|
||||
# Download repo archive and extract framework
|
||||
require_cmd tar
|
||||
|
||||
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")"
|
||||
cleanup_work() { rm -rf "$WORK_DIR"; }
|
||||
trap cleanup_work EXIT
|
||||
|
||||
info "Downloading framework from ${GIT_REF}…"
|
||||
if command -v curl &>/dev/null; then
|
||||
curl -fsSL "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
|
||||
elif command -v wget &>/dev/null; then
|
||||
wget -qO- "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
|
||||
else
|
||||
fail "curl or wget required to download framework."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Gitea archives extract to <repo-name>/ inside the work dir
|
||||
EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
|
||||
# Download repo archive and extract framework (shared with the dev build)
|
||||
ensure_monorepo
|
||||
FRAMEWORK_SRC="$EXTRACTED_DIR/packages/mosaic/framework"
|
||||
|
||||
if [[ ! -d "$FRAMEWORK_SRC" ]]; then
|
||||
@@ -356,7 +455,11 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
||||
fi
|
||||
|
||||
CURRENT="$(installed_cli_version)"
|
||||
LATEST="$(latest_cli_version)"
|
||||
if [[ "$FLAG_DEV" == "true" ]]; then
|
||||
LATEST=""
|
||||
else
|
||||
LATEST="$(latest_cli_version)"
|
||||
fi
|
||||
|
||||
if [[ -n "$CURRENT" ]]; then
|
||||
dim " Installed: ${CLI_PKG}@${CURRENT}"
|
||||
@@ -364,7 +467,9 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
||||
dim " Installed: (none)"
|
||||
fi
|
||||
|
||||
if [[ -n "$LATEST" ]]; then
|
||||
if [[ "$FLAG_DEV" == "true" ]]; then
|
||||
dim " Source: ${REPO_BASE} (ref: ${GIT_REF}, build-from-source)"
|
||||
elif [[ -n "$LATEST" ]]; then
|
||||
dim " Latest: ${CLI_PKG}@${LATEST}"
|
||||
else
|
||||
dim " Latest: (registry unreachable)"
|
||||
@@ -372,7 +477,9 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
||||
echo ""
|
||||
|
||||
if [[ "$FLAG_CHECK" == "true" ]]; then
|
||||
if [[ -z "$LATEST" ]]; then
|
||||
if [[ "$FLAG_DEV" == "true" ]]; then
|
||||
info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)."
|
||||
elif [[ -z "$LATEST" ]]; then
|
||||
warn "Could not reach registry."
|
||||
elif [[ -z "$CURRENT" ]]; then
|
||||
warn "Not installed."
|
||||
@@ -383,6 +490,16 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
||||
else
|
||||
ok "Up to date (or ahead of registry)."
|
||||
fi
|
||||
elif [[ "$FLAG_DEV" == "true" ]]; then
|
||||
info "Dev mode — building CLI + gateway from source at ref ${GIT_REF}…"
|
||||
ensure_monorepo
|
||||
install_cli_from_source
|
||||
|
||||
# PATH check for npm prefix
|
||||
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
|
||||
warn "$PREFIX/bin is not on your PATH"
|
||||
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
|
||||
fi
|
||||
else
|
||||
if [[ -z "$LATEST" ]]; then
|
||||
warn "Could not reach registry at $REGISTRY — skipping npm CLI."
|
||||
|
||||
Reference in New Issue
Block a user