1285 lines
54 KiB
TypeScript
1285 lines
54 KiB
TypeScript
|
|
import { randomBytes } from 'node:crypto';
|
||
|
|
import type { Dirent } from 'node:fs';
|
||
|
|
import { appendFile, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
||
|
|
import path from 'node:path';
|
||
|
|
|
||
|
|
import { ensureProject, projectDir } from '../projects.js';
|
||
|
|
import { DEFAULT_LIVE_ARTIFACT_TOTAL_TIMEOUT_MS } from './refresh.js';
|
||
|
|
import { renderHtmlTemplateV1 } from './render.js';
|
||
|
|
import type { BoundedJsonObject, LiveArtifact, LiveArtifactCreateInput, LiveArtifactProvenance, LiveArtifactRefreshErrorRecord, LiveArtifactRefreshLogEntry, LiveArtifactRefreshSourceMetadata, LiveArtifactRefreshStepStatus, LiveArtifactUpdateInput, LiveArtifactValidationIssue } from './schema.js';
|
||
|
|
import { validateBoundedJsonObject, validateLiveArtifactCreateInput, validateLiveArtifactRefreshLogEntry, validateLiveArtifactUpdateInput, validatePersistedLiveArtifact } from './schema.js';
|
||
|
|
|
||
|
|
export type LiveArtifactSummary = Omit<LiveArtifact, 'document'> & {
|
||
|
|
hasDocument: boolean;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const LIVE_ARTIFACTS_DIR_NAME = '.live-artifacts' as const;
|
||
|
|
export const LIVE_ARTIFACT_ARTIFACT_FILE = 'artifact.json' as const;
|
||
|
|
export const LIVE_ARTIFACT_TEMPLATE_FILE = 'template.html' as const;
|
||
|
|
export const LIVE_ARTIFACT_PREVIEW_FILE = 'index.html' as const;
|
||
|
|
export const LIVE_ARTIFACT_DATA_FILE = 'data.json' as const;
|
||
|
|
export const LIVE_ARTIFACT_PROVENANCE_FILE = 'provenance.json' as const;
|
||
|
|
export const LIVE_ARTIFACT_REFRESHES_FILE = 'refreshes.jsonl' as const;
|
||
|
|
export const LIVE_ARTIFACT_REFRESH_LOCK_FILE = 'refresh.lock.json' as const;
|
||
|
|
export const LIVE_ARTIFACT_REFRESH_STATE_FILE = 'refresh-state.json' as const;
|
||
|
|
export const LIVE_ARTIFACT_SNAPSHOTS_DIR = 'snapshots' as const;
|
||
|
|
|
||
|
|
const SAFE_LIVE_ARTIFACT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||
|
|
const LIVE_ARTIFACT_ID_PREFIX = 'la';
|
||
|
|
const LIVE_ARTIFACT_ID_RANDOM_BYTES = 6;
|
||
|
|
const LIVE_ARTIFACT_ID_RANDOM_SUFFIX_LENGTH = LIVE_ARTIFACT_ID_RANDOM_BYTES * 2;
|
||
|
|
const MAX_LIVE_ARTIFACT_STORAGE_ID_LENGTH = 128;
|
||
|
|
const MAX_LIVE_ARTIFACT_SLUG_LENGTH = 128;
|
||
|
|
const FALLBACK_LIVE_ARTIFACT_SLUG = 'live-artifact';
|
||
|
|
|
||
|
|
function isPathInside(parentDir: string, targetPath: string): boolean {
|
||
|
|
const relative = path.relative(parentDir, targetPath);
|
||
|
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveInside(parentDir: string, relativePath: string, escapeMessage: string): string {
|
||
|
|
if (path.isAbsolute(relativePath) || relativePath.includes('\0')) {
|
||
|
|
throw new Error(escapeMessage);
|
||
|
|
}
|
||
|
|
const targetPath = path.resolve(parentDir, relativePath);
|
||
|
|
if (!isPathInside(parentDir, targetPath)) {
|
||
|
|
throw new Error(escapeMessage);
|
||
|
|
}
|
||
|
|
return targetPath;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface LiveArtifactStorePaths {
|
||
|
|
projectDir: string;
|
||
|
|
rootDir: string;
|
||
|
|
artifactDir: string;
|
||
|
|
artifactJsonPath: string;
|
||
|
|
templateHtmlPath: string;
|
||
|
|
generatedPreviewHtmlPath: string;
|
||
|
|
dataJsonPath: string;
|
||
|
|
provenanceJsonPath: string;
|
||
|
|
refreshesJsonlPath: string;
|
||
|
|
refreshLockPath: string;
|
||
|
|
refreshStatePath: string;
|
||
|
|
snapshotsDir: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface LiveArtifactStoreSummary {
|
||
|
|
artifact: LiveArtifactSummary;
|
||
|
|
paths: LiveArtifactStorePaths;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface LiveArtifactStoreRecord {
|
||
|
|
artifact: LiveArtifact;
|
||
|
|
paths: LiveArtifactStorePaths;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface GenerateLiveArtifactIdOptions {
|
||
|
|
title: string;
|
||
|
|
slug?: string;
|
||
|
|
randomSuffix?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface CreateLiveArtifactOptions {
|
||
|
|
projectsRoot: string;
|
||
|
|
projectId: string;
|
||
|
|
input: unknown;
|
||
|
|
templateHtml?: string;
|
||
|
|
provenanceJson?: LiveArtifactProvenance;
|
||
|
|
createdByRunId?: string;
|
||
|
|
now?: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ListLiveArtifactsOptions {
|
||
|
|
projectsRoot: string;
|
||
|
|
projectId: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface GetLiveArtifactOptions {
|
||
|
|
projectsRoot: string;
|
||
|
|
projectId: string;
|
||
|
|
artifactId: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface UpdateLiveArtifactOptions {
|
||
|
|
projectsRoot: string;
|
||
|
|
projectId: string;
|
||
|
|
artifactId: string;
|
||
|
|
input: unknown;
|
||
|
|
templateHtml?: string;
|
||
|
|
provenanceJson?: LiveArtifactProvenance;
|
||
|
|
now?: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface DeleteLiveArtifactOptions {
|
||
|
|
projectsRoot: string;
|
||
|
|
projectId: string;
|
||
|
|
artifactId: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface RegenerateLiveArtifactPreviewOptions {
|
||
|
|
projectsRoot: string;
|
||
|
|
projectId: string;
|
||
|
|
artifactId: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface AcquireLiveArtifactRefreshLockOptions {
|
||
|
|
projectsRoot: string;
|
||
|
|
projectId: string;
|
||
|
|
artifactId: string;
|
||
|
|
now?: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface AppendLiveArtifactRefreshLogEntryOptions {
|
||
|
|
projectsRoot: string;
|
||
|
|
projectId: string;
|
||
|
|
artifactId: string;
|
||
|
|
refreshId: string;
|
||
|
|
sequence: number;
|
||
|
|
step: string;
|
||
|
|
status: LiveArtifactRefreshStepStatus;
|
||
|
|
startedAt: Date | string;
|
||
|
|
finishedAt?: Date | string;
|
||
|
|
durationMs?: number;
|
||
|
|
source?: LiveArtifactRefreshSourceMetadata;
|
||
|
|
error?: LiveArtifactRefreshErrorRecord | unknown;
|
||
|
|
metadata?: BoundedJsonObject;
|
||
|
|
now?: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ListLiveArtifactRefreshLogEntriesOptions {
|
||
|
|
projectsRoot: string;
|
||
|
|
projectId: string;
|
||
|
|
artifactId: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface MarkLiveArtifactRefreshCommittedOptions {
|
||
|
|
projectsRoot: string;
|
||
|
|
projectId: string;
|
||
|
|
artifactId: string;
|
||
|
|
refreshId: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface MarkLiveArtifactRefreshRunningOptions extends MarkLiveArtifactRefreshCommittedOptions {
|
||
|
|
now?: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface CommitLiveArtifactRefreshCandidateOptions extends MarkLiveArtifactRefreshCommittedOptions {
|
||
|
|
dataJson: BoundedJsonObject;
|
||
|
|
provenanceJson?: LiveArtifactProvenance;
|
||
|
|
now?: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface MarkLiveArtifactRefreshFailedOptions extends MarkLiveArtifactRefreshCommittedOptions {
|
||
|
|
now?: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface RecoverStaleLiveArtifactRefreshesOptions {
|
||
|
|
projectsRoot: string;
|
||
|
|
now?: Date;
|
||
|
|
staleAfterMs?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface LiveArtifactRefreshRecoveryResult {
|
||
|
|
projectId: string;
|
||
|
|
artifactId: string;
|
||
|
|
refreshId: string;
|
||
|
|
status: 'recovered' | 'skipped';
|
||
|
|
reason?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface LiveArtifactPreviewRenderRecord extends LiveArtifactStoreRecord {
|
||
|
|
html: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface LiveArtifactRefreshLockMetadata {
|
||
|
|
schemaVersion: 1;
|
||
|
|
projectId: string;
|
||
|
|
artifactId: string;
|
||
|
|
refreshId: string;
|
||
|
|
refreshOrdinal: number;
|
||
|
|
acquiredAt: string;
|
||
|
|
lockId: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface LiveArtifactRefreshState {
|
||
|
|
schemaVersion: 1;
|
||
|
|
projectId: string;
|
||
|
|
artifactId: string;
|
||
|
|
nextRefreshOrdinal: number;
|
||
|
|
lastCommittedRefreshId?: string;
|
||
|
|
lastCommittedRefreshOrdinal?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface LiveArtifactRefreshLock {
|
||
|
|
artifactId: string;
|
||
|
|
lockPath: string;
|
||
|
|
metadata: LiveArtifactRefreshLockMetadata;
|
||
|
|
}
|
||
|
|
|
||
|
|
export class LiveArtifactStoreValidationError extends Error {
|
||
|
|
readonly issues: LiveArtifactValidationIssue[];
|
||
|
|
|
||
|
|
constructor(message: string, issues: LiveArtifactValidationIssue[]) {
|
||
|
|
super(message);
|
||
|
|
this.name = 'LiveArtifactStoreValidationError';
|
||
|
|
this.issues = issues;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export class LiveArtifactRefreshLockError extends Error {
|
||
|
|
readonly projectId: string;
|
||
|
|
readonly artifactId: string;
|
||
|
|
readonly lockPath: string;
|
||
|
|
|
||
|
|
constructor(message: string, options: { projectId: string; artifactId: string; lockPath: string }) {
|
||
|
|
super(message);
|
||
|
|
this.name = 'LiveArtifactRefreshLockError';
|
||
|
|
this.projectId = options.projectId;
|
||
|
|
this.artifactId = options.artifactId;
|
||
|
|
this.lockPath = options.lockPath;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export class LiveArtifactStaleRefreshError extends Error {
|
||
|
|
readonly projectId: string;
|
||
|
|
readonly artifactId: string;
|
||
|
|
readonly refreshId: string;
|
||
|
|
readonly lastCommittedRefreshId?: string;
|
||
|
|
|
||
|
|
constructor(message: string, options: { projectId: string; artifactId: string; refreshId: string; lastCommittedRefreshId?: string }) {
|
||
|
|
super(message);
|
||
|
|
this.name = 'LiveArtifactStaleRefreshError';
|
||
|
|
this.projectId = options.projectId;
|
||
|
|
this.artifactId = options.artifactId;
|
||
|
|
this.refreshId = options.refreshId;
|
||
|
|
if (options.lastCommittedRefreshId !== undefined) this.lastCommittedRefreshId = options.lastCommittedRefreshId;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function truncateSlugAtSegmentBoundary(slug: string, maxLength: number): string {
|
||
|
|
if (slug.length <= maxLength) return slug;
|
||
|
|
const truncated = slug.slice(0, maxLength).replace(/-+$/g, '');
|
||
|
|
return truncated.length > 0 ? truncated : slug.slice(0, maxLength);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function generateLiveArtifactSlug(input: string): string {
|
||
|
|
const slug = input
|
||
|
|
.normalize('NFKD')
|
||
|
|
.replace(/[\u0300-\u036f]/g, '')
|
||
|
|
.toLowerCase()
|
||
|
|
.replace(/[^a-z0-9]+/g, '-')
|
||
|
|
.replace(/^-+|-+$/g, '')
|
||
|
|
.replace(/-{2,}/g, '-');
|
||
|
|
|
||
|
|
return truncateSlugAtSegmentBoundary(slug || FALLBACK_LIVE_ARTIFACT_SLUG, MAX_LIVE_ARTIFACT_SLUG_LENGTH);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function generateLiveArtifactId(options: GenerateLiveArtifactIdOptions): string {
|
||
|
|
const randomSuffix = options.randomSuffix ?? randomBytes(LIVE_ARTIFACT_ID_RANDOM_BYTES).toString('hex');
|
||
|
|
if (!/^[a-f0-9]+$/i.test(randomSuffix) || randomSuffix.length === 0) {
|
||
|
|
throw new Error('invalid live artifact id random suffix');
|
||
|
|
}
|
||
|
|
|
||
|
|
const suffix = randomSuffix.toLowerCase();
|
||
|
|
const maxSlugLength = MAX_LIVE_ARTIFACT_STORAGE_ID_LENGTH - LIVE_ARTIFACT_ID_PREFIX.length - suffix.length - 2;
|
||
|
|
if (maxSlugLength < 1) {
|
||
|
|
throw new Error('invalid live artifact id random suffix');
|
||
|
|
}
|
||
|
|
const slug = truncateSlugAtSegmentBoundary(generateLiveArtifactSlug(options.slug ?? options.title), maxSlugLength);
|
||
|
|
return validateLiveArtifactStorageId(`${LIVE_ARTIFACT_ID_PREFIX}-${slug}-${suffix}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function validateLiveArtifactStorageId(artifactId: string): string {
|
||
|
|
if (!SAFE_LIVE_ARTIFACT_ID.test(artifactId) || artifactId === '.' || artifactId === '..') {
|
||
|
|
throw new Error('invalid live artifact id');
|
||
|
|
}
|
||
|
|
return artifactId;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function liveArtifactsRootDir(projectsRoot: string, projectId: string): string {
|
||
|
|
const projectDirPath = path.resolve(projectDir(projectsRoot, projectId));
|
||
|
|
return resolveInside(projectDirPath, LIVE_ARTIFACTS_DIR_NAME, 'live artifact path escapes project dir');
|
||
|
|
}
|
||
|
|
|
||
|
|
export function liveArtifactStorePaths(
|
||
|
|
projectsRoot: string,
|
||
|
|
projectId: string,
|
||
|
|
artifactId: string,
|
||
|
|
): LiveArtifactStorePaths {
|
||
|
|
const safeArtifactId = validateLiveArtifactStorageId(artifactId);
|
||
|
|
const projectDirPath = path.resolve(projectDir(projectsRoot, projectId));
|
||
|
|
const rootDir = liveArtifactsRootDir(projectsRoot, projectId);
|
||
|
|
const artifactDir = resolveInside(rootDir, safeArtifactId, 'live artifact path escapes storage root');
|
||
|
|
if (!isPathInside(projectDirPath, artifactDir)) throw new Error('live artifact path escapes project dir');
|
||
|
|
|
||
|
|
return {
|
||
|
|
projectDir: projectDirPath,
|
||
|
|
rootDir,
|
||
|
|
artifactDir,
|
||
|
|
artifactJsonPath: resolveInside(artifactDir, LIVE_ARTIFACT_ARTIFACT_FILE, 'live artifact path escapes artifact dir'),
|
||
|
|
templateHtmlPath: resolveInside(artifactDir, LIVE_ARTIFACT_TEMPLATE_FILE, 'live artifact path escapes artifact dir'),
|
||
|
|
generatedPreviewHtmlPath: resolveInside(artifactDir, LIVE_ARTIFACT_PREVIEW_FILE, 'live artifact path escapes artifact dir'),
|
||
|
|
dataJsonPath: resolveInside(artifactDir, LIVE_ARTIFACT_DATA_FILE, 'live artifact path escapes artifact dir'),
|
||
|
|
provenanceJsonPath: resolveInside(artifactDir, LIVE_ARTIFACT_PROVENANCE_FILE, 'live artifact path escapes artifact dir'),
|
||
|
|
refreshesJsonlPath: resolveInside(artifactDir, LIVE_ARTIFACT_REFRESHES_FILE, 'live artifact path escapes artifact dir'),
|
||
|
|
refreshLockPath: resolveInside(artifactDir, LIVE_ARTIFACT_REFRESH_LOCK_FILE, 'live artifact path escapes artifact dir'),
|
||
|
|
refreshStatePath: resolveInside(artifactDir, LIVE_ARTIFACT_REFRESH_STATE_FILE, 'live artifact path escapes artifact dir'),
|
||
|
|
snapshotsDir: resolveInside(artifactDir, LIVE_ARTIFACT_SNAPSHOTS_DIR, 'live artifact path escapes artifact dir'),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function ensureLiveArtifactStoreLayout(
|
||
|
|
projectsRoot: string,
|
||
|
|
projectId: string,
|
||
|
|
artifactId: string,
|
||
|
|
): Promise<LiveArtifactStorePaths> {
|
||
|
|
await ensureProject(projectsRoot, projectId);
|
||
|
|
const paths = liveArtifactStorePaths(projectsRoot, projectId, artifactId);
|
||
|
|
await mkdir(paths.snapshotsDir, { recursive: true });
|
||
|
|
await writeFile(paths.refreshesJsonlPath, '', { flag: 'a' });
|
||
|
|
return paths;
|
||
|
|
}
|
||
|
|
|
||
|
|
function stableJson(value: unknown): string {
|
||
|
|
return `${JSON.stringify(value, null, 2)}\n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function writeFileAtomic(filePath: string, contents: string): Promise<void> {
|
||
|
|
const tempPath = `${filePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
||
|
|
await writeFile(tempPath, contents, 'utf8');
|
||
|
|
await rename(tempPath, filePath);
|
||
|
|
}
|
||
|
|
|
||
|
|
function defaultTemplateHtml(title: string): string {
|
||
|
|
return [
|
||
|
|
'<!doctype html>',
|
||
|
|
'<html lang="en">',
|
||
|
|
' <head>',
|
||
|
|
' <meta charset="utf-8" />',
|
||
|
|
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||
|
|
' <title>{{data.title}}</title>',
|
||
|
|
' </head>',
|
||
|
|
' <body>',
|
||
|
|
' <main>',
|
||
|
|
` <h1>{{data.title}}</h1>`,
|
||
|
|
` <p>${title}</p>`,
|
||
|
|
' </main>',
|
||
|
|
' </body>',
|
||
|
|
'</html>',
|
||
|
|
'',
|
||
|
|
].join('\n');
|
||
|
|
}
|
||
|
|
|
||
|
|
function defaultProvenance(nowIso: string): LiveArtifactProvenance {
|
||
|
|
return {
|
||
|
|
generatedAt: nowIso,
|
||
|
|
generatedBy: 'agent',
|
||
|
|
notes: 'Created through the live artifact registration service.',
|
||
|
|
sources: [{ label: 'Agent-authored live artifact input', type: 'user_input' }],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function toSummary(artifact: LiveArtifact): LiveArtifactSummary {
|
||
|
|
const { document: _document, ...summary } = artifact;
|
||
|
|
return {
|
||
|
|
...summary,
|
||
|
|
hasDocument: _document !== undefined,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function validationError(path: string, message: string): LiveArtifactStoreValidationError {
|
||
|
|
return new LiveArtifactStoreValidationError(message, [{ path, message }]);
|
||
|
|
}
|
||
|
|
|
||
|
|
function toIsoDate(value: Date | string): string {
|
||
|
|
return value instanceof Date ? value.toISOString() : value;
|
||
|
|
}
|
||
|
|
|
||
|
|
function truncateText(value: string, maxLength: number): string {
|
||
|
|
return value.length <= maxLength ? value : `${value.slice(0, Math.max(0, maxLength - 1))}…`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function compactLiveArtifactRefreshError(error: unknown): LiveArtifactRefreshErrorRecord {
|
||
|
|
if (error && typeof error === 'object') {
|
||
|
|
const record = error as { code?: unknown; message?: unknown; path?: unknown };
|
||
|
|
const compact: LiveArtifactRefreshErrorRecord = {
|
||
|
|
message: truncateText(typeof record.message === 'string' ? record.message : String(error), 2_048),
|
||
|
|
};
|
||
|
|
if (typeof record.code === 'string' && record.code.length > 0) compact.code = truncateText(record.code, 128);
|
||
|
|
if (typeof record.path === 'string' && record.path.length > 0) compact.path = truncateText(record.path, 260);
|
||
|
|
return compact;
|
||
|
|
}
|
||
|
|
|
||
|
|
return { message: truncateText(String(error), 2_048) };
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeRefreshLogEntry(options: AppendLiveArtifactRefreshLogEntryOptions): LiveArtifactRefreshLogEntry {
|
||
|
|
const startedAt = toIsoDate(options.startedAt);
|
||
|
|
const finishedAt = options.finishedAt === undefined ? undefined : toIsoDate(options.finishedAt);
|
||
|
|
const durationMs = options.durationMs ?? (
|
||
|
|
finishedAt === undefined ? undefined : Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt))
|
||
|
|
);
|
||
|
|
const entry: LiveArtifactRefreshLogEntry = {
|
||
|
|
schemaVersion: 1,
|
||
|
|
projectId: options.projectId,
|
||
|
|
artifactId: options.artifactId,
|
||
|
|
refreshId: options.refreshId,
|
||
|
|
sequence: options.sequence,
|
||
|
|
step: options.step,
|
||
|
|
status: options.status,
|
||
|
|
startedAt,
|
||
|
|
createdAt: (options.now ?? new Date()).toISOString(),
|
||
|
|
};
|
||
|
|
if (finishedAt !== undefined) entry.finishedAt = finishedAt;
|
||
|
|
if (durationMs !== undefined) entry.durationMs = durationMs;
|
||
|
|
if (options.source !== undefined) entry.source = options.source;
|
||
|
|
if (options.error !== undefined) entry.error = compactLiveArtifactRefreshError(options.error);
|
||
|
|
if (options.metadata !== undefined) entry.metadata = options.metadata;
|
||
|
|
return entry;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatRefreshId(refreshOrdinal: number): string {
|
||
|
|
if (!Number.isSafeInteger(refreshOrdinal) || refreshOrdinal < 1) {
|
||
|
|
throw new Error('invalid live artifact refresh ordinal');
|
||
|
|
}
|
||
|
|
return `refresh-${refreshOrdinal.toString().padStart(6, '0')}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseRefreshOrdinal(refreshId: string): number {
|
||
|
|
const match = /^refresh-(\d+)$/.exec(refreshId);
|
||
|
|
if (match === null) throw new Error('invalid live artifact refresh id');
|
||
|
|
const refreshOrdinal = Number(match[1]);
|
||
|
|
if (!Number.isSafeInteger(refreshOrdinal) || refreshOrdinal < 1) {
|
||
|
|
throw new Error('invalid live artifact refresh id');
|
||
|
|
}
|
||
|
|
return refreshOrdinal;
|
||
|
|
}
|
||
|
|
|
||
|
|
function defaultRefreshState(projectId: string, artifactId: string): LiveArtifactRefreshState {
|
||
|
|
return { schemaVersion: 1, projectId, artifactId, nextRefreshOrdinal: 1 };
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeRefreshState(value: unknown, projectId: string, artifactId: string): LiveArtifactRefreshState {
|
||
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
|
|
throw validationError('refresh-state.json', 'live artifact refresh state must be an object');
|
||
|
|
}
|
||
|
|
const raw = value as Record<string, unknown>;
|
||
|
|
if (raw.schemaVersion !== 1) throw validationError('refresh-state.json.schemaVersion', 'live artifact refresh state schemaVersion must be 1');
|
||
|
|
if (raw.projectId !== projectId) throw validationError('refresh-state.json.projectId', 'live artifact refresh state projectId does not match requested project');
|
||
|
|
if (raw.artifactId !== artifactId) throw validationError('refresh-state.json.artifactId', 'live artifact refresh state artifactId does not match storage directory');
|
||
|
|
if (!Number.isSafeInteger(raw.nextRefreshOrdinal) || (raw.nextRefreshOrdinal as number) < 1) {
|
||
|
|
throw validationError('refresh-state.json.nextRefreshOrdinal', 'live artifact refresh state nextRefreshOrdinal must be a positive safe integer');
|
||
|
|
}
|
||
|
|
|
||
|
|
const state: LiveArtifactRefreshState = {
|
||
|
|
schemaVersion: 1,
|
||
|
|
projectId,
|
||
|
|
artifactId,
|
||
|
|
nextRefreshOrdinal: raw.nextRefreshOrdinal as number,
|
||
|
|
};
|
||
|
|
if (raw.lastCommittedRefreshId !== undefined) {
|
||
|
|
if (typeof raw.lastCommittedRefreshId !== 'string') throw validationError('refresh-state.json.lastCommittedRefreshId', 'live artifact refresh state lastCommittedRefreshId must be a string');
|
||
|
|
state.lastCommittedRefreshId = raw.lastCommittedRefreshId;
|
||
|
|
}
|
||
|
|
if (raw.lastCommittedRefreshOrdinal !== undefined) {
|
||
|
|
if (!Number.isSafeInteger(raw.lastCommittedRefreshOrdinal) || (raw.lastCommittedRefreshOrdinal as number) < 1) {
|
||
|
|
throw validationError('refresh-state.json.lastCommittedRefreshOrdinal', 'live artifact refresh state lastCommittedRefreshOrdinal must be a positive safe integer');
|
||
|
|
}
|
||
|
|
state.lastCommittedRefreshOrdinal = raw.lastCommittedRefreshOrdinal as number;
|
||
|
|
}
|
||
|
|
return state;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readLiveArtifactRefreshState(paths: LiveArtifactStorePaths, projectId: string, artifactId: string): Promise<LiveArtifactRefreshState> {
|
||
|
|
const text = await readTextFileOrDefault(paths.refreshStatePath, '');
|
||
|
|
if (text.trim().length === 0) return defaultRefreshState(projectId, artifactId);
|
||
|
|
try {
|
||
|
|
return normalizeRefreshState(JSON.parse(text), projectId, artifactId);
|
||
|
|
} catch (error) {
|
||
|
|
if (error instanceof SyntaxError) throw validationError('refresh-state.json', 'live artifact refresh state contains invalid JSON');
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function writeLiveArtifactRefreshState(paths: LiveArtifactStorePaths, state: LiveArtifactRefreshState): Promise<void> {
|
||
|
|
await writeFile(paths.refreshStatePath, stableJson(state), 'utf8');
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeRefreshLockMetadata(value: unknown, lockPath: string): LiveArtifactRefreshLockMetadata {
|
||
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
|
|
throw validationError(lockPath, 'live artifact refresh lock must be an object');
|
||
|
|
}
|
||
|
|
const raw = value as Record<string, unknown>;
|
||
|
|
if (raw.schemaVersion !== 1) throw validationError(`${lockPath}.schemaVersion`, 'live artifact refresh lock schemaVersion must be 1');
|
||
|
|
if (typeof raw.projectId !== 'string' || raw.projectId.length === 0) throw validationError(`${lockPath}.projectId`, 'live artifact refresh lock projectId must be a string');
|
||
|
|
if (typeof raw.artifactId !== 'string' || raw.artifactId.length === 0) throw validationError(`${lockPath}.artifactId`, 'live artifact refresh lock artifactId must be a string');
|
||
|
|
if (typeof raw.refreshId !== 'string' || raw.refreshId.length === 0) throw validationError(`${lockPath}.refreshId`, 'live artifact refresh lock refreshId must be a string');
|
||
|
|
if (!Number.isSafeInteger(raw.refreshOrdinal) || (raw.refreshOrdinal as number) < 1) throw validationError(`${lockPath}.refreshOrdinal`, 'live artifact refresh lock refreshOrdinal must be a positive safe integer');
|
||
|
|
if (typeof raw.acquiredAt !== 'string' || Number.isNaN(Date.parse(raw.acquiredAt))) throw validationError(`${lockPath}.acquiredAt`, 'live artifact refresh lock acquiredAt must be an ISO date string');
|
||
|
|
if (typeof raw.lockId !== 'string' || raw.lockId.length === 0) throw validationError(`${lockPath}.lockId`, 'live artifact refresh lock id must be a string');
|
||
|
|
return {
|
||
|
|
schemaVersion: 1,
|
||
|
|
projectId: raw.projectId,
|
||
|
|
artifactId: raw.artifactId,
|
||
|
|
refreshId: raw.refreshId,
|
||
|
|
refreshOrdinal: raw.refreshOrdinal as number,
|
||
|
|
acquiredAt: raw.acquiredAt,
|
||
|
|
lockId: raw.lockId,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readLiveArtifactRefreshLockMetadata(paths: LiveArtifactStorePaths): Promise<LiveArtifactRefreshLockMetadata> {
|
||
|
|
try {
|
||
|
|
return normalizeRefreshLockMetadata(JSON.parse(await readFile(paths.refreshLockPath, 'utf8')), LIVE_ARTIFACT_REFRESH_LOCK_FILE);
|
||
|
|
} catch (error) {
|
||
|
|
if (error instanceof SyntaxError) throw validationError(LIVE_ARTIFACT_REFRESH_LOCK_FILE, 'live artifact refresh lock contains invalid JSON');
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readPersistedLiveArtifact(paths: LiveArtifactStorePaths): Promise<LiveArtifact> {
|
||
|
|
let parsed: unknown;
|
||
|
|
try {
|
||
|
|
parsed = JSON.parse(await readFile(paths.artifactJsonPath, 'utf8'));
|
||
|
|
} catch (error) {
|
||
|
|
if (error instanceof SyntaxError) {
|
||
|
|
throw validationError('artifact.json', 'live artifact file contains invalid JSON');
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
|
||
|
|
const persisted = validatePersistedLiveArtifact(parsed);
|
||
|
|
if (!persisted.ok) throw new LiveArtifactStoreValidationError(persisted.error, persisted.issues);
|
||
|
|
return persisted.value;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function writePersistedLiveArtifact(paths: LiveArtifactStorePaths, artifact: LiveArtifact): Promise<LiveArtifact> {
|
||
|
|
const persisted = validatePersistedLiveArtifact(artifact);
|
||
|
|
if (!persisted.ok) throw new LiveArtifactStoreValidationError(persisted.error, persisted.issues);
|
||
|
|
await writeFile(paths.artifactJsonPath, stableJson(persisted.value), 'utf8');
|
||
|
|
return persisted.value;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readPersistedDataJson(paths: LiveArtifactStorePaths): Promise<BoundedJsonObject> {
|
||
|
|
let parsed: unknown;
|
||
|
|
try {
|
||
|
|
parsed = JSON.parse(await readFile(paths.dataJsonPath, 'utf8'));
|
||
|
|
} catch (error) {
|
||
|
|
if (error instanceof SyntaxError) {
|
||
|
|
throw validationError('data.json', 'live artifact data file contains invalid JSON');
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = validateBoundedJsonObject(parsed, 'data.json');
|
||
|
|
if (!result.ok) throw new LiveArtifactStoreValidationError(result.error, result.issues);
|
||
|
|
return result.value;
|
||
|
|
}
|
||
|
|
|
||
|
|
function assertArtifactMatchesStorage(artifact: LiveArtifact, projectId: string, artifactId: string): void {
|
||
|
|
if (artifact.id !== artifactId) {
|
||
|
|
throw validationError('id', 'live artifact id does not match storage directory');
|
||
|
|
}
|
||
|
|
if (artifact.projectId !== projectId) {
|
||
|
|
throw validationError('projectId', 'live artifact projectId does not match requested project');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function assertLiveArtifactRefreshLockScope(
|
||
|
|
projectsRoot: string,
|
||
|
|
projectId: string,
|
||
|
|
artifactId: string,
|
||
|
|
): Promise<LiveArtifactStorePaths> {
|
||
|
|
const safeArtifactId = validateLiveArtifactStorageId(artifactId);
|
||
|
|
const paths = liveArtifactStorePaths(projectsRoot, projectId, safeArtifactId);
|
||
|
|
const artifact = await readPersistedLiveArtifact(paths);
|
||
|
|
assertArtifactMatchesStorage(artifact, projectId, safeArtifactId);
|
||
|
|
return paths;
|
||
|
|
}
|
||
|
|
|
||
|
|
function artifactWithDataJson(artifact: LiveArtifact, dataJson: BoundedJsonObject): LiveArtifact {
|
||
|
|
if (artifact.document?.format !== 'html_template_v1') return artifact;
|
||
|
|
return { ...artifact, document: { ...artifact.document, dataJson } };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readLiveArtifactWithDataJsonCache(paths: LiveArtifactStorePaths): Promise<LiveArtifact> {
|
||
|
|
const artifact = await readPersistedLiveArtifact(paths);
|
||
|
|
if (artifact.document?.format !== 'html_template_v1') return artifact;
|
||
|
|
|
||
|
|
const dataJson = await readPersistedDataJson(paths);
|
||
|
|
return artifactWithDataJson(artifact, dataJson);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderPreviewHtml(templateHtml: string, dataJson: BoundedJsonObject): string {
|
||
|
|
try {
|
||
|
|
return renderHtmlTemplateV1({ templateHtml, dataJson }).html;
|
||
|
|
} catch (error) {
|
||
|
|
const message = error instanceof Error ? error.message : String(error);
|
||
|
|
throw new LiveArtifactStoreValidationError(message, [{ path: 'template.html', message }]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function writeLiveArtifactFiles(
|
||
|
|
paths: LiveArtifactStorePaths,
|
||
|
|
artifact: LiveArtifact,
|
||
|
|
templateHtml: string,
|
||
|
|
provenanceJson: LiveArtifactProvenance,
|
||
|
|
dataJsonOverride?: BoundedJsonObject,
|
||
|
|
): Promise<LiveArtifact> {
|
||
|
|
const dataJson = dataJsonOverride ?? artifact.document?.dataJson ?? {};
|
||
|
|
const artifactForWrite = artifactWithDataJson(artifact, dataJson);
|
||
|
|
const previewHtml = artifactForWrite.document?.format === 'html_template_v1'
|
||
|
|
? renderPreviewHtml(templateHtml, dataJson)
|
||
|
|
: templateHtml;
|
||
|
|
|
||
|
|
await mkdir(paths.snapshotsDir, { recursive: true });
|
||
|
|
await Promise.all([
|
||
|
|
writeFile(paths.artifactJsonPath, stableJson(artifactForWrite), 'utf8'),
|
||
|
|
writeFile(paths.templateHtmlPath, templateHtml, 'utf8'),
|
||
|
|
writeFile(paths.generatedPreviewHtmlPath, previewHtml, 'utf8'),
|
||
|
|
writeFile(paths.dataJsonPath, stableJson(dataJson), 'utf8'),
|
||
|
|
writeFile(paths.provenanceJsonPath, stableJson(provenanceJson), 'utf8'),
|
||
|
|
writeFile(paths.refreshesJsonlPath, '', { flag: 'a' }),
|
||
|
|
]);
|
||
|
|
return artifactForWrite;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function renderLiveArtifactPreviewFromFiles(paths: LiveArtifactStorePaths, artifact: LiveArtifact): Promise<string> {
|
||
|
|
const templateHtml = await readFile(paths.templateHtmlPath, 'utf8');
|
||
|
|
if (artifact.document?.format !== 'html_template_v1') return templateHtml;
|
||
|
|
|
||
|
|
const dataJson = await readPersistedDataJson(paths);
|
||
|
|
return renderPreviewHtml(templateHtml, dataJson);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readTextFileOrDefault(filePath: string, fallback: string): Promise<string> {
|
||
|
|
try {
|
||
|
|
return await readFile(filePath, 'utf8');
|
||
|
|
} catch (error) {
|
||
|
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return fallback;
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readProvenanceOrDefault(paths: LiveArtifactStorePaths, nowIso: string): Promise<LiveArtifactProvenance> {
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(await readFile(paths.provenanceJsonPath, 'utf8')) as LiveArtifactProvenance;
|
||
|
|
return parsed;
|
||
|
|
} catch (error) {
|
||
|
|
if (error instanceof SyntaxError) throw validationError('provenance.json', 'live artifact provenance file contains invalid JSON');
|
||
|
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return defaultProvenance(nowIso);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function createLiveArtifact(options: CreateLiveArtifactOptions): Promise<LiveArtifactStoreRecord> {
|
||
|
|
const result = validateLiveArtifactCreateInput(options.input);
|
||
|
|
if (!result.ok) throw new LiveArtifactStoreValidationError(result.error, result.issues);
|
||
|
|
|
||
|
|
const input: LiveArtifactCreateInput = result.value;
|
||
|
|
const nowIso = (options.now ?? new Date()).toISOString();
|
||
|
|
const artifactId = generateLiveArtifactId(input.slug === undefined ? { title: input.title } : { title: input.title, slug: input.slug });
|
||
|
|
const slug = generateLiveArtifactSlug(input.slug ?? input.title);
|
||
|
|
const artifactBase: LiveArtifact = {
|
||
|
|
schemaVersion: 1,
|
||
|
|
id: artifactId,
|
||
|
|
projectId: options.projectId,
|
||
|
|
title: input.title,
|
||
|
|
slug,
|
||
|
|
status: input.status ?? 'active',
|
||
|
|
pinned: input.pinned ?? false,
|
||
|
|
preview: input.preview,
|
||
|
|
refreshStatus: 'idle',
|
||
|
|
createdAt: nowIso,
|
||
|
|
updatedAt: nowIso,
|
||
|
|
document: input.document,
|
||
|
|
};
|
||
|
|
if (input.sessionId !== undefined) artifactBase.sessionId = input.sessionId;
|
||
|
|
if (options.createdByRunId !== undefined) artifactBase.createdByRunId = options.createdByRunId;
|
||
|
|
const persisted = validatePersistedLiveArtifact(artifactBase);
|
||
|
|
if (!persisted.ok) throw new LiveArtifactStoreValidationError(persisted.error, persisted.issues);
|
||
|
|
|
||
|
|
await ensureProject(options.projectsRoot, options.projectId);
|
||
|
|
const finalPaths = liveArtifactStorePaths(options.projectsRoot, options.projectId, artifactId);
|
||
|
|
await mkdir(finalPaths.rootDir, { recursive: true });
|
||
|
|
|
||
|
|
const tempArtifactId = validateLiveArtifactStorageId(`tmp-${randomBytes(12).toString('hex')}`);
|
||
|
|
const tempPaths = liveArtifactStorePaths(options.projectsRoot, options.projectId, tempArtifactId);
|
||
|
|
const templateHtml = options.templateHtml ?? defaultTemplateHtml(input.title);
|
||
|
|
const provenanceJson = options.provenanceJson ?? defaultProvenance(nowIso);
|
||
|
|
|
||
|
|
await rm(tempPaths.artifactDir, { recursive: true, force: true });
|
||
|
|
await mkdir(tempPaths.artifactDir, { recursive: false });
|
||
|
|
|
||
|
|
try {
|
||
|
|
const writtenArtifact = await writeLiveArtifactFiles(tempPaths, persisted.value, templateHtml, provenanceJson);
|
||
|
|
await rename(tempPaths.artifactDir, finalPaths.artifactDir);
|
||
|
|
return { artifact: writtenArtifact, paths: finalPaths };
|
||
|
|
} catch (error) {
|
||
|
|
await rm(tempPaths.artifactDir, { recursive: true, force: true });
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function listLiveArtifacts(options: ListLiveArtifactsOptions): Promise<LiveArtifactSummary[]> {
|
||
|
|
const rootDir = liveArtifactsRootDir(options.projectsRoot, options.projectId);
|
||
|
|
let entries: Dirent[];
|
||
|
|
try {
|
||
|
|
entries = await readdir(rootDir, { withFileTypes: true });
|
||
|
|
} catch (error) {
|
||
|
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return [];
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
|
||
|
|
const summaries: LiveArtifactSummary[] = [];
|
||
|
|
for (const entry of entries) {
|
||
|
|
if (!entry.isDirectory() || entry.name.startsWith('tmp-')) continue;
|
||
|
|
|
||
|
|
const artifactId = validateLiveArtifactStorageId(entry.name);
|
||
|
|
const paths = liveArtifactStorePaths(options.projectsRoot, options.projectId, artifactId);
|
||
|
|
const artifact = await readPersistedLiveArtifact(paths);
|
||
|
|
assertArtifactMatchesStorage(artifact, options.projectId, artifactId);
|
||
|
|
|
||
|
|
summaries.push(toSummary(artifact));
|
||
|
|
}
|
||
|
|
|
||
|
|
summaries.sort((a, b) => {
|
||
|
|
const updatedDelta = Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
||
|
|
if (updatedDelta !== 0) return updatedDelta;
|
||
|
|
return a.id.localeCompare(b.id);
|
||
|
|
});
|
||
|
|
return summaries;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function getLiveArtifact(options: GetLiveArtifactOptions): Promise<LiveArtifactStoreRecord> {
|
||
|
|
const artifactId = validateLiveArtifactStorageId(options.artifactId);
|
||
|
|
const paths = liveArtifactStorePaths(options.projectsRoot, options.projectId, artifactId);
|
||
|
|
const artifact = await readLiveArtifactWithDataJsonCache(paths);
|
||
|
|
assertArtifactMatchesStorage(artifact, options.projectId, artifactId);
|
||
|
|
return { artifact, paths };
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function appendLiveArtifactRefreshLogEntry(
|
||
|
|
options: AppendLiveArtifactRefreshLogEntryOptions,
|
||
|
|
): Promise<LiveArtifactRefreshLogEntry> {
|
||
|
|
const artifactId = validateLiveArtifactStorageId(options.artifactId);
|
||
|
|
const paths = liveArtifactStorePaths(options.projectsRoot, options.projectId, artifactId);
|
||
|
|
const current = await readPersistedLiveArtifact(paths);
|
||
|
|
assertArtifactMatchesStorage(current, options.projectId, artifactId);
|
||
|
|
|
||
|
|
const normalized = normalizeRefreshLogEntry({ ...options, artifactId });
|
||
|
|
const result = validateLiveArtifactRefreshLogEntry(normalized);
|
||
|
|
if (!result.ok) throw new LiveArtifactStoreValidationError(result.error, result.issues);
|
||
|
|
|
||
|
|
await appendFile(paths.refreshesJsonlPath, `${JSON.stringify(result.value)}\n`, 'utf8');
|
||
|
|
return result.value;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function acquireLiveArtifactRefreshLock(
|
||
|
|
options: AcquireLiveArtifactRefreshLockOptions,
|
||
|
|
): Promise<LiveArtifactRefreshLock> {
|
||
|
|
const artifactId = validateLiveArtifactStorageId(options.artifactId);
|
||
|
|
const paths = await assertLiveArtifactRefreshLockScope(options.projectsRoot, options.projectId, artifactId);
|
||
|
|
const state = await readLiveArtifactRefreshState(paths, options.projectId, artifactId);
|
||
|
|
const refreshOrdinal = state.nextRefreshOrdinal;
|
||
|
|
const refreshId = formatRefreshId(refreshOrdinal);
|
||
|
|
const metadata: LiveArtifactRefreshLockMetadata = {
|
||
|
|
schemaVersion: 1,
|
||
|
|
projectId: options.projectId,
|
||
|
|
artifactId,
|
||
|
|
refreshId,
|
||
|
|
refreshOrdinal,
|
||
|
|
acquiredAt: (options.now ?? new Date()).toISOString(),
|
||
|
|
lockId: randomBytes(12).toString('hex'),
|
||
|
|
};
|
||
|
|
|
||
|
|
try {
|
||
|
|
await writeFile(paths.refreshLockPath, stableJson(metadata), { encoding: 'utf8', flag: 'wx' });
|
||
|
|
} catch (error) {
|
||
|
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
|
||
|
|
throw new LiveArtifactRefreshLockError('live artifact refresh already active', {
|
||
|
|
projectId: options.projectId,
|
||
|
|
artifactId,
|
||
|
|
lockPath: paths.refreshLockPath,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
await writeLiveArtifactRefreshState(paths, {
|
||
|
|
...state,
|
||
|
|
nextRefreshOrdinal: refreshOrdinal + 1,
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
await rm(paths.refreshLockPath, { force: true });
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
|
||
|
|
return { artifactId, lockPath: paths.refreshLockPath, metadata };
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function markLiveArtifactRefreshCommitted(
|
||
|
|
options: MarkLiveArtifactRefreshCommittedOptions,
|
||
|
|
): Promise<LiveArtifactRefreshState> {
|
||
|
|
const artifactId = validateLiveArtifactStorageId(options.artifactId);
|
||
|
|
const paths = await assertLiveArtifactRefreshLockScope(options.projectsRoot, options.projectId, artifactId);
|
||
|
|
const refreshOrdinal = parseRefreshOrdinal(options.refreshId);
|
||
|
|
const state = await readLiveArtifactRefreshState(paths, options.projectId, artifactId);
|
||
|
|
if (refreshOrdinal >= state.nextRefreshOrdinal) {
|
||
|
|
throw validationError('refreshId', 'live artifact refresh id has not been allocated');
|
||
|
|
}
|
||
|
|
if ((state.lastCommittedRefreshOrdinal ?? 0) >= refreshOrdinal) {
|
||
|
|
const staleOptions: { projectId: string; artifactId: string; refreshId: string; lastCommittedRefreshId?: string } = {
|
||
|
|
projectId: options.projectId,
|
||
|
|
artifactId,
|
||
|
|
refreshId: options.refreshId,
|
||
|
|
};
|
||
|
|
if (state.lastCommittedRefreshId !== undefined) staleOptions.lastCommittedRefreshId = state.lastCommittedRefreshId;
|
||
|
|
throw new LiveArtifactStaleRefreshError('live artifact refresh is older than the latest committed refresh', staleOptions);
|
||
|
|
}
|
||
|
|
|
||
|
|
const nextState: LiveArtifactRefreshState = {
|
||
|
|
...state,
|
||
|
|
nextRefreshOrdinal: Math.max(state.nextRefreshOrdinal, refreshOrdinal + 1),
|
||
|
|
lastCommittedRefreshId: options.refreshId,
|
||
|
|
lastCommittedRefreshOrdinal: refreshOrdinal,
|
||
|
|
};
|
||
|
|
await writeLiveArtifactRefreshState(paths, nextState);
|
||
|
|
return nextState;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function markLiveArtifactRefreshRunning(
|
||
|
|
options: MarkLiveArtifactRefreshRunningOptions,
|
||
|
|
): Promise<LiveArtifactStoreRecord> {
|
||
|
|
const artifactId = validateLiveArtifactStorageId(options.artifactId);
|
||
|
|
const paths = await assertLiveArtifactRefreshLockScope(options.projectsRoot, options.projectId, artifactId);
|
||
|
|
const current = await readPersistedLiveArtifact(paths);
|
||
|
|
assertArtifactMatchesStorage(current, options.projectId, artifactId);
|
||
|
|
const nowIso = (options.now ?? new Date()).toISOString();
|
||
|
|
const artifact = await writePersistedLiveArtifact(paths, {
|
||
|
|
...current,
|
||
|
|
refreshStatus: 'running',
|
||
|
|
updatedAt: nowIso,
|
||
|
|
});
|
||
|
|
return { artifact, paths };
|
||
|
|
}
|
||
|
|
|
||
|
|
function assertLiveArtifactRefreshCanCommit(
|
||
|
|
state: LiveArtifactRefreshState,
|
||
|
|
options: MarkLiveArtifactRefreshCommittedOptions,
|
||
|
|
): number {
|
||
|
|
const refreshOrdinal = parseRefreshOrdinal(options.refreshId);
|
||
|
|
if (refreshOrdinal >= state.nextRefreshOrdinal) {
|
||
|
|
throw validationError('refreshId', 'live artifact refresh id has not been allocated');
|
||
|
|
}
|
||
|
|
if ((state.lastCommittedRefreshOrdinal ?? 0) >= refreshOrdinal) {
|
||
|
|
const staleOptions: { projectId: string; artifactId: string; refreshId: string; lastCommittedRefreshId?: string } = {
|
||
|
|
projectId: options.projectId,
|
||
|
|
artifactId: options.artifactId,
|
||
|
|
refreshId: options.refreshId,
|
||
|
|
};
|
||
|
|
if (state.lastCommittedRefreshId !== undefined) staleOptions.lastCommittedRefreshId = state.lastCommittedRefreshId;
|
||
|
|
throw new LiveArtifactStaleRefreshError('live artifact refresh is older than the latest committed refresh', staleOptions);
|
||
|
|
}
|
||
|
|
return refreshOrdinal;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function writeLiveArtifactSuccessfulSnapshot(
|
||
|
|
paths: LiveArtifactStorePaths,
|
||
|
|
options: {
|
||
|
|
refreshId: string;
|
||
|
|
artifact: LiveArtifact;
|
||
|
|
dataJson: BoundedJsonObject;
|
||
|
|
templateHtml: string;
|
||
|
|
previewHtml: string;
|
||
|
|
provenanceJson: LiveArtifactProvenance;
|
||
|
|
},
|
||
|
|
): Promise<void> {
|
||
|
|
parseRefreshOrdinal(options.refreshId);
|
||
|
|
await mkdir(paths.snapshotsDir, { recursive: true });
|
||
|
|
|
||
|
|
// MVP decision: failed refresh payloads are not retained on disk. Failed attempts
|
||
|
|
// are summarized in refreshes.jsonl only; snapshots/<refreshId>/ is reserved for
|
||
|
|
// validated successful commits that are safe to use for history/rollback views.
|
||
|
|
const finalSnapshotDir = resolveInside(paths.snapshotsDir, options.refreshId, 'live artifact snapshot path escapes snapshots dir');
|
||
|
|
const tempSnapshotDir = resolveInside(paths.snapshotsDir, `.tmp-${options.refreshId}-${randomBytes(6).toString('hex')}`, 'live artifact snapshot path escapes snapshots dir');
|
||
|
|
|
||
|
|
await rm(tempSnapshotDir, { recursive: true, force: true });
|
||
|
|
await mkdir(tempSnapshotDir, { recursive: true });
|
||
|
|
try {
|
||
|
|
await Promise.all([
|
||
|
|
writeFile(resolveInside(tempSnapshotDir, LIVE_ARTIFACT_ARTIFACT_FILE, 'live artifact snapshot path escapes snapshot dir'), stableJson(options.artifact), 'utf8'),
|
||
|
|
writeFile(resolveInside(tempSnapshotDir, LIVE_ARTIFACT_DATA_FILE, 'live artifact snapshot path escapes snapshot dir'), stableJson(options.dataJson), 'utf8'),
|
||
|
|
writeFile(resolveInside(tempSnapshotDir, LIVE_ARTIFACT_TEMPLATE_FILE, 'live artifact snapshot path escapes snapshot dir'), options.templateHtml, 'utf8'),
|
||
|
|
writeFile(resolveInside(tempSnapshotDir, LIVE_ARTIFACT_PREVIEW_FILE, 'live artifact snapshot path escapes snapshot dir'), options.previewHtml, 'utf8'),
|
||
|
|
writeFile(resolveInside(tempSnapshotDir, LIVE_ARTIFACT_PROVENANCE_FILE, 'live artifact snapshot path escapes snapshot dir'), stableJson(options.provenanceJson), 'utf8'),
|
||
|
|
]);
|
||
|
|
await rename(tempSnapshotDir, finalSnapshotDir);
|
||
|
|
} catch (error) {
|
||
|
|
await rm(tempSnapshotDir, { recursive: true, force: true });
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function commitLiveArtifactRefreshCandidate(
|
||
|
|
options: CommitLiveArtifactRefreshCandidateOptions,
|
||
|
|
): Promise<LiveArtifactStoreRecord> {
|
||
|
|
const artifactId = validateLiveArtifactStorageId(options.artifactId);
|
||
|
|
const paths = await assertLiveArtifactRefreshLockScope(options.projectsRoot, options.projectId, artifactId);
|
||
|
|
const current = await readLiveArtifactWithDataJsonCache(paths);
|
||
|
|
assertArtifactMatchesStorage(current, options.projectId, artifactId);
|
||
|
|
|
||
|
|
const state = await readLiveArtifactRefreshState(paths, options.projectId, artifactId);
|
||
|
|
const refreshOrdinal = assertLiveArtifactRefreshCanCommit(state, { ...options, artifactId });
|
||
|
|
const nowIso = (options.now ?? new Date()).toISOString();
|
||
|
|
const candidateData = validateBoundedJsonObject(options.dataJson, 'data.json');
|
||
|
|
if (!candidateData.ok) throw new LiveArtifactStoreValidationError(candidateData.error, candidateData.issues);
|
||
|
|
|
||
|
|
const candidateArtifact: LiveArtifact = artifactWithDataJson({
|
||
|
|
...current,
|
||
|
|
refreshStatus: 'succeeded',
|
||
|
|
updatedAt: nowIso,
|
||
|
|
lastRefreshedAt: nowIso,
|
||
|
|
}, candidateData.value);
|
||
|
|
const persisted = validatePersistedLiveArtifact(candidateArtifact);
|
||
|
|
if (!persisted.ok) throw new LiveArtifactStoreValidationError(persisted.error, persisted.issues);
|
||
|
|
|
||
|
|
const templateHtml = await readTextFileOrDefault(paths.templateHtmlPath, defaultTemplateHtml(persisted.value.title));
|
||
|
|
const provenanceJson = options.provenanceJson ?? await readProvenanceOrDefault(paths, nowIso);
|
||
|
|
const previewHtml = persisted.value.document?.format === 'html_template_v1'
|
||
|
|
? renderPreviewHtml(templateHtml, candidateData.value)
|
||
|
|
: templateHtml;
|
||
|
|
|
||
|
|
const nextState: LiveArtifactRefreshState = {
|
||
|
|
...state,
|
||
|
|
nextRefreshOrdinal: Math.max(state.nextRefreshOrdinal, refreshOrdinal + 1),
|
||
|
|
lastCommittedRefreshId: options.refreshId,
|
||
|
|
lastCommittedRefreshOrdinal: refreshOrdinal,
|
||
|
|
};
|
||
|
|
|
||
|
|
await writeLiveArtifactSuccessfulSnapshot(paths, {
|
||
|
|
refreshId: options.refreshId,
|
||
|
|
artifact: persisted.value,
|
||
|
|
dataJson: candidateData.value,
|
||
|
|
templateHtml,
|
||
|
|
previewHtml,
|
||
|
|
provenanceJson,
|
||
|
|
});
|
||
|
|
await Promise.all([
|
||
|
|
writeFileAtomic(paths.artifactJsonPath, stableJson(persisted.value)),
|
||
|
|
writeFileAtomic(paths.dataJsonPath, stableJson(candidateData.value)),
|
||
|
|
writeFileAtomic(paths.generatedPreviewHtmlPath, previewHtml),
|
||
|
|
writeFileAtomic(paths.provenanceJsonPath, stableJson(provenanceJson)),
|
||
|
|
]);
|
||
|
|
await writeLiveArtifactRefreshState(paths, nextState);
|
||
|
|
return { artifact: persisted.value, paths };
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function markLiveArtifactRefreshFailed(
|
||
|
|
options: MarkLiveArtifactRefreshFailedOptions,
|
||
|
|
): Promise<LiveArtifactStoreRecord> {
|
||
|
|
const artifactId = validateLiveArtifactStorageId(options.artifactId);
|
||
|
|
const paths = await assertLiveArtifactRefreshLockScope(options.projectsRoot, options.projectId, artifactId);
|
||
|
|
const current = await readPersistedLiveArtifact(paths);
|
||
|
|
assertArtifactMatchesStorage(current, options.projectId, artifactId);
|
||
|
|
const nowIso = (options.now ?? new Date()).toISOString();
|
||
|
|
const artifact = await writePersistedLiveArtifact(paths, {
|
||
|
|
...current,
|
||
|
|
refreshStatus: 'failed',
|
||
|
|
updatedAt: nowIso,
|
||
|
|
});
|
||
|
|
return { artifact, paths };
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function releaseLiveArtifactRefreshLock(lock: LiveArtifactRefreshLock): Promise<void> {
|
||
|
|
let current: LiveArtifactRefreshLockMetadata;
|
||
|
|
try {
|
||
|
|
current = JSON.parse(await readFile(lock.lockPath, 'utf8')) as LiveArtifactRefreshLockMetadata;
|
||
|
|
} catch (error) {
|
||
|
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return;
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (
|
||
|
|
current.projectId !== lock.metadata.projectId
|
||
|
|
|| current.artifactId !== lock.metadata.artifactId
|
||
|
|
|| current.lockId !== lock.metadata.lockId
|
||
|
|
) {
|
||
|
|
throw new LiveArtifactRefreshLockError('live artifact refresh lock ownership mismatch', {
|
||
|
|
projectId: lock.metadata.projectId,
|
||
|
|
artifactId: lock.metadata.artifactId,
|
||
|
|
lockPath: lock.lockPath,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
await rm(lock.lockPath, { force: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function withLiveArtifactRefreshLock<T>(
|
||
|
|
options: AcquireLiveArtifactRefreshLockOptions,
|
||
|
|
callback: (lock: LiveArtifactRefreshLock) => Promise<T>,
|
||
|
|
): Promise<T> {
|
||
|
|
const lock = await acquireLiveArtifactRefreshLock(options);
|
||
|
|
try {
|
||
|
|
return await callback(lock);
|
||
|
|
} finally {
|
||
|
|
await releaseLiveArtifactRefreshLock(lock);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function listLiveArtifactRefreshLogEntries(
|
||
|
|
options: ListLiveArtifactRefreshLogEntriesOptions,
|
||
|
|
): Promise<LiveArtifactRefreshLogEntry[]> {
|
||
|
|
const artifactId = validateLiveArtifactStorageId(options.artifactId);
|
||
|
|
const paths = liveArtifactStorePaths(options.projectsRoot, options.projectId, artifactId);
|
||
|
|
const current = await readPersistedLiveArtifact(paths);
|
||
|
|
assertArtifactMatchesStorage(current, options.projectId, artifactId);
|
||
|
|
|
||
|
|
const text = await readTextFileOrDefault(paths.refreshesJsonlPath, '');
|
||
|
|
const entries: LiveArtifactRefreshLogEntry[] = [];
|
||
|
|
for (const [index, line] of text.split('\n').entries()) {
|
||
|
|
if (line.trim().length === 0) continue;
|
||
|
|
let parsed: unknown;
|
||
|
|
try {
|
||
|
|
parsed = JSON.parse(line);
|
||
|
|
} catch (error) {
|
||
|
|
if (error instanceof SyntaxError) {
|
||
|
|
throw validationError(`refreshes.jsonl.${index + 1}`, 'live artifact refresh log contains invalid JSON');
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
const result = validateLiveArtifactRefreshLogEntry(parsed, `refreshes.jsonl.${index + 1}`);
|
||
|
|
if (!result.ok) throw new LiveArtifactStoreValidationError(result.error, result.issues);
|
||
|
|
if (result.value.projectId !== options.projectId || result.value.artifactId !== artifactId) {
|
||
|
|
throw validationError(`refreshes.jsonl.${index + 1}`, 'live artifact refresh log entry does not match storage scope');
|
||
|
|
}
|
||
|
|
entries.push(result.value);
|
||
|
|
}
|
||
|
|
return entries;
|
||
|
|
}
|
||
|
|
|
||
|
|
function nextRefreshRecoverySequence(entries: LiveArtifactRefreshLogEntry[], refreshId: string): number {
|
||
|
|
let maxSequence = -1;
|
||
|
|
for (const entry of entries) {
|
||
|
|
if (entry.refreshId === refreshId) maxSequence = Math.max(maxSequence, entry.sequence);
|
||
|
|
}
|
||
|
|
return maxSequence + 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function recoverLiveArtifactRefreshLock(
|
||
|
|
projectsRoot: string,
|
||
|
|
projectId: string,
|
||
|
|
artifactId: string,
|
||
|
|
now: Date,
|
||
|
|
staleAfterMs: number,
|
||
|
|
): Promise<LiveArtifactRefreshRecoveryResult> {
|
||
|
|
const paths = liveArtifactStorePaths(projectsRoot, projectId, artifactId);
|
||
|
|
const lockMetadata = await readLiveArtifactRefreshLockMetadata(paths);
|
||
|
|
|
||
|
|
if (lockMetadata.projectId !== projectId || lockMetadata.artifactId !== artifactId) {
|
||
|
|
return { projectId, artifactId, refreshId: lockMetadata.refreshId, status: 'skipped', reason: 'lock scope mismatch' };
|
||
|
|
}
|
||
|
|
|
||
|
|
const acquiredAtMs = Date.parse(lockMetadata.acquiredAt);
|
||
|
|
const ageMs = now.getTime() - acquiredAtMs;
|
||
|
|
if (ageMs < staleAfterMs) {
|
||
|
|
return { projectId, artifactId, refreshId: lockMetadata.refreshId, status: 'skipped', reason: 'lock has not timed out' };
|
||
|
|
}
|
||
|
|
|
||
|
|
const artifact = await readPersistedLiveArtifact(paths);
|
||
|
|
assertArtifactMatchesStorage(artifact, projectId, artifactId);
|
||
|
|
const entries = await listLiveArtifactRefreshLogEntries({ projectsRoot, projectId, artifactId });
|
||
|
|
const finishedAt = now.toISOString();
|
||
|
|
await appendLiveArtifactRefreshLogEntry({
|
||
|
|
projectsRoot,
|
||
|
|
projectId,
|
||
|
|
artifactId,
|
||
|
|
refreshId: lockMetadata.refreshId,
|
||
|
|
sequence: nextRefreshRecoverySequence(entries, lockMetadata.refreshId),
|
||
|
|
step: 'refresh:crash_recovery',
|
||
|
|
status: 'failed',
|
||
|
|
startedAt: lockMetadata.acquiredAt,
|
||
|
|
finishedAt,
|
||
|
|
durationMs: Math.max(0, now.getTime() - acquiredAtMs),
|
||
|
|
error: {
|
||
|
|
code: 'REFRESH_CRASH_RECOVERY_TIMEOUT',
|
||
|
|
message: 'Refresh was still running when the daemon started and exceeded the total refresh timeout.',
|
||
|
|
},
|
||
|
|
metadata: { staleAfterMs },
|
||
|
|
now,
|
||
|
|
});
|
||
|
|
|
||
|
|
await writePersistedLiveArtifact(paths, {
|
||
|
|
...artifact,
|
||
|
|
refreshStatus: 'failed',
|
||
|
|
updatedAt: finishedAt,
|
||
|
|
});
|
||
|
|
await rm(paths.refreshLockPath, { force: true });
|
||
|
|
|
||
|
|
return { projectId, artifactId, refreshId: lockMetadata.refreshId, status: 'recovered' };
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function recoverStaleLiveArtifactRefreshes(
|
||
|
|
options: RecoverStaleLiveArtifactRefreshesOptions,
|
||
|
|
): Promise<LiveArtifactRefreshRecoveryResult[]> {
|
||
|
|
const now = options.now ?? new Date();
|
||
|
|
const staleAfterMs = options.staleAfterMs ?? DEFAULT_LIVE_ARTIFACT_TOTAL_TIMEOUT_MS;
|
||
|
|
if (!Number.isSafeInteger(staleAfterMs) || staleAfterMs < 1) {
|
||
|
|
throw new RangeError('staleAfterMs must be a positive safe integer');
|
||
|
|
}
|
||
|
|
|
||
|
|
let projectEntries: Dirent[];
|
||
|
|
try {
|
||
|
|
projectEntries = await readdir(options.projectsRoot, { withFileTypes: true });
|
||
|
|
} catch (error) {
|
||
|
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return [];
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
|
||
|
|
const results: LiveArtifactRefreshRecoveryResult[] = [];
|
||
|
|
for (const projectEntry of projectEntries) {
|
||
|
|
if (!projectEntry.isDirectory()) continue;
|
||
|
|
const projectId = projectEntry.name;
|
||
|
|
let artifactEntries: Dirent[];
|
||
|
|
try {
|
||
|
|
artifactEntries = await readdir(liveArtifactsRootDir(options.projectsRoot, projectId), { withFileTypes: true });
|
||
|
|
} catch (error) {
|
||
|
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') continue;
|
||
|
|
results.push({ projectId, artifactId: '', refreshId: '', status: 'skipped', reason: error instanceof Error ? error.message : String(error) });
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const artifactEntry of artifactEntries) {
|
||
|
|
if (!artifactEntry.isDirectory() || artifactEntry.name.startsWith('tmp-')) continue;
|
||
|
|
let artifactId: string;
|
||
|
|
try {
|
||
|
|
artifactId = validateLiveArtifactStorageId(artifactEntry.name);
|
||
|
|
} catch {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const paths = liveArtifactStorePaths(options.projectsRoot, projectId, artifactId);
|
||
|
|
try {
|
||
|
|
await stat(paths.refreshLockPath);
|
||
|
|
} catch (error) {
|
||
|
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') continue;
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
results.push(await recoverLiveArtifactRefreshLock(options.projectsRoot, projectId, artifactId, now, staleAfterMs));
|
||
|
|
} catch (error) {
|
||
|
|
results.push({
|
||
|
|
projectId,
|
||
|
|
artifactId,
|
||
|
|
refreshId: '',
|
||
|
|
status: 'skipped',
|
||
|
|
reason: error instanceof Error ? error.message : String(error),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function regenerateLiveArtifactPreview(options: RegenerateLiveArtifactPreviewOptions): Promise<LiveArtifactPreviewRenderRecord> {
|
||
|
|
const artifactId = validateLiveArtifactStorageId(options.artifactId);
|
||
|
|
const paths = liveArtifactStorePaths(options.projectsRoot, options.projectId, artifactId);
|
||
|
|
const artifact = await readLiveArtifactWithDataJsonCache(paths);
|
||
|
|
assertArtifactMatchesStorage(artifact, options.projectId, artifactId);
|
||
|
|
|
||
|
|
const html = await renderLiveArtifactPreviewFromFiles(paths, artifact);
|
||
|
|
await writeFile(paths.generatedPreviewHtmlPath, html, 'utf8');
|
||
|
|
|
||
|
|
return { artifact, paths, html };
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function ensureLiveArtifactPreview(options: RegenerateLiveArtifactPreviewOptions): Promise<LiveArtifactPreviewRenderRecord> {
|
||
|
|
const artifactId = validateLiveArtifactStorageId(options.artifactId);
|
||
|
|
const paths = liveArtifactStorePaths(options.projectsRoot, options.projectId, artifactId);
|
||
|
|
const artifact = await readLiveArtifactWithDataJsonCache(paths);
|
||
|
|
assertArtifactMatchesStorage(artifact, options.projectId, artifactId);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const dependencyStats = await Promise.all([
|
||
|
|
stat(paths.artifactJsonPath),
|
||
|
|
stat(paths.templateHtmlPath),
|
||
|
|
...(artifact.document?.format === 'html_template_v1' ? [stat(paths.dataJsonPath)] : []),
|
||
|
|
]);
|
||
|
|
const previewStat = await stat(paths.generatedPreviewHtmlPath);
|
||
|
|
const newestDependencyMtime = Math.max(...dependencyStats.map((dependencyStat) => dependencyStat.mtimeMs));
|
||
|
|
if (previewStat.mtimeMs > newestDependencyMtime) {
|
||
|
|
return { artifact, paths, html: await readFile(paths.generatedPreviewHtmlPath, 'utf8') };
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')) throw error;
|
||
|
|
}
|
||
|
|
|
||
|
|
const html = await renderLiveArtifactPreviewFromFiles(paths, artifact);
|
||
|
|
await writeFile(paths.generatedPreviewHtmlPath, html, 'utf8');
|
||
|
|
return { artifact, paths, html };
|
||
|
|
}
|
||
|
|
|
||
|
|
export type LiveArtifactCodeVariant = 'template' | 'rendered';
|
||
|
|
|
||
|
|
export async function readLiveArtifactCode(options: RegenerateLiveArtifactPreviewOptions & { variant: LiveArtifactCodeVariant }): Promise<string> {
|
||
|
|
if (options.variant === 'rendered') {
|
||
|
|
return (await ensureLiveArtifactPreview(options)).html;
|
||
|
|
}
|
||
|
|
|
||
|
|
const artifactId = validateLiveArtifactStorageId(options.artifactId);
|
||
|
|
const paths = liveArtifactStorePaths(options.projectsRoot, options.projectId, artifactId);
|
||
|
|
const artifact = await readLiveArtifactWithDataJsonCache(paths);
|
||
|
|
assertArtifactMatchesStorage(artifact, options.projectId, artifactId);
|
||
|
|
return readFile(paths.templateHtmlPath, 'utf8');
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function updateLiveArtifact(options: UpdateLiveArtifactOptions): Promise<LiveArtifactStoreRecord> {
|
||
|
|
const artifactId = validateLiveArtifactStorageId(options.artifactId);
|
||
|
|
const result = validateLiveArtifactUpdateInput(options.input);
|
||
|
|
if (!result.ok) throw new LiveArtifactStoreValidationError(result.error, result.issues);
|
||
|
|
|
||
|
|
const input: LiveArtifactUpdateInput = result.value;
|
||
|
|
const paths = liveArtifactStorePaths(options.projectsRoot, options.projectId, artifactId);
|
||
|
|
const current = await readPersistedLiveArtifact(paths);
|
||
|
|
assertArtifactMatchesStorage(current, options.projectId, artifactId);
|
||
|
|
|
||
|
|
const nowIso = (options.now ?? new Date()).toISOString();
|
||
|
|
const updated: LiveArtifact = {
|
||
|
|
...current,
|
||
|
|
title: input.title ?? current.title,
|
||
|
|
slug: input.slug === undefined ? current.slug : generateLiveArtifactSlug(input.slug),
|
||
|
|
pinned: input.pinned ?? current.pinned,
|
||
|
|
status: input.status ?? current.status,
|
||
|
|
preview: input.preview ?? current.preview,
|
||
|
|
updatedAt: nowIso,
|
||
|
|
};
|
||
|
|
if (input.document !== undefined) updated.document = input.document;
|
||
|
|
|
||
|
|
const persisted = validatePersistedLiveArtifact(updated);
|
||
|
|
if (!persisted.ok) throw new LiveArtifactStoreValidationError(persisted.error, persisted.issues);
|
||
|
|
|
||
|
|
const templateHtml = options.templateHtml ?? await readTextFileOrDefault(paths.templateHtmlPath, defaultTemplateHtml(persisted.value.title));
|
||
|
|
const provenanceJson = options.provenanceJson ?? await readProvenanceOrDefault(paths, nowIso);
|
||
|
|
const dataJson = input.document === undefined && persisted.value.document?.format === 'html_template_v1'
|
||
|
|
? await readPersistedDataJson(paths)
|
||
|
|
: persisted.value.document?.dataJson;
|
||
|
|
|
||
|
|
const writtenArtifact = await writeLiveArtifactFiles(paths, persisted.value, templateHtml, provenanceJson, dataJson);
|
||
|
|
|
||
|
|
return { artifact: writtenArtifact, paths };
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function deleteLiveArtifact(options: DeleteLiveArtifactOptions): Promise<void> {
|
||
|
|
const artifactId = validateLiveArtifactStorageId(options.artifactId);
|
||
|
|
const paths = liveArtifactStorePaths(options.projectsRoot, options.projectId, artifactId);
|
||
|
|
const current = await readPersistedLiveArtifact(paths);
|
||
|
|
assertArtifactMatchesStorage(current, options.projectId, artifactId);
|
||
|
|
await rm(paths.artifactDir, { recursive: true, force: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
export function summarizeLiveArtifactRecord(record: LiveArtifactStoreRecord): LiveArtifactStoreSummary {
|
||
|
|
return { artifact: toSummary(record.artifact), paths: record.paths };
|
||
|
|
}
|