fix(runtime): align startup brief and test status paths

---
Build: pass | Tests: pass — Tests  1951 passed (1951)
This commit is contained in:
Operator & Codex 2026-04-26 12:48:47 +02:00
parent fe14fadc1c
commit 1389e17ec4
12 changed files with 243 additions and 93 deletions

View file

@ -7,15 +7,16 @@
# scripts/write-test-build-status.sh build # run build only
# scripts/write-test-build-status.sh tests # run tests only
#
# Status dir resolves to $CLAWDIE_VAR_DIR or $HOME/.clawdie/status,
# matching getDefaultStatusDir() in src/reports/test-report.ts.
# Status dir resolves to $AGENT_STATUS_DIR, then the legacy $CLAWDIE_VAR_DIR,
# and otherwise defaults to the repo-local tmp/status directory, matching
# getDefaultStatusDir() in src/reports/test-report.ts.
set -u
REPO_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$REPO_ROOT"
STATUS_DIR=${CLAWDIE_VAR_DIR:-"${HOME:-/var/db/clawdie}/.clawdie/status"}
STATUS_DIR=${AGENT_STATUS_DIR:-${CLAWDIE_VAR_DIR:-"$REPO_ROOT/tmp/status"}}
mkdir -p "$STATUS_DIR"
TMP_DIR="$REPO_ROOT/tmp/test-build-status"
mkdir -p "$TMP_DIR"

View file

