Compare commits

..
Author SHA1 Message Date
Jason WoltjeandClaude Fable 5 18f960d3e1 docs(l0): parameterize hard gates on the project's integration trunk
ci/woodpecker/pr/ci Pipeline was successful
Per Jason's Option-A ruling on #1216: the constitution supplies the general
framework; the project declares its own flow. Defines the integration trunk
once in CONSTITUTION.md Hard Gates — declared by exactly one
'Integration trunk: <branch>' line in the project's root AGENTS.md, default
'main', value bound by git check-ref-format --branch semantics, malformed or
multiple declarations are a hard stop, one trunk only, no gate relaxation —
and binds gates 5/15, E2E-DELIVERY, CODE-REVIEW, ORCHESTRATOR(-PROTOCOL),
BOOTSTRAP provisioning, and the defaults AGENTS session closure to that term.
CI-CD-PIPELINES YAML examples stay on the default trunk with a substitution
note.

Known mechanized consumer left as tracked follow-up on #1216: pr-merge.sh:133
hardcodes a {main,next} base allowlist — fail-closed (refuses exotic trunks,
cannot misroute), to be parameterized with red-first tests separately.

Refs #1216

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
2026-08-13 13:45:49 -05:00
37 changed files with 263 additions and 3894 deletions
@@ -1,110 +0,0 @@
'use client';
import type { ReactElement } from 'react';
import { formatAge, type FreshnessLabel } from '@/lib/freshness/model';
/**
* Rendering rules for non-current freshness states (RI-5-001).
*
* - `unavailable` renders an explicit failure panel — never an empty
* healthy collection.
* - `stale` may render last-known data, but only under a visible label
* carrying source identity, snapshot version, and age.
* - `partial` renders the verified parts plus an explicit list of what is
* missing.
*/
interface RetryableNoticeProps {
readonly onRetry?: () => void;
readonly retryLabel?: string;
}
function RetryButton({ onRetry, retryLabel }: RetryableNoticeProps): ReactElement | null {
if (!onRetry) return null;
return (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg border border-surface-border px-3 py-1.5 text-xs transition-colors hover:border-gray-500"
>
{retryLabel ?? 'Retry'}
</button>
);
}
export interface UnavailableDataNoticeProps extends RetryableNoticeProps {
/** What is unavailable, e.g. "Tasks". */
readonly title: string;
/** Optional underlying failure detail (network message, invalidation reason). */
readonly detail?: string | null;
}
/** Explicit `unavailable` state. Never renders as an empty healthy collection. */
export function UnavailableDataNotice({
title,
detail,
onRetry,
retryLabel,
}: UnavailableDataNoticeProps): ReactElement {
return (
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
<p className="font-medium text-text-primary">{title} are unavailable</p>
<p className="mt-1 text-text-muted">
This is not an empty result the data could not be verified from the gateway.
{detail ? ` ${detail}` : ''}
</p>
<RetryButton onRetry={onRetry} retryLabel={retryLabel} />
</div>
);
}
export interface StaleDataNoticeProps extends RetryableNoticeProps {
/** Provenance of the last-known snapshot being displayed. */
readonly label: FreshnessLabel;
}
/**
* Situational-awareness banner for `stale` data: last-known data may render,
* but visibly labeled with source identity, snapshot version, and age.
*/
export function StaleDataNotice({
label,
onRetry,
retryLabel,
}: StaleDataNoticeProps): ReactElement {
return (
<div role="status" className="rounded-lg border border-warning/40 px-4 py-3 text-sm">
<p className="font-medium text-warning">Showing last-known data it may be out of date</p>
<p className="mt-1 text-xs text-text-muted">
Source {label.source} · snapshot v{label.version} · fetched{' '}
{formatAge(label.fetchedAt, Date.now())}. Verdicts derived from this data are unknown and
changes are disabled until it is revalidated.
</p>
<RetryButton onRetry={onRetry} retryLabel={retryLabel ?? 'Revalidate'} />
</div>
);
}
export interface PartialDataNoticeProps extends RetryableNoticeProps {
/** Display names of the sections whose collections are unavailable. */
readonly missing: readonly string[];
}
/** `partial` surface banner: verified parts render, missing parts are explicit. */
export function PartialDataNotice({
missing,
onRetry,
retryLabel,
}: PartialDataNoticeProps): ReactElement {
return (
<div role="status" className="rounded-lg border border-warning/40 px-4 py-3 text-sm">
<p className="font-medium text-warning">Some data could not be loaded</p>
<p className="mt-1 text-xs text-text-muted">
{missing.join(', ')} {missing.length === 1 ? 'is' : 'are'} unavailable sections below show
an explicit unavailable state instead of an empty list. Derived verdicts remain unknown
until every collection is revalidated.
</p>
<RetryButton onRetry={onRetry} retryLabel={retryLabel ?? 'Revalidate'} />
</div>
);
}
-324
View File
@@ -1,324 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { Task } from '@/lib/types';
import {
acceptSnapshot,
assertMutable,
canMutate,
combineFreshness,
computeDigest,
computeFreshness,
DEFAULT_FRESHNESS_POLICY,
formatAge,
type FreshSnapshot,
invalidationReasonLabels,
StaleMutationError,
UNKNOWN_VERDICT,
verdictValue,
} from './model';
import { validateProjectCollection, validateTaskCollection } from './validators';
const NOW = 1_800_000_000_000;
const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 };
const taskPayload: Task[] = [
{
id: 'task-1',
title: 'T1',
description: null,
status: 'not-started',
priority: 'high',
projectId: 'project-1',
missionId: null,
assignee: null,
tags: null,
dueDate: null,
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
];
function acceptedTaskSnapshot(
overrides: Partial<FreshSnapshot<typeof taskPayload>> = {},
): FreshSnapshot<typeof taskPayload> {
const result = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
if (result.outcome !== 'accepted') {
throw new Error(`fixture setup failed: ${result.reason}`);
}
return { ...result.snapshot, ...overrides };
}
describe('computeFreshness', () => {
it('treats a missing snapshot as unavailable, never as an empty healthy collection', () => {
expect(computeFreshness({ snapshot: null, policy, now: NOW })).toBe('unavailable');
});
it('returns current for a fresh verified snapshot regardless of data emptiness', () => {
const empty = acceptSnapshot({
value: [],
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
if (empty.outcome !== 'accepted') throw new Error('expected acceptance');
expect(computeFreshness({ snapshot: empty.snapshot, policy, now: NOW })).toBe('current');
});
it('degrades to stale once the snapshot ages past staleAfterMs', () => {
const snapshot = acceptedTaskSnapshot();
expect(computeFreshness({ snapshot, policy, now: NOW + 60_001 })).toBe('stale');
expect(computeFreshness({ snapshot, policy, now: NOW + 59_999 })).toBe('current');
});
it('degrades to stale when the latest revalidation failed', () => {
const snapshot = acceptedTaskSnapshot();
expect(computeFreshness({ snapshot, policy, now: NOW, degraded: true })).toBe('stale');
});
});
describe('mutation guard', () => {
it('permits mutations only on current data', () => {
expect(canMutate('current')).toBe(true);
for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) {
expect(canMutate(state)).toBe(false);
}
});
it('refuses mutations on non-current data via assertMutable', () => {
expect(() => assertMutable('current')).not.toThrow();
for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) {
let thrown: unknown;
try {
assertMutable(state);
} catch (caught) {
thrown = caught;
}
expect(thrown).toBeInstanceOf(StaleMutationError);
expect(thrown).toBeInstanceOf(Error);
if (thrown instanceof StaleMutationError) {
expect(thrown.name).toBe('StaleMutationError');
expect(thrown.freshness).toBe(state);
expect(thrown.message).toContain(state);
expect(thrown.message).toContain('revalidat');
}
}
});
});
describe('acceptSnapshot', () => {
it('accepts a valid payload with provenance', () => {
const result = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
expect(result.outcome).toBe('accepted');
if (result.outcome !== 'accepted') return;
expect(result.snapshot.source).toBe('gateway:/api/tasks');
expect(result.snapshot.version).toBe(1);
expect(result.snapshot.fetchedAt).toBe(NOW);
expect(result.snapshot.data).toEqual(taskPayload);
});
it('invalidates a schema-mismatched payload instead of rendering it', () => {
const result = acceptSnapshot({
value: { not: 'an array' },
validate: validateTaskCollection,
previous: acceptedTaskSnapshot(),
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
expect(result).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' });
expect(invalidationReasonLabels['schema-mismatch']).toContain('schema');
});
it('invalidates cross-workspace payloads', () => {
const userOne = acceptSnapshot({
value: [
{
id: 'p1',
name: 'P1',
description: null,
status: 'active',
userId: 'user-1',
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
],
validate: validateProjectCollection,
previous: null,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
if (userOne.outcome !== 'accepted') throw new Error('expected acceptance');
const switched = acceptSnapshot({
value: [
{
id: 'p9',
name: 'P9',
description: null,
status: 'active',
userId: 'user-2',
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
],
validate: validateProjectCollection,
previous: userOne.snapshot,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
expect(switched).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
});
it('keeps the previous workspace for collections with no intrinsic identity', () => {
const userOne = acceptSnapshot({
value: [
{
id: 'p1',
name: 'P1',
description: null,
status: 'active',
userId: 'user-1',
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
],
validate: validateProjectCollection,
previous: null,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
if (userOne.outcome !== 'accepted') throw new Error('expected acceptance');
// Empty list after the user deleted every project: no identity to check,
// so the verified scope is retained and the empty state stays healthy.
const emptied = acceptSnapshot({
value: [],
validate: validateProjectCollection,
previous: userOne.snapshot,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
expect(emptied.outcome).toBe('accepted');
if (emptied.outcome === 'accepted') {
expect(emptied.snapshot.data).toEqual([]);
expect(emptied.snapshot.workspace).toBe('user-1');
}
});
it('invalidates version regressions', () => {
const previous = acceptedTaskSnapshot({ version: 7 });
const regressed = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous,
policy,
source: 'gateway:/api/tasks',
now: NOW,
incomingVersion: 3,
});
expect(regressed).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
const newerSchema = acceptedTaskSnapshot({ schemaVersion: 4 });
const downgradedClient = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: newerSchema,
policy: { ...policy, schemaVersion: 2 },
source: 'gateway:/api/tasks',
now: NOW,
});
expect(downgradedClient).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
});
it('increments the version monotonically across accepted snapshots', () => {
const first = acceptedTaskSnapshot();
const second = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: first,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
expect(second.outcome).toBe('accepted');
if (second.outcome === 'accepted') {
expect(second.snapshot.version).toBe(first.version + 1);
}
});
});
describe('combineFreshness', () => {
it('gates the surface on the primary collection', () => {
expect(combineFreshness('unavailable', ['current'])).toBe('unavailable');
expect(combineFreshness('unknown', ['current'])).toBe('unknown');
expect(combineFreshness('current', [])).toBe('current');
});
it('degrades to partial when a secondary is unavailable', () => {
expect(combineFreshness('current', ['current', 'unavailable'])).toBe('partial');
});
it('degrades to unknown while a secondary is still loading', () => {
expect(combineFreshness('current', ['unknown'])).toBe('unknown');
});
it('degrades to stale when any collection is stale', () => {
expect(combineFreshness('current', ['stale'])).toBe('stale');
expect(combineFreshness('stale', ['current'])).toBe('stale');
});
it('propagates partial secondaries', () => {
expect(combineFreshness('current', ['partial'])).toBe('partial');
});
});
describe('computeDigest', () => {
it('is stable across key order and changes with data', () => {
const a = computeDigest({ x: 1, y: [1, 2] });
const b = computeDigest({ y: [1, 2], x: 1 });
expect(a).toBe(b);
expect(computeDigest({ x: 1, y: [1, 3] })).not.toBe(a);
});
});
describe('verdictValue', () => {
it('returns the value only for verified inputs', () => {
expect(verdictValue(true, '5')).toBe('5');
expect(verdictValue(false, '5')).toBe(UNKNOWN_VERDICT);
expect(verdictValue(false, '5')).not.toBe('5');
});
});
describe('formatAge', () => {
it('labels age in human terms', () => {
expect(formatAge(NOW, NOW)).toBe('just now');
expect(formatAge(NOW, NOW + 15_000)).toBe('under a minute ago');
expect(formatAge(NOW, NOW + 120_000)).toBe('2m ago');
expect(formatAge(NOW, NOW + 3 * 3_600_000)).toBe('3h ago');
expect(formatAge(NOW, NOW + 2 * 86_400_000)).toBe('2d ago');
});
});
-261
View File
@@ -1,261 +0,0 @@
/**
* Typed freshness model for gateway-fetched collections (RI-5-001).
*
* A failed or stale fetch must never be indistinguishable from an empty
* healthy collection. Every fetched surface carries an explicit freshness
* state, a verified snapshot identity (source, workspace, version, age), and
* a mutation guard that refuses state-changing operations unless the data is
* verified current.
*/
/** Freshness states for fetched data. Never inferred from emptiness. */
export type FreshnessState = 'current' | 'stale' | 'partial' | 'unknown' | 'unavailable';
/**
* Reasons a snapshot is invalidated. An invalidated snapshot is treated as
* unavailable and is never rendered as current.
*/
export type InvalidationReason =
| 'cache-corruption'
| 'cross-workspace'
| 'schema-mismatch'
| 'version-regression';
/** Human-readable labels for invalidation reasons (UI + error messages). */
export const invalidationReasonLabels: Record<InvalidationReason, string> = {
'cache-corruption': 'cached snapshot failed integrity checks',
'cross-workspace': 'data belongs to a different workspace',
'schema-mismatch': 'response did not match the expected schema',
'version-regression': 'snapshot version regressed below the accepted version',
};
/** A verified snapshot of fetched data with full provenance. */
export interface FreshSnapshot<T> {
readonly data: T;
/** Source identity of the fetch, e.g. `gateway:/api/tasks`. */
readonly source: string;
/** Workspace scope the data belongs to. */
readonly workspace: string;
/** Monotonic snapshot sequence number for this surface. */
readonly version: number;
/** Schema version of the validator that accepted this snapshot. */
readonly schemaVersion: number;
/** Epoch ms at which the data was verified. */
readonly fetchedAt: number;
/** Integrity digest of `data`, used to detect cache corruption. */
readonly digest: string;
}
/** Provenance label rendered next to last-known data. */
export interface FreshnessLabel {
readonly source: string;
readonly version: number;
readonly fetchedAt: number;
}
/** Policy governing freshness for a surface. */
export interface FreshnessPolicy {
/** Active workspace scope. Snapshots from other scopes are invalidated. */
readonly workspace: string;
/** Schema version of the current validator. */
readonly schemaVersion: number;
/** Age after which a verified snapshot degrades from current to stale. */
readonly staleAfterMs: number;
}
export const DEFAULT_FRESHNESS_POLICY: FreshnessPolicy = {
workspace: 'default',
schemaVersion: 1,
staleAfterMs: 60_000,
};
/** Payload returned by a successful schema validation. */
export interface FreshPayload<T> {
readonly data: T;
/**
* Workspace identity extracted from the payload itself when the collection
* carries one (e.g. a uniform `userId` on projects). `null` when the
* collection has no intrinsic workspace identity.
*/
readonly workspace: string | null;
}
/** Error thrown when a mutation is attempted on non-current data. */
export class StaleMutationError extends Error {
readonly freshness: FreshnessState;
constructor(freshness: FreshnessState) {
super(`Refused mutation on ${freshness} data: revalidation is required before mutating.`);
this.name = 'StaleMutationError';
this.freshness = freshness;
}
}
/** Stable JSON digest used for snapshot integrity checks. */
export function computeDigest(value: unknown): string {
// FNV-1a 32-bit over the stable JSON serialization. This is an integrity
// check against corruption, not a cryptographic guarantee.
let hash = 0x811c9dc5;
for (const byte of stableStringify(value)) {
hash ^= byte.charCodeAt(0);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
function stableStringify(value: unknown): string {
return serialize(value);
}
function serialize(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
if (Array.isArray(value)) return `[${value.map(serialize).join(',')}]`;
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([key, item]) => `${JSON.stringify(key)}:${serialize(item)}`);
return `{${entries.join(',')}}`;
}
export type AcceptSnapshotResult<T> =
| { readonly outcome: 'accepted'; readonly snapshot: FreshSnapshot<T> }
| { readonly outcome: 'invalidated'; readonly reason: InvalidationReason };
export interface AcceptSnapshotOptions<T> {
/** Raw fetched value (untrusted JSON). */
readonly value: unknown;
/** Schema validator; returns `null` when the value does not match. */
readonly validate: (value: unknown) => FreshPayload<T> | null;
/** Previously accepted snapshot for this surface, if any. */
readonly previous: FreshSnapshot<T> | null;
readonly policy: FreshnessPolicy;
readonly source: string;
/**
* Version carried by the incoming payload when the transport exposes one.
* Must not regress below the accepted snapshot's version.
*/
readonly incomingVersion?: number;
readonly now: number;
}
/**
* Validate and accept a fetched value as a snapshot, or invalidate it.
*
* Invalidation rules (each treated as unavailable, never rendered current):
* - schema mismatch: the payload fails validation
* - cross-workspace: the payload's workspace differs from the verified one
* - version regression: payload/schema version is below the accepted one
*/
export function acceptSnapshot<T>(options: AcceptSnapshotOptions<T>): AcceptSnapshotResult<T> {
const payload = options.validate(options.value);
if (payload === null) {
return { outcome: 'invalidated', reason: 'schema-mismatch' };
}
// Workspace identity: the payload's own scope wins; a collection with no
// intrinsic identity (e.g. an empty list after every project was deleted)
// keeps the previously verified scope rather than resetting to the policy
// default, so a legitimately empty response is not mistaken for a scope
// change.
const workspace = payload.workspace ?? options.previous?.workspace ?? options.policy.workspace;
if (options.previous !== null && options.previous.workspace !== workspace) {
return { outcome: 'invalidated', reason: 'cross-workspace' };
}
if (options.previous !== null && options.policy.schemaVersion < options.previous.schemaVersion) {
return { outcome: 'invalidated', reason: 'version-regression' };
}
if (
options.incomingVersion !== undefined &&
options.previous !== null &&
options.incomingVersion < options.previous.version
) {
return { outcome: 'invalidated', reason: 'version-regression' };
}
const snapshot: FreshSnapshot<T> = {
data: payload.data,
source: options.source,
workspace,
version: options.incomingVersion ?? (options.previous?.version ?? 0) + 1,
schemaVersion: options.policy.schemaVersion,
fetchedAt: options.now,
digest: computeDigest(payload.data),
};
return { outcome: 'accepted', snapshot };
}
export interface ComputeFreshnessOptions {
readonly snapshot: FreshSnapshot<unknown> | null;
readonly policy: FreshnessPolicy;
readonly now: number;
/**
* True when the snapshot cannot be trusted as current regardless of age:
* the latest revalidation failed, or the snapshot was restored from cache
* and has not been verified by a fetch in this session.
*/
readonly degraded?: boolean;
}
/**
* Compute the freshness state of a snapshot. A missing snapshot is
* `unavailable` (never "empty and healthy"); a degraded or aged snapshot is
* `stale` (situational awareness only).
*/
export function computeFreshness(options: ComputeFreshnessOptions): FreshnessState {
const { snapshot, policy, now, degraded = false } = options;
if (snapshot === null) return 'unavailable';
if (degraded) return 'stale';
if (now - snapshot.fetchedAt > policy.staleAfterMs) return 'stale';
return 'current';
}
/** Only verified-current data may back a state-changing action. */
export function canMutate(state: FreshnessState): boolean {
return state === 'current';
}
/** Defense in depth: reject the mutation call itself on non-current data. */
export function assertMutable(state: FreshnessState): void {
if (!canMutate(state)) {
throw new StaleMutationError(state);
}
}
/**
* Combine freshness across a multi-collection surface (primary + secondaries).
* The primary collection gates the surface: unknown while it loads,
* unavailable when it fails. Missing secondaries degrade the surface to
* `partial`; aged collections degrade it to `stale`.
*/
export function combineFreshness(
primary: FreshnessState,
secondaries: readonly FreshnessState[],
): FreshnessState {
if (primary === 'unavailable') return 'unavailable';
if (primary === 'unknown') return 'unknown';
if (secondaries.includes('unavailable')) return 'partial';
if (secondaries.includes('unknown')) return 'unknown';
if (secondaries.includes('stale') || primary === 'stale') return 'stale';
if (secondaries.includes('partial')) return 'partial';
return 'current';
}
/** Render-safe age label for snapshot provenance. */
export function formatAge(fetchedAt: number, now: number): string {
const ageMs = Math.max(0, now - fetchedAt);
if (ageMs < 10_000) return 'just now';
const minutes = Math.floor(ageMs / 60_000);
if (minutes < 1) return 'under a minute ago';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
/** Derived verdict placeholder for non-current inputs — never a green value. */
export const UNKNOWN_VERDICT = '?';
export function verdictValue(verified: boolean, value: string): string {
return verified ? value : UNKNOWN_VERDICT;
}
@@ -1,197 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from './model';
import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache';
import { validateProjectCollection, validateTaskCollection } from './validators';
import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';
import type { Project, Task } from '@/lib/types';
const KEY = 'test:tasks';
const NOW = 1_800_000_000_000;
const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 };
function storedTaskSnapshot() {
const result = acceptSnapshot({
value: taskFixtures,
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
return result.snapshot;
}
function storedProjectSnapshot() {
const result = acceptSnapshot({
value: projectFixtures,
validate: validateProjectCollection,
previous: null,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
return result.snapshot;
}
function readTasks() {
return readSnapshotCache({
key: KEY,
workspace: policy.workspace,
policy,
validate: validateTaskCollection,
});
}
/** Write an arbitrary value directly at the raw cache slot. */
function writeRaw(key: string, value: unknown): void {
sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify(value));
}
/** Parse and re-write the stored entry (for tampering with internals). */
function tamperStored<T>(key: string, mutate: (stored: T) => void): void {
const parsed = JSON.parse(sessionStorage.getItem(`mosaic:freshness:v1:${key}`) ?? '{}') as T;
mutate(parsed);
writeRaw(key, parsed);
}
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
sessionStorage.clear();
});
describe('readSnapshotCache', () => {
it('misses when nothing is stored', () => {
expect(readTasks()).toEqual({ outcome: 'miss' });
});
it('hits for a well-formed entry and preserves provenance', () => {
const snapshot = storedTaskSnapshot();
writeSnapshotCache(KEY, snapshot);
const result = readTasks();
expect(result.outcome).toBe('hit');
if (result.outcome === 'hit') {
expect(result.snapshot.data).toEqual(taskFixtures);
expect(result.snapshot.source).toBe('gateway:/api/tasks');
expect(result.snapshot.version).toBe(snapshot.version);
expect(result.snapshot.fetchedAt).toBe(snapshot.fetchedAt);
expect(result.snapshot.workspace).toBe(snapshot.workspace);
}
});
it('invalidates unparsable entries as cache corruption', () => {
sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, '{not json');
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' });
});
it('invalidates structurally wrong entries as cache corruption', () => {
const malformed: unknown[] = [
'nested but not a snapshot',
{ data: taskFixtures }, // missing provenance fields
{
data: taskFixtures,
source: 1,
workspace: 'w',
version: 1,
schemaVersion: 1,
fetchedAt: 1,
digest: 'x',
},
null,
17,
];
for (const entry of malformed) {
writeRaw(KEY, entry);
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' });
}
});
it('invalidates digest mismatches as cache corruption (tampered data)', () => {
writeSnapshotCache(KEY, storedTaskSnapshot());
tamperStored<{ data: Task[] }>(KEY, (stored) => {
stored.data = [...stored.data, { ...stored.data[0]!, id: 'injected-task' }];
});
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' });
});
it('invalidates entries scoped to another workspace', () => {
const snapshot = storedTaskSnapshot();
writeSnapshotCache(KEY, { ...snapshot, workspace: 'someone-else' });
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
});
it('invalidates entries written by a newer schema as a version regression', () => {
const snapshot = storedTaskSnapshot();
writeSnapshotCache(KEY, { ...snapshot, schemaVersion: policy.schemaVersion + 1 });
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
});
it('invalidates entries whose data no longer validates (schema mismatch)', () => {
writeSnapshotCache(KEY, storedTaskSnapshot());
tamperStored<{ data: unknown }>(KEY, (stored) => {
stored.data = { malformed: true };
});
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' });
});
it('never reports a corrupted raw entry as a hit (negative control)', () => {
for (const raw of ['{oops', 'null', '"string"', '[]', '12']) {
sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, raw);
const result = readTasks();
expect(result.outcome).not.toBe('hit');
expect(result.outcome).toBe('invalidated');
}
});
it('scopes project collections by their workspace identity', () => {
const snapshot = storedProjectSnapshot();
writeSnapshotCache('test:projects', snapshot);
const sameScope = readSnapshotCache({
key: 'test:projects',
workspace: 'user-1',
policy,
validate: validateProjectCollection,
});
expect(sameScope.outcome).toBe('hit');
const foreignScope = readSnapshotCache({
key: 'test:projects',
workspace: 'user-2',
policy,
validate: validateProjectCollection,
});
expect(foreignScope).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
});
});
describe('writeSnapshotCache round-trip', () => {
it('round-trips an accepted project snapshot', () => {
const snapshot = storedProjectSnapshot();
writeSnapshotCache('test:projects', snapshot);
const result = readSnapshotCache({
key: 'test:projects',
workspace: snapshot.workspace,
policy,
validate: validateProjectCollection,
});
expect(result.outcome).toBe('hit');
if (result.outcome === 'hit') {
expect(result.snapshot.data).toEqual(projectFixtures as Project[]);
}
});
});
describe('clearSnapshotCache', () => {
it('drops the entry so the next read misses', () => {
writeSnapshotCache(KEY, storedTaskSnapshot());
expect(readTasks().outcome).toBe('hit');
clearSnapshotCache(KEY);
expect(readTasks()).toEqual({ outcome: 'miss' });
});
});
@@ -1,154 +0,0 @@
import {
computeDigest,
type FreshPayload,
type FreshSnapshot,
type FreshnessPolicy,
type InvalidationReason,
} from './model';
/**
* Session-scoped last-known snapshot cache (RI-5-001).
*
* Restored snapshots are situational awareness only: they surface as `stale`
* until a fetch re-verifies them. A cache entry that is corrupted, belongs to
* another workspace, was written by a newer schema, or no longer validates is
* invalidated (treated as unavailable, never rendered as current).
*/
const CACHE_PREFIX = 'mosaic:freshness:v1';
interface StoredSnapshot {
data: unknown;
source: string;
workspace: string;
version: number;
schemaVersion: number;
fetchedAt: number;
digest: string;
}
export type SnapshotCacheRead<T> =
| { readonly outcome: 'hit'; readonly snapshot: FreshSnapshot<T> }
| { readonly outcome: 'miss' }
| { readonly outcome: 'invalidated'; readonly reason: InvalidationReason };
export interface ReadSnapshotCacheOptions<T> {
readonly key: string;
readonly workspace: string;
readonly policy: FreshnessPolicy;
readonly validate: (value: unknown) => FreshPayload<T> | null;
}
function cacheKey(key: string): string {
return `${CACHE_PREFIX}:${key}`;
}
function isStoredSnapshot(value: unknown): value is StoredSnapshot {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate['data'] === 'object' &&
candidate['data'] !== null &&
typeof candidate['source'] === 'string' &&
typeof candidate['workspace'] === 'string' &&
typeof candidate['version'] === 'number' &&
typeof candidate['schemaVersion'] === 'number' &&
typeof candidate['fetchedAt'] === 'number' &&
typeof candidate['digest'] === 'string'
);
}
function getStorage(): Storage | null {
try {
return globalThis.sessionStorage ?? null;
} catch {
return null;
}
}
/**
* Restore a cached snapshot under the active workspace scope. Every failure
* mode maps to an explicit invalidation reason or a miss — never to data
* that renders as current.
*/
export function readSnapshotCache<T>(options: ReadSnapshotCacheOptions<T>): SnapshotCacheRead<T> {
const storage = getStorage();
if (storage === null) return { outcome: 'miss' };
let raw: string | null;
try {
raw = storage.getItem(cacheKey(options.key));
} catch {
return { outcome: 'miss' };
}
if (raw === null) return { outcome: 'miss' };
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { outcome: 'invalidated', reason: 'cache-corruption' };
}
if (!isStoredSnapshot(parsed)) {
return { outcome: 'invalidated', reason: 'cache-corruption' };
}
if (parsed.workspace !== options.workspace) {
return { outcome: 'invalidated', reason: 'cross-workspace' };
}
if (parsed.schemaVersion > options.policy.schemaVersion) {
// Written by a newer build than the running client: version regression.
return { outcome: 'invalidated', reason: 'version-regression' };
}
const payload = options.validate(parsed.data);
if (payload === null) {
return { outcome: 'invalidated', reason: 'schema-mismatch' };
}
if (computeDigest(payload.data) !== parsed.digest) {
return { outcome: 'invalidated', reason: 'cache-corruption' };
}
return {
outcome: 'hit',
snapshot: {
data: payload.data,
source: parsed.source,
workspace: parsed.workspace,
version: parsed.version,
schemaVersion: parsed.schemaVersion,
fetchedAt: parsed.fetchedAt,
digest: parsed.digest,
},
};
}
/** Persist a verified snapshot. Failures are non-fatal (cache is best-effort). */
export function writeSnapshotCache<T>(key: string, snapshot: FreshSnapshot<T>): void {
const storage = getStorage();
if (storage === null) return;
const stored: StoredSnapshot = {
data: snapshot.data,
source: snapshot.source,
workspace: snapshot.workspace,
version: snapshot.version,
schemaVersion: snapshot.schemaVersion,
fetchedAt: snapshot.fetchedAt,
digest: snapshot.digest,
};
try {
storage.setItem(cacheKey(key), JSON.stringify(stored));
} catch {
// Quota or serialization failures simply skip caching.
}
}
/** Drop a cached snapshot (used when a surface invalidates its cache entry). */
export function clearSnapshotCache(key: string): void {
const storage = getStorage();
if (storage === null) return;
try {
storage.removeItem(cacheKey(key));
} catch {
// Ignorable: a wedged storage entry is detected as corruption on read.
}
}
@@ -1,372 +0,0 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Task } from '@/lib/types';
import { acceptSnapshot, StaleMutationError, DEFAULT_FRESHNESS_POLICY } from './model';
import type { FreshnessFailure } from './use-fresh-collection';
import {
describeFailure,
useFreshCollection,
type FreshCollection,
type UseFreshCollectionOptions,
} from './use-fresh-collection';
import { validateProjectCollection, validateTaskCollection } from './validators';
import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';
/**
* Failure-matrix coverage for the freshness seam (RI-5-001): network failure,
* auth failure, malformed response, cache corruption, stale age, schema
* mismatch, cross-workspace, recovery, and stale-action rejection — with
* negative controls proving no case yields current data or an enabled
* mutation.
*/
const NOW = 1_800_000_000_000;
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
let root: Root | null = null;
let container: HTMLDivElement;
let latest: FreshCollection<Task[]> | null = null;
function Probe({
options,
}: {
options: UseFreshCollectionOptions<Task[]>;
}): React.ReactElement | null {
latest = useFreshCollection<Task[]>(options);
return null;
}
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
beforeEach(() => {
sessionStorage.clear();
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
latest = null;
sessionStorage.clear();
vi.restoreAllMocks();
});
async function renderCollection(
options: UseFreshCollectionOptions<Task[]>,
): Promise<FreshCollection<Task[]>> {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<Probe options={options} />);
});
if (latest === null) throw new Error('hook did not run');
return latest;
}
function taskOptions(
overrides: Partial<UseFreshCollectionOptions<Task[]>> = {},
): UseFreshCollectionOptions<Task[]> {
return {
source: 'gateway:/api/tasks',
fetcher: () => Promise.resolve(taskFixtures),
validate: validateTaskCollection,
cacheKey: 'tasks',
clock: () => NOW,
...overrides,
};
}
function authError(statusCode: number): Error & { statusCode: number } {
return Object.assign(new Error(`Request failed with ${statusCode}`), { statusCode });
}
function seedCache(key: string): number {
const result = acceptSnapshot({
value: taskFixtures,
validate: validateTaskCollection,
previous: null,
policy: DEFAULT_FRESHNESS_POLICY,
source: 'gateway:/api/tasks',
now: NOW,
});
if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify({ ...result.snapshot }));
return result.snapshot.version;
}
describe('useFreshCollection failure matrix', () => {
it('is unknown (not empty) while the first validation is in flight', async () => {
const deferred = createDeferred<Task[]>();
const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
expect(collection.freshness).toBe('unknown');
expect(collection.validating).toBe(true);
expect(collection.data).toBeNull();
expect(collection.canMutate).toBe(false);
await act(async () => {
deferred.resolve(taskFixtures);
await deferred.promise;
});
});
it('becomes current with provenance after a verified fetch', async () => {
const collection = await renderCollection(taskOptions());
expect(collection.freshness).toBe('current');
expect(collection.data).toEqual(taskFixtures);
expect(collection.snapshot?.source).toBe('gateway:/api/tasks');
expect(collection.snapshot?.version).toBe(1);
expect(collection.failure).toBeNull();
expect(collection.canMutate).toBe(true);
// Verified snapshot is persisted for last-known restore.
expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeTruthy();
});
it('treats a network failure as unavailable — never an empty healthy collection', async () => {
const collection = await renderCollection(
taskOptions({ fetcher: () => Promise.reject(new Error('network down')) }),
);
expect(collection.freshness).toBe('unavailable');
expect(collection.data).toBeNull();
expect(collection.failure).toEqual({ kind: 'fetch', message: 'network down' });
expect(collection.canMutate).toBe(false);
expect(describeFailure(collection.failure)).toBe('network down');
});
it('treats an auth failure as unavailable and drops the last-known snapshot', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1 ? Promise.resolve(taskFixtures) : Promise.reject(authError(401));
},
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('unavailable');
expect(latest?.data).toBeNull();
expect(latest?.failure?.kind).toBe('fetch');
// The previous user's data must not linger in the session cache.
expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull();
});
it('invalidates a malformed response as a schema mismatch', async () => {
const collection = await renderCollection(
taskOptions({ fetcher: () => Promise.resolve({ malformed: true }) }),
);
expect(collection.freshness).toBe('unavailable');
expect(collection.data).toBeNull();
expect(collection.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' });
expect(collection.canMutate).toBe(false);
});
it('keeps the previous snapshot as labeled stale when a later payload mismatches', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1 ? Promise.resolve(taskFixtures) : Promise.resolve('garbage');
},
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('stale');
expect(latest?.data).toEqual(taskFixtures);
expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' });
expect(latest?.canMutate).toBe(false);
});
it('drops the snapshot when the workspace changes under it (cross-workspace)', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return Promise.resolve(
call === 1 ? projectFixtures : [{ ...projectFixtures[0], userId: 'user-2' }],
);
},
validate: validateProjectCollection as unknown as (value: unknown) => {
data: Task[];
workspace: string | null;
},
source: 'gateway:/api/projects',
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('unavailable');
expect(latest?.data).toBeNull();
expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'cross-workspace' });
});
it('ages from current to stale and refuses mutations on stale data', async () => {
let fakeNow = NOW;
const collection = await renderCollection(
taskOptions({
clock: () => fakeNow,
policy: { staleAfterMs: 40 },
tickMs: 10,
}),
);
expect(collection.freshness).toBe('current');
// Age the snapshot past the policy and let the tick recompute.
fakeNow = NOW + 60;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 25));
});
expect(latest?.freshness).toBe('stale');
expect(latest?.data).toEqual(taskFixtures);
expect(latest?.canMutate).toBe(false);
const operation = vi.fn(async () => 'result');
await expect(latest?.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
expect(operation).not.toHaveBeenCalled();
});
it('recovers to current after a successful revalidation', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1
? Promise.reject(new Error('first attempt failed'))
: Promise.resolve(taskFixtures);
},
}),
);
expect(collection.freshness).toBe('unavailable');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('current');
expect(latest?.failure).toBeNull();
const operation = vi.fn(async (data: Task[]) => data.length);
await expect(latest?.mutate(operation)).resolves.toBe(taskFixtures.length);
expect(operation).toHaveBeenCalledOnce();
});
it('restores a cached snapshot as unverified stale data, then verifies it', async () => {
const seededVersion = seedCache('tasks');
const deferred = createDeferred<Task[]>();
const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
// Restored data is situational awareness only: labeled stale, never
// current, and mutations are refused before verification.
expect(collection.freshness).toBe('stale');
expect(collection.data).toEqual(taskFixtures);
expect(collection.canMutate).toBe(false);
await expect(collection.mutate(vi.fn())).rejects.toBeInstanceOf(StaleMutationError);
await act(async () => {
deferred.resolve(taskFixtures);
await deferred.promise;
});
expect(latest?.freshness).toBe('current');
expect(latest?.snapshot?.version).toBe(seededVersion + 1);
});
it('never promotes corrupted cache data to current (cache corruption)', async () => {
sessionStorage.setItem('mosaic:freshness:v1:tasks', '{"data":');
const collection = await renderCollection(
taskOptions({ fetcher: () => Promise.reject(new Error('still down')) }),
);
expect(collection.freshness).toBe('unavailable');
expect(collection.data).toBeNull();
expect(collection.canMutate).toBe(false);
// The corrupted entry is dropped so it cannot come back.
expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull();
});
it('refuses mutations while unknown or unavailable — the call itself, not just the button', async () => {
const deferred = createDeferred<Task[]>();
const unknown = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
const operation = vi.fn(async () => 'result');
await expect(unknown.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
expect(operation).not.toHaveBeenCalled();
await act(async () => {
deferred.reject(new Error('failed'));
await deferred.promise.catch(() => undefined);
});
const unavailable = latest!;
await expect(unavailable.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
expect(operation).not.toHaveBeenCalled();
expect(unavailable.canMutate).toBe(false);
});
it('degrades to stale with last-known data when a revalidation fails after success', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1
? Promise.resolve(taskFixtures)
: Promise.reject(new Error('connection lost'));
},
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('stale');
expect(latest?.data).toEqual(taskFixtures);
const failure: FreshnessFailure | null = latest?.failure ?? null;
expect(failure).toEqual({ kind: 'fetch', message: 'connection lost' });
});
});
@@ -1,281 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
acceptSnapshot,
assertMutable,
computeFreshness,
DEFAULT_FRESHNESS_POLICY,
invalidationReasonLabels,
type FreshPayload,
type FreshSnapshot,
type FreshnessPolicy,
type FreshnessState,
type InvalidationReason,
StaleMutationError,
} from './model';
import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache';
/**
* Freshness-aware collection fetch hook (RI-5-001).
*
* One hook owns one gateway collection end to end: fetch, schema validation,
* snapshot acceptance with provenance, session-scoped last-known caching,
* aging, and the mutation guard. Pages consume `freshness` and never infer
* health from emptiness.
*/
/** Why the latest validation did not produce a current snapshot. */
export type FreshnessFailure =
| { readonly kind: 'fetch'; readonly message: string }
| { readonly kind: 'invalidated'; readonly reason: InvalidationReason };
export interface UseFreshCollectionOptions<T> {
/** Source identity for provenance labels, e.g. `gateway:/api/tasks`. */
readonly source: string;
/** Performs the unvalidated fetch. The hook owns abort and verification. */
readonly fetcher: (signal: AbortSignal) => Promise<unknown>;
/**
* Runtime schema validator. Returning `null` invalidates the payload
* (`schema-mismatch`) instead of letting malformed JSON flow into render.
*/
readonly validate: (value: unknown) => FreshPayload<T> | null;
/** Overrides of the default freshness policy. */
readonly policy?: Partial<FreshnessPolicy>;
/**
* Session cache key for last-known snapshots. `null`/omitted disables
* restore. Restored snapshots are unverified: they render only as
* labeled `stale` data until a fetch re-verifies them.
*/
readonly cacheKey?: string | null;
/** Injectable clock for deterministic age transitions in tests. */
readonly clock?: () => number;
/** Aging tick interval override (default derived from `staleAfterMs`). */
readonly tickMs?: number;
/** When false, no fetch runs (surfaces stay `unavailable`/`unknown`). */
readonly enabled?: boolean;
}
export interface FreshCollection<T> {
/** Last verified (or restored-unverified) snapshot, or `null`. */
readonly snapshot: FreshSnapshot<T> | null;
/** Snapshot data or `null` — never a fabricated empty collection. */
readonly data: T | null;
readonly freshness: FreshnessState;
/** True while a validation request is in flight. */
readonly validating: boolean;
/** Outcome of the latest failed validation, `null` when healthy. */
readonly failure: FreshnessFailure | null;
/** False unless freshness is `current`; drives disabled UI affordances. */
readonly canMutate: boolean;
/** Re-run the fetch and re-verify. Always allowed (it is a read). */
readonly revalidate: () => Promise<void>;
/**
* Run a state-changing operation against verified-current data only.
* Rejects with `StaleMutationError` on any other state — the guard fires
* even if a disabled button was bypassed (defense in depth).
*/
readonly mutate: <R>(operation: (data: T) => Promise<R>) => Promise<R>;
}
const defaultClock = (): number => Date.now();
function resolveTickMs(policy: FreshnessPolicy, override?: number): number {
if (override !== undefined && override > 0) return override;
return Math.min(5_000, Math.max(250, Math.floor(policy.staleAfterMs / 4)));
}
function isAuthFailure(caught: unknown): boolean {
return (
typeof caught === 'object' &&
caught !== null &&
'statusCode' in caught &&
((caught as { statusCode?: unknown }).statusCode === 401 ||
(caught as { statusCode?: unknown }).statusCode === 403)
);
}
function fetchFailureMessage(caught: unknown): string {
if (caught instanceof Error && caught.message.trim().length > 0) return caught.message;
return 'The request failed.';
}
/** Human-readable summary of a failure for unavailable/stale notices. */
export function describeFailure(failure: FreshnessFailure | null): string | null {
if (failure === null) return null;
if (failure.kind === 'fetch') return failure.message;
return `The snapshot was invalidated: ${invalidationReasonLabels[failure.reason]}.`;
}
export function useFreshCollection<T>(options: UseFreshCollectionOptions<T>): FreshCollection<T> {
const optionsRef = useRef(options);
optionsRef.current = options;
const policy = useMemo<FreshnessPolicy>(
() => ({ ...DEFAULT_FRESHNESS_POLICY, ...options.policy }),
[options.policy],
);
const policyRef = useRef(policy);
policyRef.current = policy;
const clockRef = useRef(options.clock ?? defaultClock);
clockRef.current = options.clock ?? defaultClock;
const [snapshot, setSnapshot] = useState<FreshSnapshot<T> | null>(null);
const [failure, setFailure] = useState<FreshnessFailure | null>(null);
const [unverified, setUnverified] = useState(false);
const [validating, setValidating] = useState(options.enabled !== false);
const [now, setNow] = useState(() => (options.clock ?? defaultClock)());
const snapshotRef = useRef(snapshot);
snapshotRef.current = snapshot;
const failureRef = useRef(failure);
failureRef.current = failure;
const unverifiedRef = useRef(unverified);
unverifiedRef.current = unverified;
const runRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);
const revalidate = useCallback(async (): Promise<void> => {
const current = optionsRef.current;
if (current.enabled === false) {
setValidating(false);
return;
}
const runId = ++runRef.current;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setValidating(true);
let value: unknown;
try {
value = await current.fetcher(controller.signal);
} catch (caught) {
if (runRef.current !== runId || controller.signal.aborted) return;
if (isAuthFailure(caught)) {
// An unauthenticated viewer must not keep (or be served) the
// previous user's last-known data.
setSnapshot(null);
setUnverified(false);
if (current.cacheKey) clearSnapshotCache(current.cacheKey);
}
setFailure({ kind: 'fetch', message: fetchFailureMessage(caught) });
setValidating(false);
return;
}
if (runRef.current !== runId) return;
const result = acceptSnapshot({
value,
validate: current.validate,
previous: snapshotRef.current,
policy: policyRef.current,
source: current.source,
now: clockRef.current(),
});
if (result.outcome === 'accepted') {
setSnapshot(result.snapshot);
setUnverified(false);
setFailure(null);
if (current.cacheKey) writeSnapshotCache(current.cacheKey, result.snapshot);
} else {
if (result.reason === 'cross-workspace') {
// Data verified for a different workspace must not linger as
// last-known situational awareness either.
setSnapshot(null);
setUnverified(false);
}
if (current.cacheKey) clearSnapshotCache(current.cacheKey);
setFailure({ kind: 'invalidated', reason: result.reason });
}
setValidating(false);
}, []);
// Restore the last-known snapshot (unverified) and run the first fetch.
useEffect(() => {
if (optionsRef.current.enabled === false) {
setValidating(false);
return;
}
const cacheKey = optionsRef.current.cacheKey;
if (cacheKey) {
const restored = readSnapshotCache<T>({
key: cacheKey,
workspace: policyRef.current.workspace,
policy: policyRef.current,
validate: optionsRef.current.validate,
});
if (restored.outcome === 'hit') {
setSnapshot(restored.snapshot);
setUnverified(true);
} else if (restored.outcome === 'invalidated') {
// A corrupted/foreign/regressed entry is dropped immediately; it must
// never surface as data. The fetch decides the visible state.
clearSnapshotCache(cacheKey);
}
}
void revalidate();
return () => {
abortRef.current?.abort();
};
// Mount-once by design: `revalidate` is stable and reads live options
// through refs, so it never needs to re-run when options change.
// Route-param pages remount this hook via an identity `key` instead.
}, [revalidate]);
// Aging tick: recomputes freshness as the snapshot ages past the policy.
useEffect(() => {
const interval = setInterval(
() => {
setNow(clockRef.current());
},
resolveTickMs(policyRef.current, optionsRef.current.tickMs),
);
return () => clearInterval(interval);
}, []);
const freshness = useMemo<FreshnessState>(() => {
if (snapshot === null) return validating ? 'unknown' : 'unavailable';
return computeFreshness({
snapshot,
policy,
now,
degraded: failure !== null || unverified,
});
// `now` from state covers age; refs inside computeFreshness are pure.
}, [snapshot, validating, failure, unverified, now, policy]);
const canMutate = freshness === 'current';
const mutate = useCallback(async <R>(operation: (data: T) => Promise<R>): Promise<R> => {
const currentSnapshot = snapshotRef.current;
// No verified snapshot at all: with nothing verified there is nothing
// current to mutate, regardless of the recorded failure.
if (currentSnapshot === null) throw new StaleMutationError('unavailable');
const state = computeFreshness({
snapshot: currentSnapshot,
policy: policyRef.current,
now: clockRef.current(),
degraded: failureRef.current !== null || unverifiedRef.current,
});
assertMutable(state);
return operation(currentSnapshot.data);
}, []);
return {
snapshot,
data: snapshot === null ? null : snapshot.data,
freshness,
validating,
failure,
canMutate,
revalidate,
mutate,
};
}
@@ -1,103 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { Mission, Project, Task } from '@/lib/types';
import {
validateMissionCollection,
validateProjectCollection,
validateProjectEntity,
validateTaskCollection,
} from './validators';
import { missionFixtures, projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';
describe('validateTaskCollection', () => {
it('accepts a well-formed task collection', () => {
expect(validateTaskCollection(taskFixtures)).toEqual({
data: taskFixtures,
workspace: null,
});
});
it('accepts an empty collection (a healthy empty state is a valid payload)', () => {
expect(validateTaskCollection([])).toEqual({ data: [], workspace: null });
});
it.each([
['not an array', { items: [] }],
['item is not an object', ['nope']],
['missing id', [{ ...(taskFixtures[0] as Task), id: undefined }]],
['missing title', [{ ...(taskFixtures[0] as Task), title: undefined }]],
['unknown status enum', [{ ...(taskFixtures[0] as Task), status: 'finished' }]],
['unknown priority enum', [{ ...(taskFixtures[0] as Task), priority: 'urgent' }]],
['tags of the wrong type', [{ ...(taskFixtures[0] as Task), tags: 'spa' }]],
['metadata of the wrong type', [{ ...(taskFixtures[0] as Task), metadata: 'notes' }]],
['createdAt of the wrong type', [{ ...(taskFixtures[0] as Task), createdAt: 1234 }]],
['null sneaks past a required string', [{ ...(taskFixtures[0] as Task), title: null }]],
])('rejects a malformed payload: %s', (_label, value) => {
expect(validateTaskCollection(value)).toBeNull();
});
});
describe('validateMissionCollection', () => {
it('accepts a well-formed mission collection', () => {
expect(validateMissionCollection(missionFixtures)).toEqual({
data: missionFixtures,
workspace: null,
});
});
it.each([
['not an array', null],
['item missing name', [{ ...(missionFixtures[0] as Mission), name: 42 }]],
['unknown status enum', [{ ...(missionFixtures[0] as Mission), status: 'canceled' }]],
['projectId of the wrong type', [{ ...(missionFixtures[0] as Mission), projectId: 7 }]],
])('rejects a malformed payload: %s', (_label, value) => {
expect(validateMissionCollection(value)).toBeNull();
});
});
describe('validateProjectCollection', () => {
it('accepts a uniform workspace-scoped collection and reports its workspace', () => {
expect(validateProjectCollection(projectFixtures)).toEqual({
data: projectFixtures,
workspace: 'user-1',
});
});
it('accepts an empty collection with no workspace identity', () => {
expect(validateProjectCollection([])).toEqual({ data: [], workspace: null });
});
it.each([
['not an array', 42],
['item missing userId', [{ ...(projectFixtures[0] as Project), userId: undefined }]],
['unknown status enum', [{ ...(projectFixtures[0] as Project), status: 'live' }]],
['description of the wrong type', [{ ...(projectFixtures[0] as Project), description: 1 }]],
])('rejects a malformed payload: %s', (_label, value) => {
expect(validateProjectCollection(value)).toBeNull();
});
it('rejects a collection mixing workspace identities (cross-workspace leak)', () => {
const mixed = [
projectFixtures[0] as Project,
{ ...(projectFixtures[1] as Project), userId: 'user-2' },
];
expect(validateProjectCollection(mixed)).toBeNull();
});
});
describe('validateProjectEntity', () => {
it('accepts a well-formed project and reports its workspace', () => {
expect(validateProjectEntity(projectFixtures[0])).toEqual({
data: projectFixtures[0],
workspace: 'user-1',
});
});
it.each([
['not an object', 'project-1'],
['null', null],
['array', [projectFixtures[0]]],
['missing userId', [{ ...(projectFixtures[0] as Project), userId: null }]],
])('rejects a malformed entity: %s', (_label, value) => {
expect(validateProjectEntity(value)).toBeNull();
});
});
-135
View File
@@ -1,135 +0,0 @@
import type { Mission, Project, Task, MissionStatus, TaskPriority, TaskStatus } from '@/lib/types';
import type { FreshPayload } from './model';
/**
* Runtime schema validators for gateway collections (RI-5-001).
*
* `api<T>()` returns untrusted JSON cast to `T`; these validators are the
* seam where a malformed response becomes an explicit schema mismatch
* instead of flowing into the render path as if it were healthy data.
*/
const taskStatuses: readonly TaskStatus[] = [
'not-started',
'in-progress',
'blocked',
'done',
'cancelled',
];
const taskPriorities: readonly TaskPriority[] = ['critical', 'high', 'medium', 'low'];
const missionStatuses: readonly MissionStatus[] = [
'planning',
'active',
'paused',
'completed',
'failed',
];
const projectStatuses: readonly Project['status'][] = ['active', 'paused', 'completed', 'archived'];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}
function isOneOf<T extends string>(value: unknown, allowed: readonly T[]): value is T {
return typeof value === 'string' && (allowed as readonly string[]).includes(value);
}
function isNullableRecord(value: unknown): value is Record<string, unknown> | null {
return value === null || isRecord(value);
}
function isNullableStringArray(value: unknown): value is string[] | null {
if (value === null) return true;
if (!Array.isArray(value)) return false;
return value.every((item) => typeof item === 'string');
}
function isIsoLike(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}
function isTask(value: unknown): value is Task {
if (!isRecord(value)) return false;
return (
isString(value['id']) &&
isString(value['title']) &&
isOneOf(value['status'], taskStatuses) &&
isOneOf(value['priority'], taskPriorities) &&
isNullableString(value['projectId']) &&
isNullableString(value['missionId']) &&
isNullableString(value['assignee']) &&
isNullableStringArray(value['tags']) &&
isNullableRecord(value['metadata']) &&
isNullableString(value['dueDate']) &&
isIsoLike(value['createdAt']) &&
isIsoLike(value['updatedAt'])
);
}
/** Tasks carry no workspace identity; scope falls back to the policy. */
export function validateTaskCollection(value: unknown): FreshPayload<Task[]> | null {
if (!Array.isArray(value) || !value.every(isTask)) return null;
return { data: value as Task[], workspace: null };
}
function isMission(value: unknown): value is Mission {
if (!isRecord(value)) return false;
return (
isString(value['id']) &&
isString(value['name']) &&
isOneOf(value['status'], missionStatuses) &&
isNullableString(value['projectId']) &&
isNullableString(value['description']) &&
isNullableRecord(value['metadata']) &&
isIsoLike(value['createdAt']) &&
isIsoLike(value['updatedAt'])
);
}
/** Missions carry no workspace identity; scope falls back to the policy. */
export function validateMissionCollection(value: unknown): FreshPayload<Mission[]> | null {
if (!Array.isArray(value) || !value.every(isMission)) return null;
return { data: value as Mission[], workspace: null };
}
function isProject(value: unknown): value is Project {
if (!isRecord(value)) return false;
return (
isString(value['id']) &&
isString(value['name']) &&
isOneOf(value['status'], projectStatuses) &&
isString(value['userId']) &&
isNullableString(value['description']) &&
isNullableRecord(value['metadata']) &&
isIsoLike(value['createdAt']) &&
isIsoLike(value['updatedAt'])
);
}
/**
* Projects are workspace-scoped: every item must carry the same `userId`.
* A collection mixing identities (cross-workspace leak) is a schema
* mismatch; the uniform `userId` becomes the snapshot workspace.
*/
export function validateProjectCollection(value: unknown): FreshPayload<Project[]> | null {
if (!Array.isArray(value) || !value.every(isProject)) return null;
const projects = value as Project[];
const workspaces = new Set(projects.map((project) => project.userId));
if (workspaces.size > 1) return null;
return { data: projects, workspace: projects.length > 0 ? projects[0]!.userId : null };
}
/** Single project entity (project detail primary collection). */
export function validateProjectEntity(value: unknown): FreshPayload<Project> | null {
if (!isProject(value)) return null;
const project = value as Project;
return { data: project, workspace: project.userId };
}
+18 -162
View File
@@ -35,7 +35,6 @@ afterEach(async () => {
document.body.replaceChildren(); document.body.replaceChildren();
root = null; root = null;
apiMock.mockReset(); apiMock.mockReset();
sessionStorage.clear();
}); });
async function renderProjectDetailPage(): Promise<ReturnType<typeof createMemoryRouter>> { async function renderProjectDetailPage(): Promise<ReturnType<typeof createMemoryRouter>> {
@@ -65,49 +64,21 @@ function clickButtonByText(text: string): void {
button.dispatchEvent(new MouseEvent('click', { bubbles: true })); button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
} }
async function flushAct(): Promise<void> { describe('ProjectDetailPage', () => {
await act(async () => { it('loads the project, tasks, missions, and optional PRD content for the active project', async () => {
await Promise.resolve();
});
}
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
const projectOneTasks = taskFixtures.filter((task) => task.projectId === 'project-1');
function mockHealthyLoad(): void {
apiMock apiMock
.mockResolvedValueOnce(projectFixtures[0]) .mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures) .mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(projectOneTasks); .mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
}
describe('ProjectDetailPage', () => {
it('loads the project, tasks, missions, and optional PRD content for the active project', async () => {
mockHealthyLoad();
await renderProjectDetailPage(); await renderProjectDetailPage();
expect(apiMock.mock.calls.map((call) => call[0])).toEqual([ expect(apiMock.mock.calls).toEqual([
'/api/projects/project-1', ['/api/projects/project-1'],
'/api/missions', ['/api/missions'],
'/api/tasks?projectId=project-1', ['/api/tasks?projectId=project-1'],
]); ]);
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
expect(container.textContent).toContain('Mosaic Stack'); expect(container.textContent).toContain('Mosaic Stack');
expect(container.textContent).toContain('Route /projects/:id'); expect(container.textContent).toContain('Route /projects/:id');
expect(container.textContent).toContain('Tasks'); expect(container.textContent).toContain('Tasks');
@@ -130,7 +101,10 @@ describe('ProjectDetailPage', () => {
}); });
it('opens and closes the existing read-only task modal from the tasks tab', async () => { it('opens and closes the existing read-only task modal from the tasks tab', async () => {
mockHealthyLoad(); apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
await renderProjectDetailPage(); await renderProjectDetailPage();
@@ -160,153 +134,35 @@ describe('ProjectDetailPage', () => {
expect(container.querySelector('[role="dialog"]')).toBeNull(); expect(container.querySelector('[role="dialog"]')).toBeNull();
}); });
it('shows verified completion verdicts when the task collection is current', async () => { it('renders the project with an empty missions tab when the missions request fails', async () => {
mockHealthyLoad();
await renderProjectDetailPage();
const doneCard = [...container.querySelectorAll('div')].find(
(candidate) => candidate.textContent === 'Done1',
);
expect(doneCard).toBeTruthy();
const inProgressCard = [...container.querySelectorAll('div')].find(
(candidate) => candidate.textContent === 'In Progress1',
);
expect(inProgressCard).toBeTruthy();
});
it('renders an explicit unavailable missions tab when the missions request fails (partial, not empty)', async () => {
apiMock apiMock
.mockResolvedValueOnce(projectFixtures[0]) .mockResolvedValueOnce(projectFixtures[0])
.mockRejectedValueOnce(new Error('Missions request failed')) .mockRejectedValueOnce(new Error('Missions request failed'))
.mockResolvedValueOnce(projectOneTasks); .mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
await renderProjectDetailPage(); await renderProjectDetailPage();
// Secondary failure degrades the surface to partial; the project itself
// still renders.
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'partial',
);
expect(container.textContent).toContain('Mosaic Stack'); expect(container.textContent).toContain('Mosaic Stack');
const partial = container.querySelector('[role="status"]'); expect(container.querySelector('[role="alert"]')).toBeNull();
expect(partial?.textContent).toContain('Missions');
expect(partial?.textContent).toContain('unavailable');
await act(async () => { await act(async () => {
clickButtonByText('Missions (?)'); clickButtonByText('Missions (0)');
}); });
const alert = container.querySelector('[role="alert"]'); expect(container.textContent).toContain('No missions for this project');
expect(alert?.textContent).toContain('Missions request failed');
// Negative control: a failed fetch must not look like an empty list.
expect(container.textContent).not.toContain('No missions for this project');
}); });
it('marks derived verdicts unknown when the tasks collection is unavailable', async () => { it('renders a visible alert when the project request fails and lets the user navigate back', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockRejectedValueOnce(new Error('Tasks request failed'));
await renderProjectDetailPage();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'partial',
);
// Completion verdicts become unknown ('?') — never green counts.
for (const label of ['Done', 'In Progress', 'Blocked', 'Tasks']) {
const unknownCard = [...container.querySelectorAll('div')].find(
(candidate) => candidate.textContent === `${label}?`,
);
expect(unknownCard, `expected ${label} card to render ?`).toBeTruthy();
}
// Negative control: no green "Done 1" verdict anywhere.
expect(
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
).toBe(false);
await act(async () => {
clickButtonByText('Tasks (?)');
});
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Tasks request failed');
// Negative control: no healthy empty task list from a failed fetch.
expect(container.textContent).not.toContain('No tasks found');
expect(container.querySelector('table')).toBeNull();
});
it('recovers a partial surface to current after revalidation', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockRejectedValueOnce(new Error('Tasks request failed'))
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(projectOneTasks);
await renderProjectDetailPage();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'partial',
);
await act(async () => {
clickButtonByText('Revalidate');
});
await flushAct();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
expect(
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
).toBe(true);
});
it("never shows one project's data on another project's route after navigation", async () => {
mockHealthyLoad();
const router = await renderProjectDetailPage();
expect(container.textContent).toContain('Mosaic Stack');
const deferred = createDeferred<(typeof projectFixtures)[number]>();
apiMock
.mockResolvedValueOnce(deferred.promise)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await act(async () => {
await router.navigate('/projects/project-2');
});
// While project-2 loads, nothing from project-1 may render on its route.
expect(container.textContent).toContain('Loading project...');
expect(container.textContent).not.toContain('Mosaic Stack');
expect(container.textContent).not.toContain('Route /projects/:id');
await act(async () => {
deferred.resolve(projectFixtures[1]!);
await deferred.promise;
});
expect(container.textContent).toContain('Agent Runtime');
expect(apiMock.mock.calls[3]?.[0]).toBe('/api/projects/project-2');
});
it('renders a visible unavailable state when the project request fails and lets the user navigate back', async () => {
apiMock apiMock
.mockRejectedValueOnce(new Error('Project request failed')) .mockRejectedValueOnce(new Error('Project request failed'))
.mockResolvedValueOnce(missionFixtures) .mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(projectOneTasks); .mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
const router = await renderProjectDetailPage(); const router = await renderProjectDetailPage();
const alert = container.querySelector('[role="alert"]'); const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy(); expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Project request failed'); expect(alert?.textContent).toContain('Project request failed');
expect(alert?.textContent).toContain('not an empty result');
expect(container.textContent).not.toContain('Mosaic Stack'); expect(container.textContent).not.toContain('Mosaic Stack');
await act(async () => { await act(async () => {
+75 -188
View File
@@ -1,30 +1,14 @@
import { useState, type ReactElement } from 'react'; import { useEffect, useState, type ReactElement } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { MissionTimeline } from '@/components/projects/mission-timeline'; import { MissionTimeline } from '@/components/projects/mission-timeline';
import { PrdViewer } from '@/components/projects/prd-viewer'; import { PrdViewer } from '@/components/projects/prd-viewer';
import { TaskDetailModal } from '@/components/tasks/task-detail-modal'; import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
import { TaskListView } from '@/components/tasks/task-list-view'; import { TaskListView } from '@/components/tasks/task-list-view';
import { TaskStatusSummary } from '@/components/tasks/task-status-summary'; import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
import {
PartialDataNotice,
StaleDataNotice,
UnavailableDataNotice,
} from '@/components/freshness/freshness-notices';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import type { Mission, Project, Task, TaskStatus } from '@/lib/types'; import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
import { import { getErrorMessage } from './page-errors';
combineFreshness,
UNKNOWN_VERDICT,
verdictValue,
type FreshSnapshot,
} from '@/lib/freshness/model';
import { describeFailure, useFreshCollection } from '@/lib/freshness/use-fresh-collection';
import {
validateMissionCollection,
validateProjectEntity,
validateTaskCollection,
} from '@/lib/freshness/validators';
type Tab = 'overview' | 'tasks' | 'missions' | 'prd'; type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
@@ -67,75 +51,55 @@ function TabButton({ id, label, activeTab, onClick }: TabButtonProps): ReactElem
); );
} }
/** Remounts per project id so no state from one project renders for another. */
export function ProjectDetailPage(): ReactElement { export function ProjectDetailPage(): ReactElement {
const { id = '' } = useParams(); const { id = '' } = useParams();
return <ProjectDetail id={id} key={id} />;
}
function ProjectDetail({ id }: { id: string }): ReactElement {
const navigate = useNavigate(); const navigate = useNavigate();
const enabled = id.length > 0; const [project, setProject] = useState<Project | null>(null);
const [missions, setMissions] = useState<Mission[]>([]);
// Primary collection gates the surface; missions and tasks are secondaries const [tasks, setTasks] = useState<Task[]>([]);
// whose failures degrade the surface to `partial` instead of rendering const [loading, setLoading] = useState(true);
// empty healthy lists. const [error, setError] = useState<string | null>(null);
const project = useFreshCollection<Project>({
source: `gateway:/api/projects/${id}`,
fetcher: (signal) => api<unknown>(`/api/projects/${id}`, { signal }),
validate: validateProjectEntity,
// No last-known restore: the entity carries workspace identity that
// cannot be scope-checked before display (see ProjectsPage note).
enabled,
});
const missions = useFreshCollection<Mission[]>({
source: 'gateway:/api/missions',
fetcher: (signal) => api<unknown>('/api/missions', { signal }),
validate: validateMissionCollection,
cacheKey: enabled ? 'missions' : null,
enabled,
});
const tasks = useFreshCollection<Task[]>({
source: `gateway:/api/tasks?projectId=${id}`,
fetcher: (signal) => api<unknown>(`/api/tasks?projectId=${id}`, { signal }),
validate: validateTaskCollection,
cacheKey: enabled ? `project-tasks:${id}` : null,
enabled,
});
const [activeTab, setActiveTab] = useState<Tab>('overview'); const [activeTab, setActiveTab] = useState<Tab>('overview');
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all'); const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
const [selectedTask, setSelectedTask] = useState<Task | null>(null); const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const surface = combineFreshness(project.freshness, [missions.freshness, tasks.freshness]); useEffect(() => {
const tasksVerified = tasks.freshness === 'current'; if (!id) {
const projectMissions = missions.data?.filter((mission) => mission.projectId === id) ?? null; setError('Project id is missing.');
setLoading(false);
const retryAll = (): void => { return;
void Promise.all([project.revalidate(), missions.revalidate(), tasks.revalidate()]);
};
if (!enabled) {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1>
</header>
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
Project id is missing.
</div>
<button
type="button"
onClick={() => navigate('/projects')}
className="mt-4 w-fit text-sm underline"
>
Back to projects
</button>
</div>
);
} }
if (project.freshness === 'unknown') { let cancelled = false;
setLoading(true);
setError(null);
void Promise.all([
api<Project>('/api/projects/' + id),
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
api<Task[]>('/api/tasks?projectId=' + id).catch(() => [] as Task[]),
])
.then(([loadedProject, allMissions, loadedTasks]) => {
if (cancelled) return;
setProject(loadedProject);
setMissions(allMissions.filter((mission) => mission.projectId === id));
setTasks(loadedTasks);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load project.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [id]);
if (loading) {
return ( return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6"> <div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3"> <header className="mb-6 border-b px-1 pb-3">
@@ -146,17 +110,15 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
); );
} }
if (project.freshness === 'unavailable' || project.data === null) { if (error || !project) {
return ( return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6"> <div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3"> <header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1> <h1 className="text-2xl font-semibold">Project</h1>
</header> </header>
<UnavailableDataNotice <div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
title="This project" {error ?? 'Project not found.'}
detail={describeFailure(project.failure)} </div>
onRetry={retryAll}
/>
<button <button
type="button" type="button"
onClick={() => navigate('/projects')} onClick={() => navigate('/projects')}
@@ -168,48 +130,18 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
); );
} }
const projectTasks = tasks.data ?? null;
const filteredTasks = const filteredTasks =
projectTasks === null taskFilter === 'all' ? tasks : tasks.filter((task) => task.status === taskFilter);
? [] const prdContent = getPrdContent(project);
: taskFilter === 'all'
? projectTasks
: projectTasks.filter((task) => task.status === taskFilter);
// Derived completion verdicts: unknown (never green) unless the task
// collection is verified current.
const doneCount = projectTasks?.filter((task) => task.status === 'done').length ?? 0;
const inProgressCount = projectTasks?.filter((task) => task.status === 'in-progress').length ?? 0;
const blockedCount = projectTasks?.filter((task) => task.status === 'blocked').length ?? 0;
const prdContent = getPrdContent(project.data);
const tabs: Array<{ id: Tab; label: string }> = [ const tabs: Array<{ id: Tab; label: string }> = [
{ id: 'overview', label: 'Overview' }, { id: 'overview', label: 'Overview' },
{ { id: 'tasks', label: `Tasks (${tasks.length})` },
id: 'tasks', { id: 'missions', label: `Missions (${missions.length})` },
label: `Tasks (${projectTasks === null ? UNKNOWN_VERDICT : projectTasks.length})`,
},
{
id: 'missions',
label: `Missions (${projectMissions === null ? UNKNOWN_VERDICT : projectMissions.length})`,
},
...(prdContent ? [{ id: 'prd' as const, label: 'PRD' }] : []), ...(prdContent ? [{ id: 'prd' as const, label: 'PRD' }] : []),
]; ];
const staleSnapshot: FreshSnapshot<unknown> | null =
project.freshness === 'stale'
? project.snapshot
: missions.freshness === 'stale'
? missions.snapshot
: tasks.freshness === 'stale'
? tasks.snapshot
: null;
const missingSections: string[] = [];
if (missions.freshness === 'unavailable') missingSections.push('Missions');
if (tasks.freshness === 'unavailable') missingSections.push('Tasks');
return ( return (
<div data-freshness={surface} className="flex min-h-screen flex-col px-4 py-6 sm:px-6"> <div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3"> <header className="mb-6 border-b px-1 pb-3">
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted"> <nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
<button <button
@@ -220,64 +152,49 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
Projects Projects
</button> </button>
<span>/</span> <span>/</span>
<span className="text-text-primary">{project.data.name}</span> <span className="text-text-primary">{project.name}</span>
</nav> </nav>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-text-primary">{project.data.name}</h1> <h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
<span <span
className={cn( className={cn(
'rounded-full px-2 py-0.5 text-xs', 'rounded-full px-2 py-0.5 text-xs',
projectStatusColors[project.data.status] ?? 'bg-gray-600/20 text-gray-400', projectStatusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
)} )}
> >
{project.data.status} {project.status}
</span> </span>
</div> </div>
{project.data.description ? ( {project.description ? (
<p className="mt-1 text-sm text-text-muted">{project.data.description}</p> <p className="mt-1 text-sm text-text-muted">{project.description}</p>
) : null} ) : null}
<p className="mt-2 text-xs text-text-muted"> <p className="mt-2 text-xs text-text-muted">
Created {new Date(project.data.createdAt).toLocaleDateString()} · Updated{' '} Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
{new Date(project.data.updatedAt).toLocaleDateString()} {new Date(project.updatedAt).toLocaleDateString()}
</p> </p>
</div> </div>
</div> </div>
</header> </header>
{staleSnapshot !== null ? (
<div className="mb-6">
<StaleDataNotice label={staleSnapshot} onRetry={retryAll} />
</div>
) : null}
{missingSections.length > 0 ? (
<div className="mb-6">
<PartialDataNotice missing={missingSections} onRetry={retryAll} />
</div>
) : null}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard <StatCard label="Tasks" value={String(tasks.length)} />
label="Tasks"
value={projectTasks === null ? UNKNOWN_VERDICT : String(projectTasks.length)}
/>
<StatCard <StatCard
label="Done" label="Done"
value={verdictValue(tasksVerified, String(doneCount))} value={String(tasks.filter((task) => task.status === 'done').length)}
valueClass={tasksVerified ? 'text-success' : undefined} valueClass="text-success"
/> />
<StatCard <StatCard
label="In Progress" label="In Progress"
value={verdictValue(tasksVerified, String(inProgressCount))} value={String(tasks.filter((task) => task.status === 'in-progress').length)}
valueClass={tasksVerified ? 'text-blue-400' : undefined} valueClass="text-blue-400"
/> />
<StatCard <StatCard
label="Blocked" label="Blocked"
value={verdictValue(tasksVerified, String(blockedCount))} value={String(tasks.filter((task) => task.status === 'blocked').length)}
valueClass={tasksVerified && blockedCount > 0 ? 'text-error' : undefined} valueClass={tasks.some((task) => task.status === 'blocked') ? 'text-error' : undefined}
/> />
</div> </div>
@@ -294,43 +211,23 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
</div> </div>
{activeTab === 'overview' ? ( {activeTab === 'overview' ? (
<OverviewTab project={project.data} missions={projectMissions} tasks={projectTasks} /> <OverviewTab project={project} missions={missions} tasks={tasks} />
) : null} ) : null}
{activeTab === 'tasks' ? ( {activeTab === 'tasks' ? (
<div> <div>
{projectTasks === null ? (
<UnavailableDataNotice
title="Tasks"
detail={describeFailure(tasks.failure)}
onRetry={retryAll}
/>
) : (
<>
<div className="mb-4"> <div className="mb-4">
<TaskStatusSummary <TaskStatusSummary
tasks={projectTasks} tasks={tasks}
activeFilter={taskFilter} activeFilter={taskFilter}
onFilterChange={setTaskFilter} onFilterChange={setTaskFilter}
/> />
</div> </div>
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} /> <TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
</>
)}
</div> </div>
) : null} ) : null}
{activeTab === 'missions' ? ( {activeTab === 'missions' ? <MissionTimeline missions={missions} /> : null}
projectMissions === null ? (
<UnavailableDataNotice
title="Missions"
detail={describeFailure(missions.failure)}
onRetry={retryAll}
/>
) : (
<MissionTimeline missions={projectMissions} />
)
) : null}
{activeTab === 'prd' && prdContent ? ( {activeTab === 'prd' && prdContent ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-6"> <div className="rounded-lg border border-surface-border bg-surface-card p-6">
@@ -351,26 +248,18 @@ function OverviewTab({
tasks, tasks,
}: { }: {
project: Project; project: Project;
missions: Mission[] | null; missions: Mission[];
tasks: Task[] | null; tasks: Task[];
}): ReactElement { }): ReactElement {
const recentTasks = const recentTasks = [...tasks]
tasks === null .sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
? null
: [...tasks]
.sort(
(left, right) =>
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
)
.slice(0, 5); .slice(0, 5);
return ( return (
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<section> <section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2> <h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
{recentTasks === null ? ( {recentTasks.length === 0 ? (
<UnavailableDataNotice title="Tasks" />
) : recentTasks.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center"> <div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No tasks yet</p> <p className="text-sm text-text-muted">No tasks yet</p>
</div> </div>
@@ -398,9 +287,7 @@ function OverviewTab({
<section> <section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2> <h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
{missions === null ? ( {missions.length === 0 ? (
<UnavailableDataNotice title="Missions" />
) : missions.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center"> <div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No missions yet</p> <p className="text-sm text-text-muted">No missions yet</p>
</div> </div>
+3 -69
View File
@@ -51,7 +51,6 @@ afterEach(async () => {
document.body.replaceChildren(); document.body.replaceChildren();
root = null; root = null;
apiMock.mockReset(); apiMock.mockReset();
sessionStorage.clear();
}); });
async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRouter>> { async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRouter>> {
@@ -72,22 +71,6 @@ async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRoute
return router; return router;
} }
function clickButtonByText(text: string): void {
const button = [...container.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes(text),
);
if (!button) {
throw new Error(`Button containing "${text}" not found`);
}
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
async function flushAct(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
describe('ProjectsPage', () => { describe('ProjectsPage', () => {
it('shows a visible loading state while the project request is in flight', async () => { it('shows a visible loading state while the project request is in flight', async () => {
const deferred = createDeferred<typeof projectFixtures>(); const deferred = createDeferred<typeof projectFixtures>();
@@ -108,7 +91,7 @@ describe('ProjectsPage', () => {
const router = await renderProjectsPage(); const router = await renderProjectsPage();
expect(apiMock.mock.calls[0]?.[0]).toBe('/api/projects'); expect(apiMock).toHaveBeenCalledWith('/api/projects');
expect(container.textContent).toContain('Mosaic Stack'); expect(container.textContent).toContain('Mosaic Stack');
expect(container.textContent).toContain('Agent Runtime'); expect(container.textContent).toContain('Agent Runtime');
@@ -125,7 +108,7 @@ describe('ProjectsPage', () => {
expect(container.textContent).toContain('Project detail target'); expect(container.textContent).toContain('Project detail target');
}); });
it('renders the empty state only for a verified empty collection', async () => { it('renders the empty state when the API returns no projects', async () => {
apiMock.mockResolvedValueOnce([]); apiMock.mockResolvedValueOnce([]);
await renderProjectsPage(); await renderProjectsPage();
@@ -134,12 +117,9 @@ describe('ProjectsPage', () => {
expect(container.textContent).toContain( expect(container.textContent).toContain(
'Projects will appear here when created via the gateway API', 'Projects will appear here when created via the gateway API',
); );
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
}); });
it('renders a failed fetch as an explicit unavailable state, never an empty collection', async () => { it('renders a visible alert when the projects request fails', async () => {
apiMock.mockRejectedValueOnce(new Error('Projects are unavailable')); apiMock.mockRejectedValueOnce(new Error('Projects are unavailable'));
await renderProjectsPage(); await renderProjectsPage();
@@ -147,51 +127,5 @@ describe('ProjectsPage', () => {
const alert = container.querySelector('[role="alert"]'); const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy(); expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Projects are unavailable'); expect(alert?.textContent).toContain('Projects are unavailable');
expect(alert?.textContent).toContain('not an empty result');
// Negative controls: no healthy empty state and no project cards render
// from a failed fetch.
expect(container.textContent).not.toContain('No projects yet');
expect(container.textContent).not.toContain('Mosaic Stack');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'unavailable',
);
});
it('renders an auth failure as unavailable and recovers after retry', async () => {
apiMock
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { statusCode: 401 }))
.mockResolvedValueOnce(projectFixtures);
await renderProjectsPage();
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Unauthorized');
expect(container.textContent).not.toContain('No projects yet');
await act(async () => {
clickButtonByText('Retry');
});
await flushAct();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.textContent).toContain('Mosaic Stack');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
it('renders a schema-mismatched response as unavailable, never as data', async () => {
apiMock.mockResolvedValueOnce({ results: projectFixtures });
await renderProjectsPage();
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('not an empty result');
expect(container.textContent).not.toContain('Mosaic Stack');
expect(container.textContent).not.toContain('No projects yet');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'unavailable',
);
}); });
}); });
+32 -30
View File
@@ -1,51 +1,53 @@
import { type ReactElement } from 'react'; import { useEffect, useState, type ReactElement } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ProjectCard } from '@/components/projects/project-card'; import { ProjectCard } from '@/components/projects/project-card';
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { Project } from '@/lib/types'; import type { Project } from '@/lib/types';
import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection'; import { getErrorMessage } from './page-errors';
import { validateProjectCollection } from '@/lib/freshness/validators';
export function ProjectsPage(): ReactElement { export function ProjectsPage(): ReactElement {
const navigate = useNavigate(); const navigate = useNavigate();
const projects = useFreshCollection<Project[]>({ const [projects, setProjects] = useState<Project[]>([]);
source: 'gateway:/api/projects', const [loading, setLoading] = useState(true);
fetcher: (signal) => api<unknown>('/api/projects', { signal }), const [error, setError] = useState<string | null>(null);
validate: validateProjectCollection,
// Projects carry workspace identity (userId) that is only knowable from useEffect(() => {
// the payload itself, so a restored entry cannot be scope-checked before let cancelled = false;
// display. Conservative choice: no last-known restore for this surface;
// cross-workspace switching is still invalidated at verification time. void api<Project[]>('/api/projects')
.then((response) => {
if (cancelled) return;
setProjects(response);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load projects.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
}); });
const retry = (): void => {
void projects.revalidate(); return () => {
cancelled = true;
}; };
}, []);
return ( return (
<div <div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
data-freshness={projects.freshness}
className="flex min-h-screen flex-col px-4 py-6 sm:px-6"
>
<header className="mb-6 border-b px-1 pb-3"> <header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Projects</h1> <h1 className="text-2xl font-semibold">Projects</h1>
</header> </header>
{projects.freshness === 'stale' && projects.snapshot ? ( {error ? (
<div className="mb-6"> <div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
<StaleDataNotice label={projects.snapshot} onRetry={retry} /> {error}
</div> </div>
) : null} ) : null}
{projects.freshness === 'unknown' ? ( {loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p> <p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
) : projects.freshness === 'unavailable' ? ( ) : projects.length === 0 ? (
<UnavailableDataNotice
title="Projects"
detail={describeFailure(projects.failure)}
onRetry={retry}
/>
) : projects.data !== null && projects.data.length === 0 ? (
<div className="py-12 text-center"> <div className="py-12 text-center">
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2> <h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
<p className="mt-1 text-sm text-text-muted"> <p className="mt-1 text-sm text-text-muted">
@@ -54,7 +56,7 @@ export function ProjectsPage(): ReactElement {
</div> </div>
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{(projects.data ?? []).map((project) => ( {projects.map((project) => (
<ProjectCard <ProjectCard
key={project.id} key={project.id}
project={project} project={project}
+1 -87
View File
@@ -3,9 +3,6 @@ import { createRoot, type Root } from 'react-dom/client';
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom'; import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { taskFixtures } from './page-fixtures'; import { taskFixtures } from './page-fixtures';
import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from '@/lib/freshness/model';
import { writeSnapshotCache } from '@/lib/freshness/snapshot-cache';
import { validateTaskCollection } from '@/lib/freshness/validators';
const { apiMock } = vi.hoisted(() => ({ const { apiMock } = vi.hoisted(() => ({
apiMock: vi.fn(), apiMock: vi.fn(),
@@ -51,7 +48,6 @@ afterEach(async () => {
document.body.replaceChildren(); document.body.replaceChildren();
root = null; root = null;
apiMock.mockReset(); apiMock.mockReset();
sessionStorage.clear();
}); });
async function renderTasksPage(): Promise<void> { async function renderTasksPage(): Promise<void> {
@@ -76,13 +72,6 @@ function clickButtonByText(text: string): void {
button.dispatchEvent(new MouseEvent('click', { bubbles: true })); button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
} }
/** Flush pending promise callbacks inside the act environment. */
async function flushAct(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
describe('TasksPage', () => { describe('TasksPage', () => {
it('shows a visible loading state before the tasks request settles', async () => { it('shows a visible loading state before the tasks request settles', async () => {
const deferred = createDeferred<typeof taskFixtures>(); const deferred = createDeferred<typeof taskFixtures>();
@@ -143,7 +132,7 @@ describe('TasksPage', () => {
expect(container.textContent).toContain('Wire list and kanban modal interactions'); expect(container.textContent).toContain('Wire list and kanban modal interactions');
}); });
it('renders a failed fetch as an explicit unavailable state, never an empty healthy board', async () => { it('renders a visible alert when the tasks request fails', async () => {
apiMock.mockRejectedValueOnce(new Error('Tasks request failed')); apiMock.mockRejectedValueOnce(new Error('Tasks request failed'));
await renderTasksPage(); await renderTasksPage();
@@ -151,80 +140,5 @@ describe('TasksPage', () => {
const alert = container.querySelector('[role="alert"]'); const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy(); expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Tasks request failed'); expect(alert?.textContent).toContain('Tasks request failed');
expect(alert?.textContent).toContain('not an empty result');
// Negative controls: no board, no healthy empty-state markers, and the
// surface is marked unavailable rather than current.
expect(container.textContent).not.toContain('Not Started');
expect(container.textContent).not.toContain('No tasks');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'unavailable',
);
});
it('recovers to a current board after retrying a failed fetch', async () => {
apiMock
.mockRejectedValueOnce(new Error('Tasks request failed'))
.mockResolvedValueOnce(taskFixtures);
await renderTasksPage();
expect(container.querySelector('[role="alert"]')).toBeTruthy();
await act(async () => {
clickButtonByText('Retry');
});
await flushAct();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.textContent).toContain('Not Started');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
it('labels restored last-known data as stale with source, version, and age until verified', async () => {
// Seed a last-known snapshot fetched five minutes ago; the page must
// render it only under an explicit staleness label while the fetch is
// still in flight.
const restored = acceptSnapshot({
value: taskFixtures,
validate: validateTaskCollection,
previous: null,
policy: DEFAULT_FRESHNESS_POLICY,
source: 'gateway:/api/tasks',
now: Date.now() - 5 * 60_000,
});
if (restored.outcome !== 'accepted') throw new Error('fixture setup failed');
writeSnapshotCache('tasks', restored.snapshot);
const deferred = createDeferred<typeof taskFixtures>();
apiMock.mockReturnValueOnce(deferred.promise);
await renderTasksPage();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'stale',
);
const banner = container.querySelector('[role="status"]');
expect(banner?.textContent).toContain('last-known');
expect(banner?.textContent).toContain('may be out of date');
expect(banner?.textContent).toContain('gateway:/api/tasks');
expect(banner?.textContent).toContain('snapshot v1');
expect(banner?.textContent).toContain('5m ago');
// Last-known data still renders as situational awareness under the label.
expect(container.textContent).toContain('Route /tasks');
expect(container.textContent).not.toContain('Loading tasks...');
// Verification lands: the banner clears and the surface becomes current.
await act(async () => {
deferred.resolve(taskFixtures);
await deferred.promise;
});
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
}); });
}); });
+32 -25
View File
@@ -1,32 +1,45 @@
import { useState, type ReactElement } from 'react'; import { useEffect, useState, type ReactElement } from 'react';
import { KanbanBoard } from '@/components/tasks/kanban-board'; import { KanbanBoard } from '@/components/tasks/kanban-board';
import { TaskDetailModal } from '@/components/tasks/task-detail-modal'; import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
import { TaskListView } from '@/components/tasks/task-list-view'; import { TaskListView } from '@/components/tasks/task-list-view';
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types'; import type { Task } from '@/lib/types';
import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection'; import { getErrorMessage } from './page-errors';
import { validateTaskCollection } from '@/lib/freshness/validators';
type ViewMode = 'list' | 'kanban'; type ViewMode = 'list' | 'kanban';
export function TasksPage(): ReactElement { export function TasksPage(): ReactElement {
const tasks = useFreshCollection<Task[]>({ const [tasks, setTasks] = useState<Task[]>([]);
source: 'gateway:/api/tasks',
fetcher: (signal) => api<unknown>('/api/tasks', { signal }),
validate: validateTaskCollection,
cacheKey: 'tasks',
});
const [view, setView] = useState<ViewMode>('kanban'); const [view, setView] = useState<ViewMode>('kanban');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null); const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const retry = (): void => { useEffect(() => {
void tasks.revalidate(); let cancelled = false;
void api<Task[]>('/api/tasks')
.then((response) => {
if (cancelled) return;
setTasks(response);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load tasks.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
}; };
}, []);
return ( return (
<div data-freshness={tasks.freshness} className="flex min-h-screen flex-col px-4 py-6 sm:px-6"> <div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 flex items-center justify-between gap-4 border-b px-1 pb-3"> <header className="mb-6 flex items-center justify-between gap-4 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Tasks</h1> <h1 className="text-2xl font-semibold">Tasks</h1>
<div className="flex rounded-lg border border-surface-border"> <div className="flex rounded-lg border border-surface-border">
@@ -57,24 +70,18 @@ export function TasksPage(): ReactElement {
</div> </div>
</header> </header>
{tasks.freshness === 'stale' && tasks.snapshot ? ( {error ? (
<div className="mb-6"> <div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
<StaleDataNotice label={tasks.snapshot} onRetry={retry} /> {error}
</div> </div>
) : null} ) : null}
{tasks.freshness === 'unknown' ? ( {loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p> <p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
) : tasks.freshness === 'unavailable' ? (
<UnavailableDataNotice
title="Tasks"
detail={describeFailure(tasks.failure)}
onRetry={retry}
/>
) : view === 'kanban' ? ( ) : view === 'kanban' ? (
<KanbanBoard tasks={tasks.data ?? []} onTaskClick={setSelectedTask} /> <KanbanBoard tasks={tasks} onTaskClick={setSelectedTask} />
) : ( ) : (
<TaskListView tasks={tasks.data ?? []} onTaskClick={setSelectedTask} /> <TaskListView tasks={tasks} onTaskClick={setSelectedTask} />
)} )}
{selectedTask ? ( {selectedTask ? (
@@ -1,74 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// homedir/platform are read at call time, so they can be stubbed per case.
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return {
...actual,
homedir: () => '/home/tester',
platform: () => mockPlatform,
};
});
let mockPlatform: NodeJS.Platform = 'linux';
const { getShellProfilePath, detectShell } = await import('../../src/platform/detect.js');
describe('getShellProfilePath', () => {
const originalShell = process.env['SHELL'];
const originalZdotdir = process.env['ZDOTDIR'];
beforeEach(() => {
mockPlatform = 'linux';
delete process.env['ZDOTDIR'];
});
afterEach(() => {
if (originalShell === undefined) delete process.env['SHELL'];
else process.env['SHELL'] = originalShell;
if (originalZdotdir === undefined) delete process.env['ZDOTDIR'];
else process.env['ZDOTDIR'] = originalZdotdir;
});
// The regression this guards: setupPath() in stages/finalize.ts appends the
// PATH export to whatever this returns. A line written to ~/.bashrc is
// unreachable to `bash -lc`, systemd units and agent seats, because Debian's
// default .bashrc returns early for non-interactive shells — so an install
// reported success and left `mosaic: command not found`. Same for .zshrc,
// which zsh only reads for interactive shells.
it('never targets an interactive-only rc file', () => {
for (const shell of ['/bin/bash', '/usr/bin/zsh']) {
process.env['SHELL'] = shell;
const profile = getShellProfilePath();
expect(profile).not.toMatch(/\.bashrc$/);
expect(profile).not.toMatch(/\.zshrc$/);
}
});
it('uses ~/.profile for bash', () => {
process.env['SHELL'] = '/bin/bash';
expect(getShellProfilePath()).toBe('/home/tester/.profile');
});
it('uses ~/.zshenv for zsh', () => {
process.env['SHELL'] = '/usr/bin/zsh';
expect(getShellProfilePath()).toBe('/home/tester/.zshenv');
});
it('honours ZDOTDIR for zsh', () => {
process.env['SHELL'] = '/usr/bin/zsh';
process.env['ZDOTDIR'] = '/custom/zdot';
expect(getShellProfilePath()).toBe('/custom/zdot/.zshenv');
});
it('falls back to ~/.profile for an unknown shell', () => {
process.env['SHELL'] = '/bin/somethingelse';
expect(detectShell()).toBe('unknown');
expect(getShellProfilePath()).toBe('/home/tester/.profile');
});
it('still routes fish to its own config', () => {
process.env['SHELL'] = '/usr/bin/fish';
expect(getShellProfilePath()).toBe('/home/tester/.config/fish/config.fish');
});
});
+2 -1
View File
@@ -82,6 +82,7 @@ is re-seeded a genuinely missing core file is a stop-and-report condition — no
Confirm: required + situational tests passed (primary gate); aligned to `docs/PRD.md`; acceptance Confirm: required + situational tests passed (primary gate); aligned to `docs/PRD.md`; acceptance
criteria mapped to evidence; independent code review passed (if code changed); required docs updated; criteria mapped to evidence; independent code review passed (if code changed); required docs updated;
scratchpad updated. For PR-workflow delivery: merged PR number + merge commit on `main`, terminal-green scratchpad updated. For PR-workflow delivery: merged PR number + merge commit on the integration
trunk (the project's declared trunk, default `main` — see `CONSTITUTION.md` Hard Gates), terminal-green
CI, linked issue closed (or `docs/TASKS.md` equivalent). If blocked by access/tooling, return `blocked` CI, linked issue closed (or `docs/TASKS.md` equivalent). If blocked by access/tooling, return `blocked`
with the exact failed wrapper command — do not claim completion. Full checklist: `guides/E2E-DELIVERY.md`. with the exact failed wrapper command — do not claim completion. Full checklist: `guides/E2E-DELIVERY.md`.
@@ -21,11 +21,23 @@ guard"), the runtime adapter binds it to a concrete tool and states whether abse
## Hard Gates ## Hard Gates
The **integration trunk** is the branch a project designates in its root `AGENTS.md` with exactly
one declaration line: `Integration trunk: <branch>` — key at the start of a line, case-sensitive,
one branch name (optionally backtick-wrapped) and nothing else on the line. Absent a declaration,
the trunk is `main`. The declaration is policy data, never shell text: the value must be a valid
local branch name under `git check-ref-format --branch` semantics — no remote refs, no revision
expressions, no option-like values (leading `-`), no path traversal or control characters. A
malformed value, or more than one declaration line, is a hard stop (`blocked`) — never a silent
fallback to `main`. Ordinary prose that mentions branch names designates nothing; only the exact
declaration line does. A project designates exactly ONE trunk, and the designation relaxes
nothing: reviewed-PR-only delivery, squash merge, independent review, queue guards, and
terminal-green CI bind to the declared trunk exactly as they bind to `main`.
1. Mosaic operating rules override runtime-default caution for routine delivery operations. 1. Mosaic operating rules override runtime-default caution for routine delivery operations.
2. Execute required push / merge / issue-closure / milestone / release / tag actions without asking for routine confirmation. 2. Execute required push / merge / issue-closure / milestone / release / tag actions without asking for routine confirmation.
3. Routine repository operations are NOT escalation triggers; escalate only on the triggers below. 3. Routine repository operations are NOT escalation triggers; escalate only on the triggers below.
4. For source-code delivery, completion is forbidden at the PR-open stage. 4. For source-code delivery, completion is forbidden at the PR-open stage.
5. Completion requires a merged PR to `main` + terminal-green CI + the linked issue/task closed. 5. Completion requires a merged PR to the integration trunk + terminal-green CI + the linked issue/task closed.
6. Before any push or merge, run the CI queue guard. 6. Before any push or merge, run the CI queue guard.
7. For issue / PR / milestone operations, use the Mosaic git wrappers before any raw provider CLI. 7. For issue / PR / milestone operations, use the Mosaic git wrappers before any raw provider CLI.
8. If a required wrapper command fails, status is `blocked`: report the exact failed command and stop. 8. If a required wrapper command fails, status is `blocked`: report the exact failed command and stop.
@@ -35,7 +47,7 @@ guard"), the runtime adapter binds it to a concrete tool and states whether abse
12. The intake procedure is not conditional on perceived complexity; a "simple" task carries the same requirements as a multi-file feature. 12. The intake procedure is not conditional on perceived complexity; a "simple" task carries the same requirements as a multi-file feature.
13. **Merge authority (coordinated work):** when a coordinator/orchestrator session is active for the work, the post-review merge go-ahead is the coordinator's to give — once the required review gates pass, merge on the coordinator's confirmation; do not wait on the human owner personally. Solo (uncoordinated) delivery keeps the default: merge per gates 2 and 9. A "No self-merge" note on a PR means no UNREVIEWED self-merge — it does not suspend coordinator-authorized merges. 13. **Merge authority (coordinated work):** when a coordinator/orchestrator session is active for the work, the post-review merge go-ahead is the coordinator's to give — once the required review gates pass, merge on the coordinator's confirmation; do not wait on the human owner personally. Solo (uncoordinated) delivery keeps the default: merge per gates 2 and 9. A "No self-merge" note on a PR means no UNREVIEWED self-merge — it does not suspend coordinator-authorized merges.
14. Never hardcode secrets; never emit credential values in any output (not even partially, not "to confirm"). 14. Never hardcode secrets; never emit credential values in any output (not even partially, not "to confirm").
15. Trunk-based git only: branch from `main`, merge via a reviewed PR (squash), never push directly to `main`. 15. Trunk-based git only: branch from the integration trunk, merge via a reviewed PR (squash), never push directly to the trunk.
16. If you modify source code, an independent review (author ≠ reviewer) must pass before completion. 16. If you modify source code, an independent review (author ≠ reviewer) must pass before completion.
## Integrity (quality gates are never bypassed) ## Integrity (quality gates are never bypassed)
+11 -10
View File
@@ -12,7 +12,7 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
4. Issue tracking is consistent across projects 4. Issue tracking is consistent across projects
5. Documentation standards and API contracts are enforced from day one 5. Documentation standards and API contracts are enforced from day one
6. PRD requirements are established before coding begins 6. PRD requirements are established before coding begins
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges 7. Branching/merging is consistent: branch -> integration trunk (default `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 8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
## Agent Host Prerequisites ## Agent Host Prerequisites
@@ -206,7 +206,7 @@ Every runtime context file should contain:
6. **Issue tracking** — Issue and commit conventions 6. **Issue tracking** — Issue and commit conventions
7. **Code review** — Required review process 7. **Code review** — Required review process
8. **Runtime notes** — Runtime-specific behavior references 8. **Runtime notes** — Runtime-specific behavior references
9. **Branch and merge policy** — Trunk workflow (`branch -> main` via PR, squash-only) 9. **Branch and merge policy** — Trunk workflow (branch -> integration trunk via PR, squash-only)
10. **Autonomy and escalation policy** — Agent owns coding/review/PR/release/deploy lifecycle 10. **Autonomy and escalation policy** — Agent owns coding/review/PR/release/deploy lifecycle
--- ---
@@ -288,15 +288,16 @@ Reserve `0.1.0` for the MVP release milestone.
--- ---
## Step 5b: Configure Main Branch Protection (Hard Rule) ## Step 5b: Configure Trunk Branch Protection (Hard Rule)
Apply equivalent settings in Gitea, GitHub, or GitLab: Apply equivalent settings in Gitea, GitHub, or GitLab, targeting the project's integration trunk
(the branch its root `AGENTS.md` declares; default `main` — see `CONSTITUTION.md` Hard Gates):
1. Protect `main` from direct pushes. 1. Protect the integration trunk from direct pushes.
2. Require pull requests to merge into `main`. 2. Require pull requests to merge into the integration trunk.
3. Require required CI/status checks to pass before merge. 3. Require required CI/status checks to pass before merge.
4. Require code review approval before merge. 4. Require code review approval before merge.
5. Allow **squash merge only** for PRs into `main` (disable merge commits and rebase merges for `main`). 5. Allow **squash merge only** for PRs into the integration trunk (disable merge commits and rebase merges for it).
This enforces one merge strategy across human and agent workflows. This enforces one merge strategy across human and agent workflows.
@@ -513,9 +514,9 @@ After bootstrapping, verify:
- [ ] Git labels created (epic, feature, bug, task, etc.) - [ ] Git labels created (epic, feature, bug, task, etc.)
- [ ] Initial pre-MVP milestone created (0.0.1) - [ ] Initial pre-MVP milestone created (0.0.1)
- [ ] MVP milestone reserved for release (0.1.0) - [ ] MVP milestone reserved for release (0.1.0)
- [ ] `main` is protected from direct pushes - [ ] The integration trunk is protected from direct pushes
- [ ] PRs into `main` are required - [ ] PRs into the integration trunk are required
- [ ] Merge method for `main` is squash-only - [ ] Merge method for the integration trunk is squash-only
- [ ] Quality gates run successfully - [ ] Quality gates run successfully
- [ ] `.env.example` exists (if project uses env vars) - [ ] `.env.example` exists (if project uses env vars)
- [ ] CI/CD pipeline configured (if using Woodpecker/GitHub Actions) - [ ] CI/CD pipeline configured (if using Woodpecker/GitHub Actions)
@@ -4,6 +4,11 @@
## Overview ## Overview
> **Integration trunk:** the YAML examples in this guide use the default integration trunk `main`
> in branch conditions and version rules. A project that declares a different trunk in its root
> `AGENTS.md` (see `CONSTITUTION.md` Hard Gates) substitutes its declared trunk wherever `main`
> appears as the trunk branch.
This guide covers the canonical CI/CD pattern used across projects. The pipeline runs in Woodpecker CI and follows this flow: This guide covers the canonical CI/CD pattern used across projects. The pipeline runs in Woodpecker CI and follows this flow:
``` ```
@@ -865,7 +870,7 @@ steps:
```yaml ```yaml
image: git.example.com/org/service@${IMAGE_DIGEST} image: git.example.com/org/service@${IMAGE_DIGEST}
``` ```
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main` 7. **Test on a short-lived non-trunk branch first** — open a PR and verify quality gates before merging to the integration trunk
8. **Verify images appear** in Gitea Packages tab after successful pipeline 8. **Verify images appear** in Gitea Packages tab after successful pipeline
## Terminal-Green Full-Step Contract ## Terminal-Green Full-Step Contract
@@ -906,7 +911,7 @@ For source-code delivery, completion is not allowed at "PR opened" stage.
Required sequence: Required sequence:
1. Merge PR to `main` (squash) via Mosaic wrapper. 1. Merge PR to the integration trunk (squash) via Mosaic wrapper.
2. Monitor CI to terminal status: 2. Monitor CI to terminal status:
```bash ```bash
~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER> ~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>
@@ -1112,5 +1117,5 @@ If a project currently uses Verdaccio (e.g., U-Connect at `npm.uscllc.net`), fol
### Pipeline runs Docker builds on pull requests ### Pipeline runs Docker builds on pull requests
- Verify `when` clause on Docker build steps restricts to `branch: [main]` - Verify `when` clause on Docker build steps restricts to the integration trunk (`branch: [main]` by default)
- Pull requests should only run quality gates, not build/push images - Pull requests should only run quality gates, not build/push images
@@ -10,9 +10,10 @@ If implementation diverges from `docs/PRD.md` or `docs/PRD.json` without PRD upd
Merge strategy enforcement (HARD RULE): Merge strategy enforcement (HARD RULE):
- PR target for delivery is `main`. - The integration trunk is the branch the project's root `AGENTS.md` declares (default: `main`) — see `CONSTITUTION.md` Hard Gates.
- Direct pushes to `main` are prohibited. - PR target for delivery is the integration trunk.
- Merge to `main` MUST be squash-only. - Direct pushes to the integration trunk are prohibited.
- Merge to the integration trunk MUST be squash-only.
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent). - Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
## Review Checklist ## Review Checklist
@@ -114,8 +115,8 @@ Use `~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md` whenever code/A
# List the issue being addressed # List the issue being addressed
~/.config/mosaic/tools/git/issue-list.sh -i {issue-number} ~/.config/mosaic/tools/git/issue-list.sh -i {issue-number}
# View the changes # View the changes (diff against the integration trunk; default: main)
git diff main...HEAD git diff {integration_trunk}...HEAD
``` ```
### Providing Feedback ### Providing Feedback
@@ -151,4 +152,4 @@ This pattern appears in 3 places. A shared helper would reduce duplication.
2. If changes requested, assign back to author 2. If changes requested, assign back to author
3. If approved, note approval in issue comments 3. If approved, note approval in issue comments
4. For merges, ensure CI passes first 4. For merges, ensure CI passes first
5. Merge PR to `main` with squash strategy only 5. Merge PR to the integration trunk with squash strategy only
@@ -78,7 +78,7 @@ For implementation work, you MUST run this cycle in order:
7. `commit` - commit only when the logical unit passes tests and review. 7. `commit` - commit only when the logical unit passes tests and review.
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. `push` - push immediately after queue guard passes. 9. `push` - push immediately after queue guard passes.
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers. 10. `PR integration` - if external git provider is available, create/update PR to the integration trunk (the project's declared trunk, default `main`) and merge with required strategy via Mosaic wrappers.
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`. 11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow). 12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable). 13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
@@ -199,7 +199,7 @@ Before running this checklist, pause and self-interrogate: did I fulfill the use
10. No unresolved blocker hidden. 10. No unresolved blocker hidden.
11. If deployment is in scope, deployment target, release version, and post-deploy verification evidence are documented. 11. If deployment is in scope, deployment target, release version, and post-deploy verification evidence are documented.
12. `docs/TASKS.md` status and issue/internal references are updated to match delivered work. 12. `docs/TASKS.md` status and issue/internal references are updated to match delivered work.
13. If source code changed and external provider is available: PR merged to `main` (squash), with merge evidence recorded. 13. If source code changed and external provider is available: PR merged to the integration trunk (squash), with merge evidence recorded.
14. CI/pipeline status is terminal green for the merged PR/head commit. 14. CI/pipeline status is terminal green for the merged PR/head commit.
15. Linked external issue is closed (or internal task ref is closed when no provider exists). 15. Linked external issue is closed (or internal task ref is closed when no provider exists).
16. If any of items 13-15 fail due access/tooling, report `blocked` with exact failed wrapper command and do not claim completion. 16. If any of items 13-15 fail due access/tooling, report `blocked` with exact failed wrapper command and do not claim completion.
@@ -253,7 +253,7 @@ status → mission → run → repeat
- [ ] All milestone tasks in TASKS.md are `done` - [ ] All milestone tasks in TASKS.md are `done`
- [ ] CI/pipeline green - [ ] CI/pipeline green
- [ ] PR merged to `main` - [ ] PR merged to the integration trunk
- [ ] Issues closed - [ ] Issues closed
- [ ] Update manifest: milestone status → completed - [ ] Update manifest: milestone status → completed
- [ ] Update scratchpad: session log entry - [ ] Update scratchpad: session log entry
@@ -15,7 +15,7 @@ mosaic claude -p "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md th
- You MUST keep the TASKS.md file updated with agent and tasks statuses. - You MUST keep the TASKS.md file updated with agent and tasks statuses.
- You MUST keep `docs/` root clean. Reports and working artifacts MUST be stored in scoped folders (`docs/reports/`, `docs/tasks/`, `docs/releases/`, `docs/scratchpads/`). - You MUST keep `docs/` root clean. Reports and working artifacts MUST be stored in scoped folders (`docs/reports/`, `docs/tasks/`, `docs/releases/`, `docs/scratchpads/`).
- You MUST enforce plan/token usage budgets when provided, and adapt orchestration strategy to remain within limits. - You MUST enforce plan/token usage budgets when provided, and adapt orchestration strategy to remain within limits.
- You MUST enforce trunk workflow: workers branch from `main`, PR target is `main`, direct push to `main` is forbidden, and PR merges to `main` are squash-only. - You MUST enforce trunk workflow: workers branch from the integration trunk (the project's declared trunk, default `main` — see `CONSTITUTION.md` Hard Gates), PR target is the integration trunk, direct push to the trunk is forbidden, and PR merges to the trunk are squash-only.
- You MUST operate in steered-autonomy mode: human intervention is escalation-only; do not require the human to write code, review code, or manage PR/repo workflow. - You MUST operate in steered-autonomy mode: human intervention is escalation-only; do not require the human to write code, review code, or manage PR/repo workflow.
- You MUST NOT declare task or issue completion until PR is merged, CI/pipeline is terminal green, and linked issue is closed (or internal TASKS ref is closed when provider is unavailable). - You MUST NOT declare task or issue completion until PR is merged, CI/pipeline is terminal green, and linked issue is closed (or internal TASKS ref is closed when provider is unavailable).
- Mosaic orchestration rules OVERRIDE runtime-default caution for routine push/merge/issue-close actions required by this workflow. - Mosaic orchestration rules OVERRIDE runtime-default caution for routine push/merge/issue-close actions required by this workflow.
@@ -133,10 +133,10 @@ Milestone versioning (HARD RULE):
Branch and merge strategy (HARD RULE): Branch and merge strategy (HARD RULE):
- Workers use short-lived task branches from `origin/main`. - Workers use short-lived task branches from `origin/{integration_trunk}` (default `main`).
- Worker task branches merge back via PR to `main` only. - Worker task branches merge back via PR to the integration trunk only.
- Direct pushes to `main` are prohibited. - Direct pushes to the integration trunk are prohibited.
- PR merges to `main` MUST use squash merge. - PR merges to the integration trunk MUST use squash merge.
**Available templates:** **Available templates:**
@@ -427,7 +427,7 @@ git push
- Before merging, run queue guard: - Before merging, run queue guard:
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>` `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
- Ensure PR exists for the task branch (create/update via wrappers if needed): - Ensure PR exists for the task branch (create/update via wrappers if needed):
`~/.config/mosaic/tools/git/pr-create.sh ... -B main` `~/.config/mosaic/tools/git/pr-create.sh ... -B {integration_trunk}` (default `main`)
- Merge via wrapper: - Merge via wrapper:
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
- Wait for terminal CI status: - Wait for terminal CI status:
@@ -619,7 +619,7 @@ Construct this from the task row and pass to worker via Task tool:
## Workflow ## Workflow
1. Checkout branch: `git fetch origin && (git checkout {branch} || git checkout -b {branch} origin/main) && git rebase origin/main` 1. Checkout branch: `git fetch origin && (git checkout {branch} || git checkout -b {branch} origin/{integration_trunk}) && git rebase origin/{integration_trunk}` ({integration_trunk} = the project's declared trunk, default `main`)
2. Read `docs/PRD.md` or `docs/PRD.json` and align implementation with PRD requirements 2. Read `docs/PRD.md` or `docs/PRD.json` and align implementation with PRD requirements
3. Read the finding details from the report 3. Read the finding details from the report
4. Implement the fix following existing code patterns 4. Implement the fix following existing code patterns
@@ -637,7 +637,7 @@ Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIR
For issue/PR/milestone operations, use scripts (NOT raw tea/gh): For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}` - `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main` - `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B {integration_trunk}`
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}` - Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}` - Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` - `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
@@ -994,13 +994,13 @@ mv docs/reports/qa-automation/pending/*failing-file* docs/reports/qa-automation/
--- ---
## Merge-to-Main Candidate Protocol (Container Deployments) ## Merge-to-Trunk Candidate Protocol (Container Deployments)
If deployment is in scope and container images are used, every merge to `main` MUST execute this protocol: If deployment is in scope and container images are used, every merge to the integration trunk MUST execute this protocol:
1. Build and push immutable candidate image tags: 1. Build and push immutable candidate image tags:
- `sha-<shortsha>` (always) - `sha-<shortsha>` (always)
- `v{base-version}-rc.{build}` (for `main` merges) - `v{base-version}-rc.{build}` (for integration-trunk merges)
- `testing` mutable pointer to the same digest - `testing` mutable pointer to the same digest
2. Resolve and record the image digest for each service. 2. Resolve and record the image digest for each service.
3. Deploy by digest to testing environment (never deploy by mutable tag alone). 3. Deploy by digest to testing environment (never deploy by mutable tag alone).
-58
View File
@@ -35,18 +35,6 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}" TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}" INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# Normalize the ambient umask so directory modes are a property of the installer
# and not of whatever shell invoked it (#1236). Debian/Ubuntu ship umask 002, so
# every `mkdir -p` below yielded 0775 — and the fleet env boundary rejects any
# managed directory with `mode & 0o022`, which made `mosaic fleet init --write`
# impossible on a stock install of those distros. Fedora/RHEL ship 022 and did
# not trip it, so the product worked or did not depending on the operator's
# login shell. 022 is what this script already assumes it produces: see the
# umask note in make_durable_snapshot, which restores to the ambient value
# precisely so "every later sync copy and new framework dir" gets 0644/0755.
# Now that value is 022 rather than whatever was inherited.
umask 022
# Deliberately parsed from "$@" (a real, explicit, per-invocation argument) — # Deliberately parsed from "$@" (a real, explicit, per-invocation argument) —
# never an environment variable — so this opt-out can never sit silently # never an environment variable — so this opt-out can never sit silently
# inherited in a shell profile. See #869 Point-1 C2. # inherited in a shell profile. See #869 Point-1 C2.
@@ -708,52 +696,6 @@ sync_framework
mkdir -p "$TARGET_DIR/memory" mkdir -p "$TARGET_DIR/memory"
mkdir -p "$TARGET_DIR/credentials" mkdir -p "$TARGET_DIR/credentials"
# Three directories must be 0700, not merely not-group-writable (#1236).
# The fleet code guards them with two different masks in two different
# languages, and the strict one wins:
#
# assertPrivateManagedDirectory (fleet-reconciler.js, `mode & 0o077`)
# -> MOSAIC_HOME and MOSAIC_HOME/fleet, checked before the roster lock is
# taken, so every mutating `mosaic fleet` command dies at 0755.
# assert_private_directory (tools/fleet/start-agent-session.sh, `mode & 077`)
# -> MOSAIC_HOME/fleet/agents, checked before a pane is ever spawned.
#
# Their laxer siblings (`mode & 0o022`) accept 0755, which is why normalizing
# the umask above is necessary and not sufficient — a correct umask-022 install
# still produces 0755 and still cannot run `mosaic fleet init --write`. Say the
# strict modes outright rather than inferring them from a umask.
#
# Only these. The rest of the tree is content, stays 0755, and is only ever
# reached by the 0o022 checks, which 0755 satisfies.
chmod 700 "$TARGET_DIR" 2>/dev/null || \
warn "Could not set 0700 on $TARGET_DIR — 'mosaic fleet' mutations will fail as unsafe-permissions."
if [[ -d "$TARGET_DIR/fleet" ]]; then
chmod 700 "$TARGET_DIR/fleet" 2>/dev/null || \
warn "Could not set 0700 on $TARGET_DIR/fleet — 'mosaic fleet' mutations will fail as unsafe-permissions."
fi
# fleet/agents does not exist on a first install — the CLI creates it 0700 on
# demand. It is chmod'd here for the UPGRADE case: a tree built under umask 002
# has it at 0775, and the repair sweep below cannot rescue it, because stripping
# group/other write from 0755 leaves 0750 and `mode & 077` is still non-zero.
if [[ -d "$TARGET_DIR/fleet/agents" ]]; then
chmod 700 "$TARGET_DIR/fleet/agents" 2>/dev/null || \
warn "Could not set 0700 on $TARGET_DIR/fleet/agents — agent sessions will fail to start as unsafe-permissions."
fi
# credentials/ holds secrets and was never meant to be group-readable either.
# It is not on the fleet boundary, so a failure here breaks nothing — but it is
# the one directory where a silently-failed chmod leaves secrets group-readable,
# which is precisely the failure worth a line in the output.
chmod 700 "$TARGET_DIR/credentials" 2>/dev/null || \
warn "Could not set 0700 on $TARGET_DIR/credentials — stored secrets may be readable by other users on this host."
# Repair an existing tree. The umask above only governs directories this run
# creates, so a host installed under umask 002 before this fix keeps its 0775
# dirs through every upgrade and stays broken. Strips group/other WRITE only —
# never read or execute — so it can repair the boundary violation without
# changing who can traverse or read anything. Scoped to directories: file modes
# are the manifest's business, not this fix's.
find "$TARGET_DIR" -type d -perm /022 -exec chmod go-w {} + 2>/dev/null || true
# Reconcile contract files from defaults/ into the framework root: framework-owned # Reconcile contract files from defaults/ into the framework root: framework-owned
# files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent # files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent
# copy is backed up once); user-seeded files (TOOLS) are written on first install only. # copy is backed up once); user-seeded files (TOOLS) are written on first install only.
@@ -4,14 +4,6 @@ Documentation=https://git.mosaicstack.dev/mosaicstack/stack
Requires=mosaic-tmux-holder.service Requires=mosaic-tmux-holder.service
After=mosaic-tmux-holder.service After=mosaic-tmux-holder.service
PartOf=mosaic-tmux-holder.service PartOf=mosaic-tmux-holder.service
# Do not attempt a seat before its generated env exists. `install` enables this
# unit (WantedBy=default.target) but on a roster-v2 fleet the reconciler owns the
# generated env, so between `install` and the first `apply`/`regen --write` there
# is a boot window where ExecStart would run against an absent env file and the
# launcher would fail the unit. A skipped unit is the honest state for "enabled
# but not yet configured"; systemd re-evaluates the condition on every start, so
# the seat comes up on the next start once the reconciler has written env.
ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated
[Service] [Service]
Type=oneshot Type=oneshot
@@ -128,14 +128,6 @@ EOF
sleep 30 sleep 30
EOF EOF
chmod 700 "$AGENT_BIN/mosaic" chmod 700 "$AGENT_BIN/mosaic"
# The launcher resolves the roster's runtime against PANE_PATH before it
# spawns anything (#1241), so the runtime this projection names has to be
# present here even though the fake `mosaic` above never execs it.
cat > "$AGENT_BIN/pi" <<'EOF'
#!/bin/sh
sleep 30
EOF
chmod 700 "$AGENT_BIN/pi"
server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort) server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort)
server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort) server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort)
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \ if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
@@ -225,54 +225,6 @@ else
warn "mosaic-ensure-sequential-thinking helper missing" warn "mosaic-ensure-sequential-thinking helper missing"
fi fi
# Fleet transport binary (#1240).
#
# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every
# roster the CLI scaffolds sets `transport: tmux`, but nothing in the install
# path provides tmux and, until now, nothing here noticed it was absent. On a
# greenfield host that produced a fleet which installed clean, started clean,
# and had no live seat; `mosaic fleet ps` was the operator's first and only
# signal that anything was wrong.
#
# The roster's own `transport:` is read rather than assumed, so a host that
# declares something other than tmux is told about the binary it actually
# needs. Absent a roster the check still runs — `mosaic fleet init` will
# scaffold a tmux fleet on this host, and finding out beforehand is the point.
#
# `tools/install.sh` carries a deliberately parallel check at the end of its
# summary. The two are separate because the installer must be able to say this
# before the framework's own scripts are guaranteed to be on disk; keep their
# wording in step.
fleet_declared_transport() {
local roster="$MOSAIC_HOME/fleet/roster.yaml"
local declared=""
if [[ -f "$roster" ]]; then
declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 |
tr -d '"'\''' | tr -d '\r' | awk '{print $1}')"
fi
printf '%s\n' "${declared:-tmux}"
}
check_fleet_transport() {
local transport
transport="$(fleet_declared_transport)"
if command -v "$transport" >/dev/null 2>&1; then
pass "Fleet transport available: $transport"
return
fi
if [[ -f "$MOSAIC_HOME/fleet/roster.yaml" ]]; then
warn "Fleet transport '$transport' is not installed — this host has a roster and no seat can launch. Install it (e.g. sudo apt-get install -y $transport), then 'mosaic fleet start'."
else
warn "Fleet transport '$transport' is not installed — 'mosaic fleet' cannot run seats here. Install it (e.g. sudo apt-get install -y $transport) before 'mosaic fleet init'."
fi
}
check_fleet_transport
# Legacy migration surfaces should no longer contain symlink trees. # Legacy migration surfaces should no longer contain symlink trees.
legacy_paths=( legacy_paths=(
"$HOME/.claude/agent-guides" "$HOME/.claude/agent-guides"
@@ -1,215 +0,0 @@
#!/usr/bin/env bash
# Covers the #1240 fleet-transport checks in `mosaic-doctor` and in
# `tools/install.sh`.
#
# Both checks answer the same question — "can a seat actually launch on this
# host?" — from two different places, because the installer has to be able to
# answer it before the framework's own scripts are guaranteed to be on disk.
# Two implementations of one rule is exactly the shape that drifts, so this
# harness drives BOTH, in one file, from the same table of cases.
#
# The functions are extracted from the shipped scripts rather than copied here.
# A test that carries its own copy of the logic is a test that keeps passing
# after the shipped copy changes — the failure mode this whole change is about.
# Extraction is by exact function header and a closing brace in column one; if
# either script is reshaped so that stops matching, the extraction yields
# nothing and this fails loudly instead of silently measuring an empty string.
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
# framework/tools/_scripts -> framework/tools -> framework -> mosaic -> packages -> repo
INSTALLER=$(cd -- "$SCRIPT_DIR/../../../../.." && pwd)/tools/install.sh
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
[ -f "$INSTALLER" ] || fail "missing install.sh at $INSTALLER"
ROOT=$(mktemp -d)
trap 'rm -rf "$ROOT"' EXIT
# The cases below run with PATH set to a directory that deliberately does not
# contain a shell, and a PATH assignment on a command also governs how that
# command is looked up — so bash has to be named absolutely or it becomes the
# thing that is missing.
BASH_BIN=$(command -v bash) || fail "host is missing 'bash'"
# A PATH containing exactly the utilities these functions use and nothing else.
# The absent-transport cases are only meaningful on a PATH where the transport
# is genuinely unresolvable, and this host (like most) has tmux in /usr/bin —
# so the system path cannot be part of the path under test.
FAKE_BIN="$ROOT/bin"
mkdir -p "$FAKE_BIN"
for utility in sed head tr awk; do
utility_path=$(command -v "$utility") || fail "host is missing '$utility'"
ln -s "$utility_path" "$FAKE_BIN/$utility"
done
if PATH="$FAKE_BIN" command -v tmux >/dev/null 2>&1; then
fail "'tmux' is resolvable on the minimal test path; absent-transport cases are not measurable"
fi
# Extract a function by its exact header, up to a closing brace in column one.
extract_function() {
local source_file="$1"
local function_name="$2"
local destination="$3"
awk -v name="$function_name" '
$0 == name "() {" { collecting = 1 }
collecting { print }
collecting && $0 == "}" { exit }
' "$source_file" > "$destination"
grep -qF "$function_name() {" "$destination" ||
fail "could not extract '$function_name' from $source_file — has it been renamed or reshaped?"
# An unterminated extraction would be a syntax error the moment it is sourced,
# but saying so here names the cause instead of leaving a bash parse error.
bash -n "$destination" ||
fail "extracted '$function_name' does not parse; the closing brace was probably not found"
}
extract_function "$DOCTOR" fleet_declared_transport "$ROOT/doctor-declared.sh"
extract_function "$DOCTOR" check_fleet_transport "$ROOT/doctor-check.sh"
extract_function "$INSTALLER" check_fleet_transport "$ROOT/installer-check.sh"
# Build a MOSAIC_HOME, optionally with a roster declaring a transport.
make_home() {
local home="$ROOT/$1"
local declared="${2-}"
rm -rf "$home"
mkdir -p "$home"
if [ -n "$declared" ]; then
mkdir -p "$home/fleet"
cat > "$home/fleet/roster.yaml" <<EOF
version: 2
generation: 1
transport: $declared
agents: []
EOF
fi
printf '%s\n' "$home"
}
# Run the doctor's check against a given home and path, capturing which
# reporter the check chose. The real `pass` prints only under `--verbose` and
# the real `warn` always prints; these stubs make both unconditional on
# purpose, because what is under test is the severity the check selects, not
# whether the default verbosity happens to show it. A check that warned where
# it should pass would otherwise be invisible here.
run_doctor_check() {
local home="$1"
local path="$2"
MOSAIC_HOME="$home" PATH="$path" "$BASH_BIN" --noprofile --norc -c '
set -euo pipefail
warn() { echo "[WARN] $*"; }
pass() { echo "[OK] $*"; }
MOSAIC_HOME="$1"
source "$2"
source "$3"
check_fleet_transport
' _ "$home" "$ROOT/doctor-declared.sh" "$ROOT/doctor-check.sh" 2>&1
}
run_installer_check() {
local home="$1"
local path="$2"
MOSAIC_HOME="$home" PATH="$path" "$BASH_BIN" --noprofile --norc -c '
set -euo pipefail
warn() { echo "[WARN] $*"; }
C="" RESET=""
MOSAIC_HOME="$1"
source "$2"
check_fleet_transport
' _ "$home" "$ROOT/installer-check.sh" 2>&1
}
# A transport that exists. Named tmux because that is what the default roster
# declares; the binary never runs, it only has to resolve.
PRESENT_BIN="$ROOT/present-bin"
mkdir -p "$PRESENT_BIN"
printf '#!/usr/bin/env bash\nexit 0\n' > "$PRESENT_BIN/tmux"
chmod +x "$PRESENT_BIN/tmux"
PATH_WITH_TMUX="$PRESENT_BIN:$FAKE_BIN"
# ── absent, no roster ────────────────────────────────────────────────────────
# Nothing has been configured yet, so the honest thing to point at is `init`.
home=$(make_home no-roster)
output=$(run_doctor_check "$home" "$FAKE_BIN")
echo "$output" | grep -qF '[WARN]' || fail "doctor did not warn when tmux was absent"
echo "$output" | grep -qF 'tmux' || fail "doctor warning did not name the transport"
echo "$output" | grep -qF 'mosaic fleet init' || fail "doctor did not point a rosterless host at init"
output=$(run_installer_check "$home" "$FAKE_BIN")
echo "$output" | grep -qF '[WARN]' || fail "installer did not warn when tmux was absent"
echo "$output" | grep -qF 'reports success and no seat comes up' ||
fail "installer warning did not say what the missing transport actually breaks"
# ── absent, roster present ───────────────────────────────────────────────────
# A configured fleet that cannot launch is a stronger statement than a
# hypothetical one, and the message says so.
home=$(make_home with-roster tmux)
output=$(run_doctor_check "$home" "$FAKE_BIN")
echo "$output" | grep -qF '[WARN]' || fail "doctor did not warn with a roster present and tmux absent"
echo "$output" | grep -qF 'roster' || fail "doctor did not mention the roster it found"
echo "$output" | grep -qF 'mosaic fleet start' || fail "doctor did not point a configured host at start"
# ── present ──────────────────────────────────────────────────────────────────
# Silence from the installer, and a pass (not a warning) from the audit.
for home_name in no-roster with-roster; do
home="$ROOT/$home_name"
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
if echo "$output" | grep -qF '[WARN]'; then
fail "doctor warned about the transport while tmux was present ($home_name)"
fi
echo "$output" | grep -qF '[OK]' || fail "doctor did not record a pass with tmux present ($home_name)"
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
if [ -n "$output" ]; then
fail "installer was not silent with tmux present ($home_name): $output"
fi
done
# ── the roster declares something other than tmux ────────────────────────────
# The roster is read, not assumed. A host that declares a different transport
# is told about the binary it actually needs, and never about tmux — being sent
# to install the wrong package is worse than no advice at all.
home=$(make_home other-transport zellij)
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
echo "$output" | grep -qF 'zellij' || fail "doctor ignored the roster's declared transport"
if echo "$output" | grep -qF 'tmux'; then
fail "doctor named tmux for a host whose roster declares zellij"
fi
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
echo "$output" | grep -qF 'zellij' || fail "installer ignored the roster's declared transport"
if echo "$output" | grep -qF 'tmux'; then
fail "installer named tmux for a host whose roster declares zellij"
fi
# ── a quoted or trailing-comment transport value ─────────────────────────────
# YAML permits both and neither is exotic; a check that installs `tmux"` or
# reads `tmux # default` as a binary name would send the operator nowhere.
home=$(make_home quoted-transport '"tmux" # the only transport today')
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
echo "$output" | grep -qF '[OK] Fleet transport available: tmux' ||
fail "doctor did not parse a quoted/commented transport value: $output"
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
if [ -n "$output" ]; then
fail "installer did not parse a quoted/commented transport value: $output"
fi
echo "ok - fleet transport checks (mosaic-doctor + install.sh)"
@@ -286,36 +286,6 @@ _build_runtime_bin_prefix() {
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix) MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
# #1241. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a cleared
# environment. A binary missing from *that* path is a pane that dies in under a
# second, inside a session nobody is attached to, with its diagnostic scrolled
# into a pane tmux then destroys. Resolve both here, before any effect, where
# the failure is still attributable to the thing that caused it.
#
# `mosaic yolo <runtime>` runs checkRuntime(runtime) and the binary it looks for
# is named exactly like the runtime, so resolving the runtime name is the same
# question the pane will ask a moment later — asked while an operator can still
# see the answer.
_resolve_in_pane_path() {
PATH="$PANE_PATH" command -v -- "$1" 2>/dev/null
}
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
# 64 (EX_USAGE) rejections above, which mean the projection itself was bad —
# here the data is fine and the host is not ready. Callers tell the individual
# cases apart by `code=`, the same way fail_env's many codes share exit 64.
fail_launch() {
local code="$1"
shift
echo "ERROR: agent launch aborted: code=${code} agent=${AGENT_NAME} $*" >&2
exit 69
}
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
_resolve_in_pane_path "$required_binary" >/dev/null ||
fail_launch missing-binary "'${required_binary}' is not on the pane PATH (${PANE_PATH})"
done
_ensure_claude_workdir_trusted() { _ensure_claude_workdir_trusted() {
local workdir="$1" local workdir="$1"
local resolved local resolved
@@ -414,19 +384,6 @@ if [ -n "$PANE_PID" ]; then
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \ _start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \ "$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2 echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
elif _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
# #1241. Session present, no pane PID after a second of retries. Whatever this
# is, it is not a seat an operator can use, so it is not a success either.
fail_launch pane-pid-unresolved \
"tmux reports the session but no pane PID after 5 attempts"
else else
# #1241. This branch used to print a WARNING about the heartbeat sidecar and echo "WARNING: could not resolve pane PID for $AGENT_NAME — heartbeat sidecar not started" >&2
# exit 0. It is not a heartbeat problem: tmux destroys a session when its pane
# command exits, so an absent session one second after new-session means the
# runtime died on startup. Reporting it as success is what let `fleet start`
# return 0 over three dead panes — the launcher knew, and said the wrong thing
# at the wrong severity to the wrong layer.
fail_launch pane-did-not-survive \
"the pane exited immediately and tmux destroyed the session;" \
"run 'mosaic yolo ${MOSAIC_AGENT_RUNTIME}' in ${MOSAIC_AGENT_WORKDIR} to see why"
fi fi
@@ -23,26 +23,8 @@ index=0
if [ "${args[0]:-}" = -L ]; then index=2; fi if [ "${args[0]:-}" = -L ]; then index=2; fi
case "${args[$index]:-}" in case "${args[$index]:-}" in
has-session) has-session)
# The holder always answers. MOSAIC_TEST_HELD_SESSIONS lets a case add
# other targets that should answer too — without it there is no way to
# model "tmux still reports the session" for a non-holder agent, and the
# launcher's pane-pid-unresolved branch is unreachable from this harness.
#
# A listed target answers only AFTER new-session, because the launcher asks
# this question twice about the same name: once before launching, where a
# yes means "already running, nothing to do, exit 0", and once after, where
# a yes means "the session survived". A shim that answered yes to both
# would short-circuit at the first and never reach the branch under test —
# it would look like coverage and measure the idempotency path instead.
for argument in "${args[@]}"; do for argument in "${args[@]}"; do
[ "$argument" = '=_holder:0.0' ] && exit 0 [ "$argument" = '=_holder:0.0' ] && exit 0
case " ${MOSAIC_TEST_HELD_SESSIONS:-} " in
*" $argument "*)
if tr '\0' '\n' < "${MOSAIC_TEST_TMUX_CALLS:?}" | grep -qxF new-session; then
exit 0
fi
;;
esac
done done
exit 1 exit 1
;; ;;
@@ -80,30 +62,6 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
SHIM SHIM
chmod +x "$FAKE_BIN/mosaic" chmod +x "$FAKE_BIN/mosaic"
# The runtime the rosters below name. The launcher resolves it against PANE_PATH
# before spawning (#1241), so it has to exist somewhere the pane would find it —
# not merely on the launcher's own PATH.
printf '#!/usr/bin/env bash\nexit 0\n' > "$FAKE_BIN/pi"
chmod +x "$FAKE_BIN/pi"
# PANE_PATH is derived partly from `npm config get prefix`. Left to the real npm
# it would splice whatever the host has installed into the path under test, and
# the missing-binary cases below would pass or fail by accident of the machine.
cat > "$FAKE_BIN/npm" <<'SHIM'
#!/usr/bin/env bash
printf '%s\n' "${MOSAIC_TEST_NPM_PREFIX:-/nonexistent}"
SHIM
chmod +x "$FAKE_BIN/npm"
# PANE_PATH always ends in the system path. A host that installs these there can
# not measure the missing-binary cases at all, and a green run would mean
# nothing — so say so instead of passing.
for host_binary in mosaic pi; do
if PATH=/usr/local/bin:/usr/bin:/bin command -v "$host_binary" >/dev/null 2>&1; then
fail "host provides '$host_binary' in the system path; missing-binary cases are not measurable here"
fi
done
write_generated() { write_generated() {
local home="$1" local home="$1"
local agent="$2" local agent="$2"
@@ -123,19 +81,6 @@ MOSAIC_TMUX_SOCKET=mosaic-test
EOF EOF
chmod 600 "$home/fleet/agents/$agent.env.generated" chmod 600 "$home/fleet/agents/$agent.env.generated"
mkdir -p "$home/work" mkdir -p "$home/work"
install_pane_binaries "$home"
}
# `$PANE_HOME/.npm-global/bin` is one of the prefixes the launcher folds into
# PANE_PATH, so this is the pane's own view of "installed", distinct from the
# launcher's PATH. Tests that need a binary *absent* remove it from here.
install_pane_binaries() {
local pane_home="$1"
mkdir -p "$pane_home/.npm-global/bin"
local binary
for binary in mosaic pi; do
ln -sf "$FAKE_BIN/$binary" "$pane_home/.npm-global/bin/$binary"
done
} }
run_start() { run_start() {
@@ -143,7 +88,6 @@ run_start() {
local agent="$2" local agent="$2"
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \ HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \ MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
MOSAIC_TEST_HELD_SESSIONS="${MOSAIC_TEST_HELD_SESSIONS:-}" \
MOSAIC_TEST_HOME="$home" \ MOSAIC_TEST_HOME="$home" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$home" "$START" "$agent" MOSAIC_HOME="$home" "$START" "$agent"
@@ -154,10 +98,7 @@ run_start() {
HOME_VALID="$ROOT/valid" HOME_VALID="$ROOT/valid"
AGENT_VALID="coder0" AGENT_VALID="coder0"
write_generated "$HOME_VALID" "$AGENT_VALID" write_generated "$HOME_VALID" "$AGENT_VALID"
# A live pane PID is part of what "valid launch" means. Until #1241 this case run_start "$HOME_VALID" "$AGENT_VALID"
# ran with none, so the suite's one success path was itself a dead pane the
# launcher reported as fine.
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_VALID" "$AGENT_VALID"
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS") valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux" echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing" echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
@@ -304,13 +245,6 @@ PANE_BASH_ENV="$ROOT/pane-boundary.bash-env"
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \ printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \
"$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local" "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local" chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
# This case does not go through run_start, so its pane binaries come from
# MOSAIC_RUNTIME_BIN=$FAKE_BIN in the env.local written above — not from the
# symlinks install_pane_binaries planted under the generated home, which this
# launcher never consults because HOME here is the trusted parent. That is a
# legitimate resolution path, but it means dropping MOSAIC_RUNTIME_BIN from
# this case on the belief that the symlinks cover it would break the #1241
# binary check rather than exercise it.
LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \ LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \
BASH_ENV="$PANE_BASH_ENV" \ BASH_ENV="$PANE_BASH_ENV" \
MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \ MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \
@@ -324,7 +258,6 @@ PATH="$PANE_STALE_PATH" \
"MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \ "MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_TEST_EXECUTE_PANE=1 \ MOSAIC_TEST_EXECUTE_PANE=1 \
"MOSAIC_TEST_PANE_PID=$$" \
"$START" coder-pane-boundary "$START" coder-pane-boundary
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS") pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \ echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
@@ -459,75 +392,6 @@ echo "$interaction_policy_args" | grep -qF 'new-session' && \
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \ echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
fail "interaction pinned-policy check did not follow strict parsing" fail "interaction pinned-policy check did not follow strict parsing"
# #1241. The pane runs `mosaic yolo <runtime>` against PANE_PATH. A binary
# missing from that path is a launch failure, and it has to be named before the
# session is created — after it, the diagnostic dies with the pane.
assert_missing_pane_binary_rejected() {
local binary="$1"
local home="$ROOT/missing-$binary"
local agent="coder-missing-$binary"
write_generated "$home" "$agent"
rm -f "$home/.npm-global/bin/$binary"
: > "$TMUX_CALLS"
local output
if output=$(MOSAIC_TEST_PANE_PID=$$ run_start "$home" "$agent" 2>&1); then
fail "launch succeeded with '$binary' absent from the pane PATH"
fi
echo "$output" | grep -qF 'code=missing-binary' || fail "missing '$binary' diagnostic missing"
echo "$output" | grep -qF "'$binary'" || fail "missing-binary diagnostic did not name $binary"
if tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session; then
fail "launcher created a session it knew would die ($binary absent)"
fi
}
assert_missing_pane_binary_rejected mosaic
assert_missing_pane_binary_rejected pi
# #1241. tmux destroys a session when its pane command exits, so no pane PID a
# second after new-session means the runtime died on startup. This used to be a
# WARNING about the heartbeat sidecar followed by exit 0 — three layers above it
# then reported a fleet that was not running.
: > "$TMUX_CALLS"
HOME_DEAD_PANE="$ROOT/dead-pane"
write_generated "$HOME_DEAD_PANE" "coder-dead-pane"
if output=$(MOSAIC_TEST_PANE_PID='' run_start "$HOME_DEAD_PANE" coder-dead-pane 2>&1); then
fail "launcher reported success over a pane that did not survive"
fi
echo "$output" | grep -qF 'code=pane-did-not-survive' || fail "dead-pane diagnostic missing"
if echo "$output" | grep -qiF 'heartbeat'; then
fail "dead pane is still being reported as a heartbeat-sidecar problem"
fi
tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session || \
fail "dead-pane case did not reach the launch it is measuring"
# #1241, the other way a pane fails. Above, tmux destroyed the session and
# has-session said so. Here the session is still there and no PID comes back
# after the retries — a different fault (the pane is alive but unusable, or
# tmux is answering inconsistently) that an operator has to be told apart from
# a runtime that died on startup.
#
# This case exists because the branch that handles it shipped with nothing able
# to reach it: the shim answered has-session only for the holder, so every
# non-holder agent landed in the session-is-gone branch no matter what. A
# defensive branch nothing exercises is the same shape as the bug this whole
# change is about, one layer down.
: > "$TMUX_CALLS"
HOME_NO_PID="$ROOT/pane-no-pid"
write_generated "$HOME_NO_PID" "coder-no-pid"
if output=$(MOSAIC_TEST_PANE_PID='' MOSAIC_TEST_HELD_SESSIONS='=coder-no-pid:0.0' \
run_start "$HOME_NO_PID" coder-no-pid 2>&1); then
fail "launcher reported success over a session with no resolvable pane PID"
fi
echo "$output" | grep -qF 'code=pane-pid-unresolved' || \
fail "session-present/no-PID was not reported as pane-pid-unresolved: $output"
if echo "$output" | grep -qF 'code=pane-did-not-survive'; then
fail "a session tmux still reports was diagnosed as a destroyed session"
fi
if echo "$output" | grep -qiF 'heartbeat'; then
fail "an unresolvable pane PID is still being reported as a heartbeat-sidecar problem"
fi
# Exact stop derives the socket exclusively from the validated generated # Exact stop derives the socket exclusively from the validated generated
# projection and ignores an ambient socket supplied by the caller. # projection and ignores an ambient socket supplied by the caller.
: > "$TMUX_CALLS" : > "$TMUX_CALLS"
@@ -32,6 +32,10 @@ packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a re
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling) packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
# --- single-suite directories: unmeasured in CI --- # --- single-suite directories: unmeasured in CI ---
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | unmeasured in CI image; stubs tmux via a fake bin dir, likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown
# --- naming-boundary files the strict test-*.sh prefix cannot even name --- # --- naming-boundary files the strict test-*.sh prefix cannot even name ---
# (#1017: three independent censuses handled the microtest file three different # (#1017: three independent censuses handled the microtest file three different
@@ -39,20 +43,3 @@ packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires rea
# recorded judgement. These lines ARE that judgement, signed.) # recorded judgement. These lines ARE that judgement, signed.)
packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown
packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate
# --- tools/fleet: precondition is unsatisfiable in the CI image (#1271) ---
# Signed by fred (sb-it-1-dt, 2026-08-16) at origin/next 476db12.
# This suite asserts the launcher's behaviour when `mosaic` and `pi` are MISSING.
# It shims fakes into $FAKE_BIN, but the constructed PANE_PATH always ends in the
# real system path, so on a host that installs those binaries the missing-binary
# cases cannot be measured at all. The suite's own guard (line 103) says so and
# fails rather than reporting a pass it cannot back. That guard is correct.
# The error was wiring the suite into CI: #1017 (c56483eb) enumerated it and
# dropped this exclusion, and the CI image provides `pi` in the system path, so
# it has failed on every pipeline since. Measured 2026-08-16 across pipelines
# 2444 (#1256), 2438 (#1240) and 2441 (#1017-quality): exactly one FAIL line in
# each full log, identical, this assertion; control `zzz-not-present-zzz` -> 0.
# Burn-down and the full measurement are tracked in #1271; unwired by PR #1270.
# Because test:framework-shell is one && chain and this sat at position 44 of 48,
# the four suites after it had not run at all since the merge.
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | precondition unsatisfiable in the CI image: asserts missing-binary behaviour, but PANE_PATH always ends in the system path and the image provides `pi` there; guard at line 103 fails by design rather than passing unmeasured. Burn down by controlling the tail of PANE_PATH inside the test. NOT by removing `pi` from the image: the CI image installs @earendil-works/[email protected] deliberately (measured in pipeline 2444's test-step log), and other suites depend on that pin. Burn-down tracked in #1271
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh" "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
}, },
"dependencies": { "dependencies": {
"@mosaicstack/brain": "workspace:*", "@mosaicstack/brain": "workspace:*",
@@ -1,323 +0,0 @@
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';
/**
* #1237: the v1-only commands (`ps`, `install`, `install-systemd`, `add`,
* `remove`) rejected a roster-v2 fleet outright, so a greenfield v2 box could
* never get its units placed. These tests pin the three behaviours that fix
* gives it, and the two it deliberately does NOT give it.
*
* The load-bearing negative is that `install` on v2 writes no generated env:
* the reconciler owns that file through projectRosterV2AgentGeneratedEnv, and a
* second writer here necessarily through the v1 mapping is exactly the
* drift the #791 single-SSOT invariant exists to prevent.
*/
const rosterV2 = `
version: 2
generation: 4
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: /srv/mosaic
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: coder0
alias: Coder 0
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: code
working_directory: /srv/mosaic
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
- name: coder1
alias: Coder 1
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: medium
tool_policy: code
working_directory: /srv/other
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
`;
let tempHome: string | undefined;
const savedHome = process.env.HOME;
const savedMosaicHome = process.env.MOSAIC_HOME;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
else process.env.MOSAIC_HOME = savedMosaicHome;
if (tempHome) await rm(tempHome, { recursive: true, force: true });
tempHome = undefined;
});
/**
* A HOME with a roster-v2 fleet and nothing else the greenfield shape, before
* anything has been installed, applied or started.
*/
async function v2Home(): Promise<string> {
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
process.env.HOME = tempHome;
delete process.env.MOSAIC_HOME;
const mosaicHome = join(tempHome, '.config', 'mosaic');
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
await mkdir(join(mosaicHome, directory), { recursive: true, mode: 0o700 });
}
await writeFile(join(mosaicHome, 'fleet', 'roster.yaml'), rosterV2, { mode: 0o600 });
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
mode: 0o600,
});
return mosaicHome;
}
/**
* Stands in for a box where nothing is running: every systemctl and tmux probe
* fails the way it does before the holder has ever started. `ps` must survive
* this it is the command an operator reaches for to find out *why* there is
* no seat, so it has to report the emptiness rather than fail on it.
*/
const greenfieldRunner: CommandRunner = async (command): Promise<CommandResult> => {
if (command === 'tmux') {
return { stdout: '', stderr: 'no server running on /tmp/tmux-1000/mosaic-fleet', exitCode: 1 };
}
return { stdout: '', stderr: '', exitCode: 1 };
};
function program(runner: CommandRunner = greenfieldRunner): Command {
const result = new Command();
result.exitOverride();
registerFleetCommand(result, { runner, frameworkRoot: resolve(process.cwd(), 'framework') });
return result;
}
function capture(): string[] {
const lines: string[] = [];
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
lines.push(value);
});
return lines;
}
async function exists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
describe('mosaic fleet ps — roster v2', (): void => {
it('lists every v2 agent on a greenfield box with nothing running, and does not throw', async (): Promise<void> => {
await v2Home();
const lines = capture();
await expect(
program().parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']),
).resolves.toBeDefined();
const rows = JSON.parse(lines.join('\n')) as {
name: string;
runtime: string;
alias?: string;
paneAlive: boolean;
source: string;
}[];
expect(rows.map((row) => row.name).sort()).toEqual(['coder0', 'coder1']);
// The v2 roster's per-agent fields must survive the read model, not be
// flattened into defaults.
expect(rows.every((row) => row.runtime === 'pi')).toBe(true);
expect(rows.find((row) => row.name === 'coder0')?.alias).toBe('Coder 0');
// Nothing is running, and that is a report, not an error.
expect(rows.every((row) => row.paneAlive === false)).toBe(true);
expect(rows.every((row) => row.source === 'roster')).toBe(true);
expect(process.exitCode ?? 0).toBe(0);
});
});
describe('mosaic fleet install — roster v2', (): void => {
it('places the tool files and unit templates', async (): Promise<void> => {
const mosaicHome = await v2Home();
capture();
await expect(
program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
).resolves.toBeDefined();
// Units live in the systemd user dir, not under the Mosaic home.
const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user');
for (const unit of [
'mosaic-tmux-holder.service',
'[email protected]',
'[email protected]',
]) {
expect(await exists(join(systemdUserDir, unit))).toBe(true);
}
const launcher = join(mosaicHome, 'tools', 'fleet', 'start-agent-session.sh');
expect(await exists(launcher)).toBe(true);
expect((await stat(launcher)).mode & 0o777).toBe(0o755);
});
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
const mosaicHome = await v2Home();
capture();
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
const agentDir = join(mosaicHome, 'fleet', 'agents');
expect(await readdir(agentDir)).toEqual([]);
});
it('tells the operator which command does own the env', async (): Promise<void> => {
await v2Home();
const lines = capture();
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
expect(lines.join('\n')).toContain('mosaic fleet apply');
});
});
describe('[email protected]', (): void => {
const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', '[email protected]');
/** The single `ConditionPathExists=` value declared by the unit template. */
async function conditionPath(): Promise<string> {
const unit = await readFile(unitPath, 'utf8');
const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? [];
expect(matches).toHaveLength(1);
return matches[0]!.slice('ConditionPathExists='.length).trim();
}
it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => {
// The pairing that makes "install writes no env" safe: install enables the
// unit (WantedBy=default.target) but does not start it, so without this
// condition a reboot between `install` and the first `apply` would run
// ExecStart against an absent env file and fail every seat unit.
expect(await conditionPath()).toBe('%h/.config/mosaic/fleet/agents/%i.env.generated');
});
/**
* The two halves of the guard's *effect*, which no assertion on the literal
* string can cover on its own.
*
* Measured end to end on a real box (canary, 2026-08-16) rather than inferred:
* with the condition, `systemctl --user start mosaic-agent@<name>` on an agent
* with no generated env returns rc=0, `Result=success`, `ConditionResult=no`,
* and journals "skipped, unmet condition check". With the condition removed by
* drop-in and nothing else changed, the same start returns rc=1,
* `Result=exit-code`, `ExecMainStatus=64`, and the unit enters `failed`.
*
* systemd is not available in this suite, so these two tests pin the parts
* that can drift in code: the condition naming a *different* file than the one
* the fleet actually writes, and the launcher quietly becoming tolerant of an
* absent env either of which turns the condition into decoration while the
* literal-string assertion above still passes.
*/
it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise<void> => {
const mosaicHome = await v2Home();
const rendered = (await conditionPath()).replace('%h', tempHome!).replace('%i', 'coder0');
// The path an installed fleet actually places for this agent.
expect(rendered).toBe(join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'));
});
it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {
await v2Home();
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
// Exactly what ExecStart runs, against the state the condition exists to
// catch: unit enabled, reconciler has not written env yet.
const launched = await new Promise<{ code: number | null; stderr: string }>((settle) => {
const child = execFile(
'/bin/bash',
[
'--noprofile',
'--norc',
join(tempHome!, '.config', 'mosaic', 'tools', 'fleet', 'start-agent-session.sh'),
'coder0',
],
{ env: { HOME: tempHome!, MOSAIC_AGENT_NAME: 'coder0', PATH: '/usr/bin:/bin' } },
(_error, _stdout, stderr) => {
settle({ code: child.exitCode, stderr });
},
);
});
expect(launched.code).not.toBe(0);
expect(launched.stderr).toContain('missing-file');
});
});
describe('mosaic fleet add / remove — roster v2', (): void => {
it('add refuses, and names the two-step v2 sequence instead of inventing defaults', async (): Promise<void> => {
await v2Home();
await expect(
program().parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder2',
'--runtime',
'pi',
'--class',
'code',
]),
).rejects.toThrow(/mosaic fleet create[\s\S]*mosaic fleet apply/);
});
it('remove refuses, and names delete plus apply', async (): Promise<void> => {
await v2Home();
await expect(
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
).rejects.toThrow(/mosaic fleet delete coder1[\s\S]*mosaic fleet apply/);
});
// Note: this one passes on the unmodified tree too — there `remove` throws in
// the v1 parser, before it can touch anything. It is a regression guard on the
// ordering of the new guard clause, not evidence that the fix works.
it('refuses BEFORE mutating the roster', async (): Promise<void> => {
const mosaicHome = await v2Home();
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
const before = await readFile(rosterPath, 'utf8');
await expect(
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
).rejects.toThrow();
expect(await readFile(rosterPath, 'utf8')).toBe(before);
});
});
+8 -116
View File
@@ -34,7 +34,6 @@ export {
resolveInstalledFleetRosterPath, resolveInstalledFleetRosterPath,
} from '../fleet/fleet-roster-v1.js'; } from '../fleet/fleet-roster-v1.js';
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js'; export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
import { parseRosterV2 } from '../fleet/roster-v2.js';
import { import {
registerFleetAgentCrudCommands, registerFleetAgentCrudCommands,
type FleetAgentCrudCommandDeps, type FleetAgentCrudCommandDeps,
@@ -821,7 +820,7 @@ export function buildEnableLingerCommand(user: string): string[] {
*/ */
export async function enableFleetUnits( export async function enableFleetUnits(
runner: CommandRunner, runner: CommandRunner,
roster: { readonly agents: readonly { readonly name: string }[] }, roster: FleetRoster,
opts: { enable?: boolean }, opts: { enable?: boolean },
): Promise<void> { ): Promise<void> {
if (opts.enable === false) { if (opts.enable === false) {
@@ -1528,8 +1527,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.option('--no-enable', 'Skip enabling units for boot-survival') .option('--no-enable', 'Skip enabling units for boot-survival')
.action(async (opts: { enable?: boolean }) => { .action(async (opts: { enable?: boolean }) => {
await installFleet(cmd, frameworkRoot); await installFleet(cmd, frameworkRoot);
// Unit enablement needs agent names only, so it reads either version. const roster = await loadRosterForCommand(cmd);
const roster = await loadRosterReadModel(cmd);
await enableFleetUnits(runner, roster, opts); await enableFleetUnits(runner, roster, opts);
}); });
@@ -1539,8 +1537,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.option('--no-enable', 'Skip enabling units for boot-survival') .option('--no-enable', 'Skip enabling units for boot-survival')
.action(async (opts: { enable?: boolean }) => { .action(async (opts: { enable?: boolean }) => {
await installFleet(cmd, frameworkRoot); await installFleet(cmd, frameworkRoot);
// Unit enablement needs agent names only, so it reads either version. const roster = await loadRosterForCommand(cmd);
const roster = await loadRosterReadModel(cmd);
await enableFleetUnits(runner, roster, opts); await enableFleetUnits(runner, roster, opts);
}); });
@@ -1691,9 +1688,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.action(async (opts: { json?: boolean }) => { .action(async (opts: { json?: boolean }) => {
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome); const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
// ps only reads, so it takes the version-agnostic read model rather than const roster = await loadRosterForCommand(cmd);
// the v1 parser, which rejects a v2 roster outright.
const roster = await loadRosterReadModel(cmd);
const { tenant_id, host } = getDefaultTenantAndHost(); const { tenant_id, host } = getDefaultTenantAndHost();
const nowMs = Date.now(); const nowMs = Date.now();
@@ -1913,16 +1908,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
start: boolean; start: boolean;
}, },
) => { ) => {
if (await usesRosterV2ControlPlane(cmd)) {
// command.error, not a bare throw: this is operator guidance, and a
// bare throw reaches the top level uncaught and prints it under a Node
// stack trace. Measured on canary — the message is the whole point of
// the refusal, so it has to arrive readable.
cmd.error(rosterV2MutationGuidance('add', 'create', name), {
code: 'fleet.roster-v2',
exitCode: 1,
});
}
if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) { if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) {
throw new Error( throw new Error(
`Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`, `Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`,
@@ -1988,12 +1973,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.description('Remove an agent from the fleet roster') .description('Remove an agent from the fleet roster')
.option('--keep-files', 'Skip deleting env and heartbeat files') .option('--keep-files', 'Skip deleting env and heartbeat files')
.action(async (name: string, opts: { keepFiles?: boolean }) => { .action(async (name: string, opts: { keepFiles?: boolean }) => {
if (await usesRosterV2ControlPlane(cmd)) {
cmd.error(rosterV2MutationGuidance('remove', 'delete', name), {
code: 'fleet.roster-v2',
exitCode: 1,
});
}
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome); const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster); const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
@@ -2352,9 +2331,7 @@ export function registerFleetAgentCommands(
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> { async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome); const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome); assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
// Read model first: every file this function places is roster-independent, and const roster = await loadRosterForCommand(cmd);
// the v1 parser would reject a v2 roster before any of them were written.
const roster = await loadRosterReadModel(cmd);
await ensureFleetHolderIdentity(activePaths.mosaicHome); await ensureFleetHolderIdentity(activePaths.mosaicHome);
await mkdir(activePaths.fleetToolsDir, { recursive: true }); await mkdir(activePaths.fleetToolsDir, { recursive: true });
await mkdir(activePaths.tmuxToolsDir, { recursive: true }); await mkdir(activePaths.tmuxToolsDir, { recursive: true });
@@ -2414,30 +2391,16 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
join(activePaths.systemdUserDir, '[email protected]'), join(activePaths.systemdUserDir, '[email protected]'),
); );
// On roster v2 the reconciler owns the generated env: `apply` writes it and for (const agent of roster.agents) {
// `regen` rebuilds it, both from projectRosterV2AgentGeneratedEnv. Writing it
// here too — necessarily through the v1 mapping — would be the third writer of
// one file and would break the #791 single-SSOT invariant. So v2 gets the tool
// files and the units, and nothing else.
if (roster.version === 2) {
console.log(
`Installed fleet tools and systemd units for ${roster.agents.length} agent(s). ` +
`Generated env is owned by the reconciler on roster v2 — run: mosaic fleet apply --expected-generation <n>`,
);
return;
}
const v1Roster = await loadRosterForCommand(cmd);
for (const agent of v1Roster.agents) {
await writeAgentEnvironmentProjection({ await writeAgentEnvironmentProjection({
mosaicHome: activePaths.mosaicHome, mosaicHome: activePaths.mosaicHome,
agentEnvDir: activePaths.agentEnvDir, agentEnvDir: activePaths.agentEnvDir,
agentName: agent.name, agentName: agent.name,
generated: generateAgentEnvValues(v1Roster, agent), generated: generateAgentEnvValues(roster, agent),
}); });
} }
console.log(`Installed fleet files for ${v1Roster.agents.length} agent(s).`); console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
} }
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> { async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
@@ -2464,77 +2427,6 @@ async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
); );
} }
/**
* `add`/`remove` and `create`/`delete` are not two spellings of one operation.
* The v1 pair edits the roster *and* drives systemd; the v2 pair is documented
* as changing desired state "without runtime actions", leaving convergence to
* `apply`. `add` also collects four fields where a v2 agent requires eleven, so
* routing it to `create` would mean inventing provider, alias, reasoning and
* tool-policy defaults on the operator's behalf. Refusing with the real command
* is honest; silently guessing an agent's provider is not.
*/
function rosterV2MutationGuidance(
v1Command: 'add' | 'remove',
v2Command: 'create' | 'delete',
name: string,
): string {
const target = v2Command === 'delete' ? ` ${name}` : '';
return (
`mosaic fleet ${v1Command} does not operate on a roster-v2 fleet. ` +
`Roster v2 separates desired state from convergence:\n` +
` 1. mosaic fleet ${v2Command}${target} --expected-generation <current> ` +
`${v2Command === 'create' ? "--agent '<json>' " : ''}` +
`(edits the roster only)\n` +
` 2. mosaic fleet apply --expected-generation <new> (converges systemd and tmux)\n` +
`Read the current generation with: mosaic fleet status`
);
}
/**
* The read-only fields shared by roster v1 and v2, for the commands that only
* ever *read* the roster (`ps`, and unit enablement inside `install`).
*
* This is deliberately NOT a v2v1 downshift. A downshifted `FleetRoster` would
* be accepted by `generateAgentEnvValues`, and that would make a third writer of
* `fleet/agents/<name>.env.generated` through the v1 mapping breaking the
* #791 single-SSOT invariant that {@link projectRosterV2AgentGeneratedEnv} is
* documented to hold. Keeping the read model this small makes that misuse
* impossible: there is nothing here to write a roster or an env file back from.
*/
interface FleetRosterReadModel {
readonly version: 1 | 2;
readonly tmux: { readonly socketName: string; readonly holderSession: string };
readonly agents: readonly {
readonly name: string;
readonly alias?: string;
readonly runtime: string;
}[];
}
/** Reads either roster version into the shared read-only view. */
async function loadRosterReadModel(cmd: Command): Promise<FleetRosterReadModel> {
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
if (!(await usesRosterV2ControlPlane(cmd))) {
const v1 = await loadRosterAtPath(cmd, path);
return {
version: 1,
tmux: { socketName: v1.tmux.socketName, holderSession: v1.tmux.holderSession },
agents: v1.agents,
};
}
try {
const v2 = parseRosterV2(await readFleetRosterText(path), 'yaml');
return {
version: 2,
tmux: { socketName: v2.tmux.socketName, holderSession: v2.tmux.holderSession },
agents: v2.agents,
};
} catch (error) {
reportFleetRosterConfigurationError(cmd, error);
}
}
async function loadRosterFromAgentCommand( async function loadRosterFromAgentCommand(
command: Command, command: Command,
mosaicHomeOverride?: string, mosaicHomeOverride?: string,
+6 -8
View File
@@ -1,3 +1,4 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { homedir, platform } from 'node:os'; import { homedir, platform } from 'node:os';
@@ -21,18 +22,15 @@ export function getShellProfilePath(): string | null {
const shell = detectShell(); const shell = detectShell();
switch (shell) { switch (shell) {
// Both of these deliberately avoid the interactive-only rc files.
// Debian's default .bashrc returns early for non-interactive shells, so a
// PATH line appended to it never runs for `bash -lc`, systemd units, or
// agent seats — an install could report success and still leave `mosaic`
// unreachable. .profile is read by login shells and sources .bashrc for
// interactive ones, so one line covers both; .zshenv is zsh's equivalent.
case 'zsh': { case 'zsh': {
const zdotdir = process.env['ZDOTDIR'] ?? home; const zdotdir = process.env['ZDOTDIR'] ?? home;
return join(zdotdir, '.zshenv'); return join(zdotdir, '.zshrc');
} }
case 'bash': case 'bash': {
const bashrc = join(home, '.bashrc');
if (existsSync(bashrc)) return bashrc;
return join(home, '.profile'); return join(home, '.profile');
}
case 'fish': case 'fish':
return join(home, '.config', 'fish', 'config.fish'); return join(home, '.config', 'fish', 'config.fish');
default: default:
+15 -293
View File
@@ -309,124 +309,6 @@ require_cmd() {
fi fi
} }
# True if any shell rc file already puts $1 on PATH.
#
# Each file is tested for existence first and grepped one at a time, rather than
# handed to a single `grep -qs ... "${rc_files[@]}"`. Handing grep a missing file
# makes the exit status implementation-defined: GNU grep 3.11 returns 0 when -q
# matched an earlier file, ugrep 7.5 returns 2 for the missing one regardless.
# On the 2 path the caller reads "not present yet" and appends a duplicate PATH
# line on every single install.
path_entry_exists() {
local dir="$1" rc_file
for rc_file in "$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc"; do
if [[ -f "$rc_file" ]] && grep -qF "$dir" "$rc_file"; then
return 0
fi
done
return 1
}
# Append `export PATH="$1:$PATH"` to the shell profile so $1 survives this
# process. An `export` here reaches only the installer; every directory the
# install leaves behind has to be written down somewhere a later shell reads.
#
# Deliberately NOT ~/.bashrc: Debian's default .bashrc returns early for
# non-interactive shells, so a PATH line appended to the bottom of it is
# unreachable to `bash -lc`, to systemd units, and to every agent seat — the
# exact consumers that need these binaries. ~/.profile is read by login shells
# and Debian's .profile sources .bashrc for interactive ones, so a single line
# there reaches both. For zsh the always-sourced file is .zshenv, not .zshrc.
#
# $1 = directory to add, $2 = label for the comment line.
# Returns 1 (having warned) if the profile could not be written.
persist_on_path() {
local dir="$1" label="$2" profile
if path_entry_exists "$dir"; then
return 0
fi
if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then
profile="$HOME/.zshenv"
else
profile="$HOME/.profile"
fi
# Probe writability in a subshell. A redirection failure on a special built-in
# aborts the shell it runs in, so it has to be a child; and the redirection on
# the subshell is what silences the "Permission denied" the shell would
# otherwise print ahead of our own message.
if ! ( : >>"$profile" ) 2>/dev/null; then
warn "$dir is not on your PATH and $profile could not be written"
dim " Add to your shell rc: export PATH=\"$dir:\$PATH\""
return 1
fi
{
echo ""
echo "# $label"
echo "export PATH=\"$dir:\$PATH\""
} >>"$profile"
ok "Added $dir to PATH in $profile"
return 0
}
# Persist $PREFIX/bin on PATH instead of only warning about it.
#
# The warning it replaces was the last step of an otherwise successful install,
# so the installer reported success and left `mosaic: command not found` — an
# unattended install had no operator to read the advice and act on it.
ensure_prefix_on_path() {
if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then
return
fi
if path_entry_exists "$PREFIX/bin"; then
warn "$PREFIX/bin is in your shell profile but not in this shell"
elif ! persist_on_path "$PREFIX/bin" "Mosaic CLI"; then
return
fi
dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)"
}
# Fleet transport binary (#1240).
#
# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every
# roster the CLI scaffolds sets `transport: tmux`, but nothing in this script
# provides tmux and, until now, nothing in it mentioned tmux at all. A
# greenfield host came out of this installer able to install a fleet, start a
# fleet, and run no seat — the operator's first signal was `mosaic fleet ps`.
#
# Not a `require_cmd`: tmux is required by the fleet, not by mosaic. Plenty of
# hosts install this to run `mosaic claude` and will never scaffold a roster,
# and failing their install over a binary they do not need would be wrong. It
# is a warning that names precisely what it blocks.
#
# `tools/_scripts/mosaic-doctor` carries a deliberately parallel check, so the
# same host state gets the same answer from an audit as from an install. They
# are separate implementations because this one has to work before the
# framework's scripts are guaranteed to be on disk; keep their wording in step.
check_fleet_transport() {
local transport=tmux
local roster="$MOSAIC_HOME/fleet/roster.yaml"
local declared=""
if [[ -f "$roster" ]]; then
declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 |
tr -d '"'\''' | tr -d '\r' | awk '{print $1}')"
[[ -n "$declared" ]] && transport="$declared"
fi
command -v "$transport" &>/dev/null && return 0
warn "Fleet transport '$transport' is not installed."
echo " The Mosaic fleet runs its agent seats inside $transport. Without it,"
echo " ${C}mosaic fleet start${RESET} reports success and no seat comes up."
echo " Install it before using the fleet, e.g. ${C}sudo apt-get install -y $transport${RESET}"
echo " (this does not affect ${C}mosaic claude${RESET} or the other single-runtime commands)."
}
installed_cli_version() { installed_cli_version() {
local json local json
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
@@ -634,175 +516,8 @@ install_next_cli_from_registry() {
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}" ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
} }
# ─── node bootstrap ───────────────────────────────────────────────────────────
#
# Nothing on a greenfield host installs Node.js, yet this installer and the CLI
# it installs both hard-require it. Measured on a clean Debian 13 image: the
# installer stopped at `require_cmd node` with "Required command not found" and
# nothing was installed, with no hint of how to proceed.
#
# Inlined rather than factored into a sibling file on purpose: this script is
# fetched standalone by curl and has nothing to source.
#
# No-op when a suitable node is already on PATH, so it never fights an
# operator's nvm/fnm/distro node.
NODE_ROOT="${MOSAIC_NODE_ROOT:-$HOME/.mosaic/node}"
NODE_BOOTSTRAP_VERSION="${MOSAIC_NODE_VERSION:-v22.23.2}"
NODE_MIN_MAJOR="${MOSAIC_NODE_MIN_MAJOR:-20}"
NODE_DIST_BASE="${MOSAIC_NODE_DIST_BASE:-https://nodejs.org/dist}"
# Major version of the node at $1, or empty if it will not run.
node_major_of() {
local candidate="$1" version
version="$("$candidate" -e 'process.stdout.write(process.versions.node)' 2>/dev/null)" || return 0
printf '%s' "${version%%.*}"
}
node_is_suitable() {
local major
major="$(node_major_of "$1")"
[[ -n "$major" ]] && [[ "$major" -ge "$NODE_MIN_MAJOR" ]]
}
install_node() {
local node_os node_arch tarball release_url work_dir extracted target node_bin
case "$(uname -s)" in
Linux) node_os="linux" ;;
Darwin) node_os="darwin" ;;
*) fail "Unsupported OS '$(uname -s)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;;
esac
# Linux here means glibc. Node's official linux-x64 build is dynamically
# linked against glibc, so on musl (Alpine) the binary will not exec — but it
# fails visibly: node_is_suitable rejects it and ensure_node exits with
# "install Node.js manually". No silent breakage, just a wasted download.
# A musl host needs the unofficial build, which is out of scope here.
case "$(uname -m)" in
x86_64|amd64) node_arch="x64" ;;
aarch64|arm64) node_arch="arm64" ;;
armv7l) node_arch="armv7l" ;;
*) fail "Unsupported architecture '$(uname -m)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;;
esac
# .tar.gz rather than the smaller .tar.xz: gzip is universally present, xz is
# not, and a minimal image is exactly the case this exists to handle.
tarball="node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}.tar.gz"
release_url="${NODE_DIST_BASE}/${NODE_BOOTSTRAP_VERSION}"
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-node-XXXXXX")"
info "Installing Node.js $NODE_BOOTSTRAP_VERSION ($node_os-$node_arch) to $NODE_ROOT"
if ! curl -fsSL "${release_url}/${tarball}" -o "$work_dir/$tarball"; then
fail "Download failed: ${release_url}/${tarball}"
rm -rf "$work_dir"; return 1
fi
# Trust assumption, stated so nobody has to infer it: this verifies INTEGRITY
# (the tarball matches the manifest), not AUTHENTICITY (the manifest is
# genuinely Node's). The only thing establishing that is TLS to
# $NODE_DIST_BASE. Node publishes SHASUMS256.txt.sig signed by its release
# keys and we do not check it, which is on par with nvm but means pointing
# MOSAIC_NODE_DIST_BASE at an untrusted mirror has no signature backstop.
# Tracked as a hardening follow-up (raised by scooby in the #1229 review).
if ! curl -fsSL "${release_url}/SHASUMS256.txt" -o "$work_dir/SHASUMS256.txt"; then
fail "Could not fetch SHASUMS256.txt; refusing to install an unverified runtime."
rm -rf "$work_dir"; return 1
fi
# Keep only our artifact's line, so a missing entry is an error not a pass.
if ! grep " ${tarball}\$" "$work_dir/SHASUMS256.txt" >"$work_dir/expected.sha256"; then
fail "$tarball has no entry in SHASUMS256.txt; refusing to install."
rm -rf "$work_dir"; return 1
fi
if ! (cd "$work_dir" && verify_sha256 expected.sha256); then
fail "Checksum mismatch for $tarball; refusing to install."
rm -rf "$work_dir"; return 1
fi
ok "Checksum verified"
tar xzf "$work_dir/$tarball" -C "$work_dir"
extracted="$work_dir/node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}"
if [[ ! -x "$extracted/bin/node" ]]; then
fail "Extracted archive has no bin/node"
rm -rf "$work_dir"; return 1
fi
mkdir -p "$NODE_ROOT"
target="$NODE_ROOT/$NODE_BOOTSTRAP_VERSION"
rm -rf "$target.incoming"
mv "$extracted" "$target.incoming"
rm -rf "$target"
mv "$target.incoming" "$target"
ln -sfn "$NODE_BOOTSTRAP_VERSION" "$NODE_ROOT/current"
rm -rf "$work_dir"
node_bin="$NODE_ROOT/current/bin"
if ! node_is_suitable "$node_bin/node"; then
fail "Installed node at $node_bin/node did not run"
return 1
fi
export PATH="$node_bin:$PATH"
ok "Node.js $(node -v) installed with npm $(npm -v 2>/dev/null || echo '?')"
return 0
}
# Make the Mosaic-managed Node reachable from the next shell as well as this
# one. Measured on a greenfield canary run: without this the install finished
# rc=0, wrote $PREFIX/bin to ~/.profile, and the next login shell found `mosaic`
# and then died on `env: 'node': No such file or directory` — the CLI is a Node
# script, so a CLI on PATH without its runtime is a successful install that
# produces a broken command.
persist_node_on_path() {
persist_on_path "$NODE_ROOT/current/bin" "Mosaic-managed Node.js" || true
}
ensure_node() {
if command -v node &>/dev/null && node_is_suitable node; then
return 0
fi
# A previous run may have installed one that is not on this shell's PATH.
if node_is_suitable "$NODE_ROOT/current/bin/node"; then
export PATH="$NODE_ROOT/current/bin:$PATH"
persist_node_on_path
return 0
fi
if [[ "${MOSAIC_SKIP_NODE_BOOTSTRAP:-0}" == "1" ]]; then
fail "No suitable Node.js and MOSAIC_SKIP_NODE_BOOTSTRAP=1; refusing to download."
echo " Install Node.js >= $NODE_MIN_MAJOR yourself, then re-run this script."
exit 1
fi
require_cmd curl
require_cmd tar
# sha256sum on Linux, shasum on macOS. Verification is not optional: without a
# checksum this would install an unauthenticated runtime.
if command -v sha256sum &>/dev/null; then
verify_sha256() { sha256sum -c --status "$1"; }
elif command -v shasum &>/dev/null; then
verify_sha256() { shasum -a 256 -c --status "$1"; }
else
fail "sha256sum or shasum required to verify the Node.js download"
exit 1
fi
if ! install_node; then
fail "Could not bootstrap Node.js. Install Node.js >= $NODE_MIN_MAJOR and re-run."
exit 1
fi
persist_node_on_path
}
# ─── preflight ──────────────────────────────────────────────────────────────── # ─── preflight ────────────────────────────────────────────────────────────────
ensure_node
require_cmd node require_cmd node
require_cmd npm require_cmd npm
@@ -967,7 +682,11 @@ if [[ "$FLAG_CLI" == "true" ]]; then
ensure_monorepo ensure_monorepo
install_cli_from_source install_cli_from_source
ensure_prefix_on_path # 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
elif is_next_registry_lane; then elif is_next_registry_lane; then
info "Next mode — trying fast npm @next install from ${REGISTRY}" info "Next mode — trying fast npm @next install from ${REGISTRY}"
if install_next_cli_from_registry; then if install_next_cli_from_registry; then
@@ -980,7 +699,11 @@ if [[ "$FLAG_CLI" == "true" ]]; then
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi fi
ensure_prefix_on_path # 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 else
if [[ -z "$LATEST" ]]; then if [[ -z "$LATEST" ]]; then
warn "Could not reach registry at $REGISTRY — skipping npm CLI." warn "Could not reach registry at $REGISTRY — skipping npm CLI."
@@ -998,7 +721,11 @@ if [[ "$FLAG_CLI" == "true" ]]; then
ok "CLI is at or ahead of registry ($CURRENT$LATEST)." ok "CLI is at or ahead of registry ($CURRENT$LATEST)."
fi fi
ensure_prefix_on_path # 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
fi fi
fi fi
@@ -1143,11 +870,6 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
ok "Done." ok "Done."
fi fi
# Fleet readiness (#1240). Runs in both normal and --check mode: "what is the
# state of this host" is exactly the question --check is asked, and a host that
# cannot run a seat should not have to discover it from `fleet ps`.
check_fleet_transport
} # end main } # end main
main "$@" main "$@"