@ -53,12 +53,12 @@ describe('config identity', () => {
expect(config.AGENT_INTERNAL_DOMAIN).toBe('mevy-agent.home.arpa');
});
it('defaults tenant identity to clawdie when TENANT_ID is unset', async () => {
it('falls back to the configured runtime tenant when TENANT_ID is unset', async () => {
process.env.ASSISTANT_NAME = 'Bob';
const config = await import('./config.js');
expect(config.TENANT_ID).toBe('clawdie');
expect(config.TENANT_ID).toBe('mevy');
expect(config.TENANT_DISPLAY_NAME).toBe('Bob');
expect(config.ASSISTANT_NAME).toBe('Bob');
expect(config.AGENT_DOMAIN).toBe('home.arpa');

View file

@ -72,7 +72,7 @@ describe('getDefaultAgents', () => {
});
it('DEFAULT_AGENTS uses TENANT_ID as orchestrator id', () => {
expect(DEFAULT_AGENTS[0].id).toBe('clawdie');
expect(DEFAULT_AGENTS[0].id).toBe('mevy');
});
});

View file

@ -139,40 +139,72 @@ describe('dashboard-view', () => {
}
});
it('summarizes tenant-site rows for dashboard hero cards', () => {
const summary = buildDashboardSummary(
buildDashboardTenantSiteRows(makeRegistry(), '/nonexistent-webroot'),
it('summarizes tenant-site rows for dashboard hero cards', async () => {
const statusRoot = path.join(
process.cwd(),
'tmp',
'dashboard-view-empty-status-root',
);
fs.rmSync(statusRoot, { recursive: true, force: true });
fs.mkdirSync(statusRoot, { recursive: true });
process.env.TENANT_SITE_PUBLISH_STATUS_ROOT = statusRoot;
try {
vi.resetModules();
const { buildDashboardSummary: buildSummary, buildDashboardTenantSiteRows: buildRows } =
await import('./dashboard-view.js');
const summary = buildSummary(
buildRows(makeRegistry(), '/nonexistent-webroot'),
);
expect(summary).toEqual({
tenantCount: 1,
siteCount: 1,
availableSiteCount: 0,
plannedSiteCount: 1,
strapiSnapshotSiteCount: 1,
generatedSiteCount: 0,
});
});
it('builds tenant rollups for operator cards', () => {
const rows = buildDashboardTenantRows(
buildDashboardTenantSiteRows(makeRegistry(), '/nonexistent-webroot'),
);
expect(rows).toEqual([
{
tenantId: 'mevy',
tenantDisplayName: 'Mevy',
homeHost: 'mevy.home.arpa',
expect(summary).toEqual({
tenantCount: 1,
siteCount: 1,
availableSiteCount: 0,
plannedSiteCount: 1,
strapiSnapshotSiteCount: 1,
generatedSiteCount: 0,
internalSiteCount: 1,
publicSiteCount: 0,
disabledSiteCount: 0,
},
]);
});
} finally {
delete process.env.TENANT_SITE_PUBLISH_STATUS_ROOT;
fs.rmSync(statusRoot, { recursive: true, force: true });
}
});
it('builds tenant rollups for operator cards', async () => {
const statusRoot = path.join(
process.cwd(),
'tmp',
'dashboard-view-empty-status-root-2',
);
fs.rmSync(statusRoot, { recursive: true, force: true });
fs.mkdirSync(statusRoot, { recursive: true });
process.env.TENANT_SITE_PUBLISH_STATUS_ROOT = statusRoot;
try {
vi.resetModules();
const { buildDashboardTenantRows: buildRowsByTenant, buildDashboardTenantSiteRows: buildRows } =
await import('./dashboard-view.js');
const rows = buildRowsByTenant(
buildRows(makeRegistry(), '/nonexistent-webroot'),
);
expect(rows).toEqual([
{
tenantId: 'mevy',
tenantDisplayName: 'Mevy',
homeHost: 'mevy.home.arpa',
siteCount: 1,
availableSiteCount: 0,
plannedSiteCount: 1,
strapiSnapshotSiteCount: 1,
generatedSiteCount: 0,
internalSiteCount: 1,
publicSiteCount: 0,
disabledSiteCount: 0,
},
]);
} finally {
delete process.env.TENANT_SITE_PUBLISH_STATUS_ROOT;
fs.rmSync(statusRoot, { recursive: true, force: true });
}
});
});

View file

@ -9,14 +9,13 @@ vi.mock('./authorized-hostd.js', () => ({
describe('platform-audit-report', () => {
it('collects observed service, jail, and dataset ownership', async () => {
// Service probes run in alphabetical order over the deduped union of
// shared.services + tenant services: clawdie, clawdie_hostd, cms,
// code-service, mevy, postgresql, web-service. Then jails, then datasets.
// shared.services + tenant services: cms, code-service, mevy,
// mevy_hostd, postgresql, web-service. Then jails, then datasets.
callAuthorizedHostdMock
.mockResolvedValueOnce({ ok: false, error: 'clawdie is not running' })
.mockResolvedValueOnce({ ok: false, error: 'clawdie_hostd is not running' })
.mockResolvedValueOnce({ ok: false, error: 'cms is not running' })
.mockResolvedValueOnce({ ok: false, error: 'code-service is not running' })
.mockResolvedValueOnce({ ok: false, error: 'mevy is not running' })
.mockResolvedValueOnce({ ok: false, error: 'mevy_hostd is not running' })
.mockResolvedValueOnce({ ok: true, output: 'postgresql is running as pid 1' })
.mockResolvedValueOnce({ ok: false, error: 'web-service is not running' })
.mockResolvedValueOnce({
@ -34,7 +33,7 @@ describe('platform-audit-report', () => {
caller: 'operator',
});
expect(report.observedServices).toHaveLength(7);
expect(report.observedServices).toHaveLength(6);
expect(report.observedJails).toEqual({
shared: ['git'],
tenants: { mevy: ['mevy_ctrl_worker'] },

View file

@ -18,14 +18,14 @@ describe('platform-audit', () => {
'shared-platform',
);
expect(classifyServiceOwner('mevy', registry)).toBe('tenant:mevy');
expect(classifyServiceOwner('clawdie_hostd', registry)).toBe(
expect(classifyServiceOwner('mevy_hostd', registry)).toBe(
'shared-platform',
);
expect(classifyServiceOwner('unknown-service', registry)).toBe('unknown');
});
it('shared check wins when a service is both shared and declared by a tenant', () => {
expect(classifyServiceOwner('clawdie', registry)).toBe('shared-platform');
it('classifies shared services before unknown fallthrough', () => {
expect(classifyServiceOwner('cms', registry)).toBe('shared-platform');
});
it('classifies shared and tenant-owned jails', () => {
@ -52,7 +52,7 @@ describe('platform-audit', () => {
it('summarizes ownership for a tenant', () => {
const summary = summarizeOwnership(
['postgresql', 'mevy', 'clawdie', 'mystery'],
['postgresql', 'mevy', 'mevy_hostd', 'mystery'],
classifyServiceOwner,
registry,
'mevy',
@ -73,10 +73,10 @@ describe('platform-audit', () => {
});
it('normalizes service-name comparisons (case + separator differences)', () => {
expect(classifyServiceOwner('Clawdie_HostD', registry)).toBe(
expect(classifyServiceOwner('Mevy_HostD', registry)).toBe(
'shared-platform',
);
expect(classifyServiceOwner('clawdie-hostd', registry)).toBe(
expect(classifyServiceOwner('mevy-hostd', registry)).toBe(
'shared-platform',
);
expect(classifyServiceOwner('MEVY', registry)).toBe('tenant:mevy');
@ -105,7 +105,7 @@ describe('platform-audit', () => {
it('buckets observed resources by owner', () => {
const buckets = bucketOwnedResources(
['git', 'mevy_ctrl_worker', 'clawdie_hostd', 'mystery'],
['git', 'mevy_ctrl_worker', 'mevy_hostd', 'mystery'],
(item, currentRegistry) => {
if (item.endsWith('_hostd'))
return classifyServiceOwner(item, currentRegistry);
@ -115,7 +115,7 @@ describe('platform-audit', () => {
);
expect(buckets).toEqual({
shared: ['clawdie_hostd', 'git'],
shared: ['git', 'mevy_hostd'],
tenants: {
mevy: ['mevy_ctrl_worker'],
},

View file

@ -5,6 +5,9 @@ import { afterEach, describe, expect, it } from 'vitest';
import {
buildTestReport,
getDefaultBuildStatusPath,
getDefaultStatusDir,
getDefaultTestStatusPath,
loadTestBuildSource,
parseTestBuildStatus,
renderTestReport,
@ -63,6 +66,30 @@ describe('parseTestBuildStatus', () => {
});
});
describe('default status paths', () => {
it('defaults to repo-local tmp/status when no override is set', () => {
const previousAgent = process.env.AGENT_STATUS_DIR;
const previousLegacy = process.env.CLAWDIE_VAR_DIR;
delete process.env.AGENT_STATUS_DIR;
delete process.env.CLAWDIE_VAR_DIR;
try {
expect(getDefaultStatusDir()).toBe(path.resolve(process.cwd(), 'tmp', 'status'));
expect(getDefaultBuildStatusPath()).toBe(
path.resolve(process.cwd(), 'tmp', 'status', 'build-status.json'),
);
expect(getDefaultTestStatusPath()).toBe(
path.resolve(process.cwd(), 'tmp', 'status', 'test-status.json'),
);
} finally {
if (previousAgent === undefined) delete process.env.AGENT_STATUS_DIR;
else process.env.AGENT_STATUS_DIR = previousAgent;
if (previousLegacy === undefined) delete process.env.CLAWDIE_VAR_DIR;
else process.env.CLAWDIE_VAR_DIR = previousLegacy;
}
});
});
describe('loadTestBuildSource', () => {
let tmpDir: string | null = null;

View file

@ -1,6 +1,7 @@
import { promises as fs } from 'fs';
import path from 'path';
import { PROJECT_ROOT } from '../config.js';
import { formatDisplayDate } from '../display-date.js';
export type TestBuildOutcome = 'ok' | 'fail' | 'unknown';
@ -161,10 +162,10 @@ export async function loadTestBuildSource(inputs: {
}
export function getDefaultStatusDir(): string {
const explicit = process.env.CLAWDIE_VAR_DIR;
const explicit =
process.env.AGENT_STATUS_DIR || process.env.CLAWDIE_VAR_DIR;
if (explicit && explicit.trim()) return explicit.trim();
const home = process.env.HOME || '/var/db/clawdie';
return path.join(home, '.clawdie', 'status');
return path.join(PROJECT_ROOT, 'tmp', 'status');
}
export function getDefaultBuildStatusPath(): string {

View file

@ -1,7 +1,11 @@
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
import {
buildAiTokenBriefLines,
buildOwnershipSectionLines,
readCurrentCommitHash,
formatTimestamp,
parseUptime,
parseJails,
@ -87,7 +91,7 @@ describe('buildAiTokenBriefLines', () => {
describe('buildOwnershipSectionLines', () => {
it('includes controlplane addresses and tenant sites', () => {
const lines = buildOwnershipSectionLines({
platformId: 'clawdie',
platformId: 'mevy',
tenantId: 'mevy',
tenantDisplayName: 'Mevy',
serviceNames: ['clawdie', 'cms'],
@ -97,6 +101,7 @@ describe('buildOwnershipSectionLines', () => {
expect(lines).toEqual(
expect.arrayContaining([
'Platform: Clawdie (clawdie)',
'Controlplane (internal): ai.home.arpa',
'Tenant home: mevy.home.arpa',
'Tenant sites: blog → blog.mevy.home.arpa',
@ -156,22 +161,22 @@ describe('parseUptime', () => {
});
describe('parseJails', () => {
it('parses bastille list all output', () => {
it('parses bastille list all output and uses canonical jail names from path', () => {
const raw = [
'JID Hostname IP State Release Port',
'1 db.home.arpa 10.0.0.3 Up 15.0-RELEASE -',
'2 cms.home.arpa 10.0.0.4 Up 15.0-RELEASE -',
'3 git.home.arpa 10.0.0.6 Down 15.0-RELEASE -',
'JID Boot Prio State IP Address Published Ports Hostname Release Path',
'1 on 99 Up 10.0.1.4 - cms.mevy.home.arpa 15.0-RELEASE-p4 /usr/local/bastille/jails/cms/root',
'2 on 99 Up 10.0.1.213 - ctrl-worker.mevy.home.arpa 15.0-RELEASE-p4 /usr/local/bastille/jails/mevy_ctrl_worker/root',
'3 on 99 Down 10.0.1.212 - git-worker.mevy.home.arpa 15.0-RELEASE-p4 /usr/local/bastille/jails/mevy_git_worker/root',
].join('\n');
const result = parseJails(raw);
expect(result.up).toBe(2);
expect(result.total).toBe(3);
expect(result.names).toEqual(['db', 'cms', 'git']);
expect(result.names).toEqual(['cms', 'mevy_ctrl_worker', 'mevy_git_worker']);
expect(result.details).toContain('🌐');
expect(result.details).toContain('🧬');
expect(result.details).toContain('db');
expect(result.details).toContain('git');
expect(result.details).toContain('🛠');
expect(result.details).toContain('mevy_ctrl_worker');
expect(result.details).toContain('mevy_git_worker');
});
it('handles empty jail list', () => {
@ -183,6 +188,36 @@ describe('parseJails', () => {
});
});
describe('readCurrentCommitHash', () => {
it('reads the current branch ref instead of assuming main', () => {
const root = path.resolve(process.cwd(), 'tmp', 'startup-report-git-ref');
fs.rmSync(root, { recursive: true, force: true });
fs.mkdirSync(path.join(root, '.git', 'refs', 'heads'), {
recursive: true,
});
fs.writeFileSync(path.join(root, '.git', 'HEAD'), 'ref: refs/heads/multitenant\n');
fs.writeFileSync(
path.join(root, '.git', 'refs', 'heads', 'multitenant'),
'1234567890abcdef\n',
);
expect(readCurrentCommitHash(root)).toBe('1234567');
fs.rmSync(root, { recursive: true, force: true });
});
it('reads detached HEAD hashes directly', () => {
const root = path.resolve(process.cwd(), 'tmp', 'startup-report-git-detached');
fs.rmSync(root, { recursive: true, force: true });
fs.mkdirSync(path.join(root, '.git'), { recursive: true });
fs.writeFileSync(path.join(root, '.git', 'HEAD'), 'abcdef0123456789\n');
expect(readCurrentCommitHash(root)).toBe('abcdef0');
fs.rmSync(root, { recursive: true, force: true });
});
});
describe('parseZpool', () => {
it('parses zpool list output', () => {
const raw = 'zroot\tONLINE\t50G\t20G\t30G';
@ -333,7 +368,7 @@ describe('formatMemorySectionLines', () => {
describe('buildOwnershipSectionLines', () => {
it('summarizes tenant, shared, and unknown resources', () => {
const lines = buildOwnershipSectionLines({
platformId: 'clawdie',
platformId: 'mevy',
tenantId: 'mevy',
tenantDisplayName: 'Mevy',
serviceNames: ['postgresql', 'mevy', 'mysteryd'],

View file

@ -128,6 +128,56 @@ export function parseUptime(bootRaw: string): string {
return d > 0 ? `${d}d ${h}h` : `${h}h`;
}
function resolveGitDir(projectRoot: string): string | null {
try {
const dotGitPath = path.join(projectRoot, '.git');
const stat = fs.statSync(dotGitPath);
if (stat.isDirectory()) return dotGitPath;
if (!stat.isFile()) return null;
const raw = fs.readFileSync(dotGitPath, 'utf8').trim();
const match = raw.match(/^gitdir:\s*(.+)$/iu);
if (!match) return null;
return path.resolve(projectRoot, match[1].trim());
} catch {
return null;
}
}
function readPackedRef(gitDir: string, refName: string): string | null {
try {
const packedRefs = fs.readFileSync(path.join(gitDir, 'packed-refs'), 'utf8');
for (const line of packedRefs.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('^')) continue;
const [hash, ref] = trimmed.split(/\s+/u);
if (ref === refName && hash) return hash.trim();
}
return null;
} catch {
return null;
}
}
export function readCurrentCommitHash(projectRoot: string = PROJECT_ROOT): string {
try {
const gitDir = resolveGitDir(projectRoot);
if (!gitDir) return '?';
const headRaw = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
let fullHash = headRaw;
if (headRaw.startsWith('ref:')) {
const refName = headRaw.slice(4).trim();
const refPath = path.join(gitDir, refName);
try {
fullHash = fs.readFileSync(refPath, 'utf8').trim();
} catch {
fullHash = readPackedRef(gitDir, refName) || '';
}
}
return fullHash ? fullHash.slice(0, 7) : '?';
} catch {
return '?';
}
}
function jailIcon(name: string): string {
const n = name.toLowerCase();
@ -149,20 +199,26 @@ export function parseJails(jailRaw: string): {
.split('\n')
.filter((l) => l.trim() && !l.trim().startsWith('JID'));
const names: string[] = [];
const up = dataLines.filter((l) => /\bUp\b/i.test(l)).length;
const details = dataLines
.map((l) => {
const cols = l.trim().split(/\s{2,}/);
const state = cols[3] || '?';
const hostname = cols[6] || cols[1] || '?';
const name = hostname.split('.')[0];
names.push(name);
const isUp = /\bUp\b/i.test(state);
const icon = jailIcon(name);
return ` ${icon} ${name}${isUp ? '' : ' (down)'}`;
})
.join('\n');
return { up, total: dataLines.length, details, names };
const rendered: string[] = [];
let up = 0;
for (const line of dataLines) {
const cols = line.trim().split(/\s{2,}/);
const state = cols[3] || '?';
const hostname = cols[6] || cols[1] || '?';
const jailPath = cols[8] || '';
const pathName =
jailPath.match(/\/jails\/([^/]+)\/root$/u)?.[1] || '';
const fallbackName = hostname.split('.')[0];
const name = pathName || fallbackName;
const isUp = /\bUp\b/i.test(state);
if (isUp) up += 1;
names.push(name);
const icon = jailIcon(name);
rendered.push(` ${icon} ${name}${isUp ? '' : ' (down)'}`);
}
return { up, total: dataLines.length, details: rendered.join('\n'), names };
}
export function deriveRepoWebBase(remote: string): string {
@ -324,6 +380,7 @@ export function buildOwnershipSectionLines(opts: {
datasets: string[];
}): string[] {
const registry = loadTenantRegistry();
const canonicalPlatformId = registry.platform.id;
const tenant = getTenantRecord(opts.tenantId, registry);
const surfaces = buildSurfaceInventory(registry);
const controlplaneInternal = surfaces.find(
@ -343,7 +400,7 @@ export function buildOwnershipSectionLines(opts: {
const lines = [
...sectionHeader('🧭 Platform'),
`Platform: ${registry.platform.displayName} (${opts.platformId})`,
`Platform: ${registry.platform.displayName} (${canonicalPlatformId})`,
`Controlplane (internal): ${controlplaneInternal || registry.platform.internalDomain}`,
...(controlplanePublic
? [`Controlplane (public): ${controlplanePublic}`]
@ -361,7 +418,7 @@ export function buildOwnershipSectionLines(opts: {
const tenantWarning = tenantContextWarning(
opts.tenantId,
opts.platformId,
canonicalPlatformId,
!!tenant,
);
if (tenantWarning) {
@ -544,16 +601,7 @@ export function buildStartupReport(): string {
const now = new Date();
const timestamp = formatTimestamp(now, tz, locale);
const commitHash = (() => {
try {
return fs
.readFileSync(path.join(PROJECT_ROOT, '.git/refs/heads/main'), 'utf-8')
.trim()
.slice(0, 7);
} catch {
return '?';
}
})();
const commitHash = readCurrentCommitHash(PROJECT_ROOT);
const repoBase = deriveRepoWebBase(REMOTE_GIT_URL);
const commitUrl = repoBase ? `${repoBase}/commit/${commitHash}` : '';
@ -619,6 +667,13 @@ export function buildStartupReport(): string {
shellExec('sudo pkg version -vRUL= 2>/dev/null'),
);
const registry = loadTenantRegistry();
const tenant = getTenantRecord(TENANT_ID, registry);
const declaredServiceNames = Array.from(
new Set([
...(tenant?.service ? [tenant.service] : [TENANT_ID]),
...registry.shared.services,
]),
);
const distStaleWarning = getDistStaleWarningLine();
@ -655,7 +710,7 @@ export function buildStartupReport(): string {
platformId: PLATFORM_ID,
tenantId: TENANT_ID,
tenantDisplayName: TENANT_DISPLAY_NAME,
serviceNames: registry.shared.services,
serviceNames: declaredServiceNames,
jailNames: jails.names,
datasets: [mevyDataset, pgDataset],
}),

View file

@ -1916,7 +1916,7 @@ export async function handleStatusCommand(
`Uptime: ${h}h ${m}m`,
`Load: <code>${load1.toFixed(2)}</code> / <code>${load5.toFixed(2)}</code> / <code>${load15.toFixed(2)}</code>`,
`Memory: ${freeMB}MB free / ${totalMB}MB total`,
`Identity: <code>${PLATFORM_ID}</code> platform · <code>${TENANT_ID}</code> tenant (${TENANT_DISPLAY_NAME})`,
`Identity: <code>${loadTenantRegistry().platform.id}</code> platform · <code>${TENANT_ID}</code> tenant (${TENANT_DISPLAY_NAME})`,
];
const defaultProvider = PI_TUI_PROVIDER;

View file

@ -442,16 +442,16 @@ describe('tenant-registry', () => {
expect(() =>
addTenantRecord(
'Bob Agent',
{ service: 'clawdie_hostd' },
{ service: 'mevy_hostd' },
registryPath,
),
).toThrow('Tenant service conflicts with shared service: clawdie_hostd');
).toThrow('Tenant service conflicts with shared service: mevy_hostd');
});
it('rejects tenant ids reserved by shared resource aliases', () => {
const registryPath = makeTempRegistry();
expect(() => addTenantRecord('clawdie-hostd', {}, registryPath)).toThrow(
'Tenant id is reserved by platform resources: clawdie-hostd',
expect(() => addTenantRecord('mevy-hostd', {}, registryPath)).toThrow(
'Tenant id is reserved by platform resources: mevy-hostd',
);
});