Files
makelore/electron/services/project-progress-sync.ts
2026-07-29 17:22:35 +08:00

416 lines
14 KiB
TypeScript

import { createHash } from 'node:crypto';
import { watch, type FSWatcher } from 'node:fs';
import { access, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { OpencodeProject, OpencodeProjectStore } from '../opencode/project-store';
import { readWorksPublishFile } from '../opencode/works-publish-file';
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
import { proxyAwareFetch } from '../utils/proxy-fetch';
import { logger } from '../utils/logger';
import {
LEGACY_PROMOTION_PLAN_FILENAME,
PRODUCT_OVERVIEW_FILENAME,
} from '../../shared/project-config';
import {
getValidWorksSquareAccessToken,
subscribeWorksSquareSession,
} from './works-square-session';
export const PROJECT_PROGRESS_DOCUMENT_NAMES = ['GDD.md', 'TASKS.md'] as const;
export const PROJECT_PROGRESS_DEBOUNCE_MS = 1_000;
const PROJECT_PROGRESS_ENDPOINT = '/api/project-progress';
const PROJECT_AGENT_PROMPT_ENDPOINT_PREFIX = '/api/projects';
const PROJECT_PROGRESS_MAX_CHARS = 20_000;
const WORKS_PUBLISH_FILE_NAME = 'works-publish.json';
type ProjectProgressDocumentName = typeof PROJECT_PROGRESS_DOCUMENT_NAMES[number];
type ProjectProgressType = 'server' | 'local';
type ProjectProgressIdentity = { projectType: ProjectProgressType; projectKey: string };
export type ProjectProgressPayload = {
project_type: ProjectProgressType;
project_key: string;
progress_text: string;
};
type ProjectProgressWatcher = Pick<FSWatcher, 'close'>;
type WatchDirectory = (
projectPath: string,
onChange: (filename: string | null) => void,
) => ProjectProgressWatcher;
type ProjectProgressProjectStore = Pick<OpencodeProjectStore, 'listProjects' | 'subscribe'>;
export type ProjectProgressSyncOptions = {
apiBaseUrl?: string;
debounceMs?: number;
fetchImpl?: (input: string | URL, init?: RequestInit) => Promise<Response>;
getAccessToken?: () => Promise<string | null>;
watchDirectory?: WatchDirectory;
};
type ProjectProgressState = {
project: OpencodeProject;
watcher: ProjectProgressWatcher | null;
timer: ReturnType<typeof setTimeout> | null;
pendingProgress: boolean;
pendingPromotion: boolean;
syncing: boolean;
flushPromise: Promise<void> | null;
lastSyncedFingerprint: string | null;
lastSyncedPromotionFingerprint: string | null;
};
function normalizeApiBaseUrl(value: string): string {
const baseUrl = value.trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(baseUrl)) {
throw new Error('Project progress API base URL must start with http:// or https://');
}
return baseUrl;
}
function defaultWatchDirectory(projectPath: string, onChange: (filename: string | null) => void): ProjectProgressWatcher {
return watch(projectPath, { persistent: false }, (_event, filename) => {
if (typeof filename === 'string') {
onChange(filename);
return;
}
onChange(filename ? filename.toString() : null);
});
}
function normalizeWatchedFilename(filename: string | null): string | null {
return filename ? filename.replaceAll('\\', '/') : null;
}
function isSyncRelevantFile(filename: string | null): boolean {
const normalized = normalizeWatchedFilename(filename);
if (!normalized) return true;
return PROJECT_PROGRESS_DOCUMENT_NAMES.includes(normalized as ProjectProgressDocumentName)
|| normalized === PRODUCT_OVERVIEW_FILENAME
|| normalized === LEGACY_PROMOTION_PLAN_FILENAME
|| normalized === WORKS_PUBLISH_FILE_NAME;
}
export function mergeProjectProgressDocuments(gdd: string, tasks: string): string {
return [
'===== GDD.md =====',
gdd,
'===== TASKS.md =====',
tasks,
].join('\n');
}
function fingerprint(value: string): string {
return createHash('sha256').update(value, 'utf8').digest('hex');
}
async function readOptionalDocument(
projectPath: string,
fileName: ProjectProgressDocumentName | typeof PRODUCT_OVERVIEW_FILENAME | typeof LEGACY_PROMOTION_PLAN_FILENAME,
): Promise<string> {
try {
return await readFile(join(projectPath, fileName), 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return '';
throw error;
}
}
async function readProjectProgressContent(projectPath: string): Promise<string> {
await access(projectPath);
const [gdd, tasks] = await Promise.all([
readOptionalDocument(projectPath, 'GDD.md'),
readOptionalDocument(projectPath, 'TASKS.md'),
]);
return mergeProjectProgressDocuments(gdd, tasks);
}
async function readProductOverview(projectPath: string): Promise<string> {
await access(projectPath);
try {
return await readFile(join(projectPath, PRODUCT_OVERVIEW_FILENAME), 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
return readOptionalDocument(projectPath, LEGACY_PROMOTION_PLAN_FILENAME);
}
}
async function resolveProjectProgressIdentity(
project: OpencodeProject,
): Promise<ProjectProgressIdentity> {
const publish = await readWorksPublishFile(project.path);
if (publish.status === 'ready' && publish.publish.app_id) {
return { projectType: 'server', projectKey: publish.publish.app_id };
}
return { projectType: 'local', projectKey: project.id };
}
export function createProjectProgressSync(
projectStore: ProjectProgressProjectStore,
options: ProjectProgressSyncOptions = {},
) {
const apiBaseUrl = normalizeApiBaseUrl(options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl);
const debounceMs = Math.max(0, options.debounceMs ?? PROJECT_PROGRESS_DEBOUNCE_MS);
const fetchImpl = options.fetchImpl ?? proxyAwareFetch;
const getAccessToken = options.getAccessToken ?? getValidWorksSquareAccessToken;
const watchDirectory = options.watchDirectory ?? defaultWatchDirectory;
const states = new Map<string, ProjectProgressState>();
let unsubscribeProjectStore: (() => void) | null = null;
let unsubscribeSession: (() => void) | null = null;
let started = false;
function clearTimer(state: ProjectProgressState): void {
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
}
function closeWatcher(state: ProjectProgressState): void {
clearTimer(state);
state.watcher?.close();
state.watcher = null;
}
function disposeProject(projectId: string): void {
const state = states.get(projectId);
if (!state) return;
closeWatcher(state);
states.delete(projectId);
}
function markPending(state: ProjectProgressState, filename: string | null): void {
const normalized = normalizeWatchedFilename(filename);
if (!normalized
|| PROJECT_PROGRESS_DOCUMENT_NAMES.includes(normalized as ProjectProgressDocumentName)) {
state.pendingProgress = true;
}
if (!normalized || normalized === PRODUCT_OVERVIEW_FILENAME || normalized === LEGACY_PROMOTION_PLAN_FILENAME) {
state.pendingPromotion = true;
}
if (normalized === WORKS_PUBLISH_FILE_NAME) {
state.pendingProgress = true;
state.pendingPromotion = true;
state.lastSyncedFingerprint = null;
state.lastSyncedPromotionFingerprint = null;
}
}
function scheduleProject(projectId: string, immediate = false, filename: string | null = null): void {
const state = states.get(projectId);
if (!state || !started) return;
markPending(state, filename);
clearTimer(state);
state.timer = setTimeout(() => {
state.timer = null;
void startFlush(state);
}, immediate ? 0 : debounceMs);
}
function attachProject(project: OpencodeProject): void {
const previous = states.get(project.id);
if (previous && previous.project.path === project.path) {
previous.project = project;
scheduleProject(project.id, true);
return;
}
if (previous) closeWatcher(previous);
const state: ProjectProgressState = {
project,
watcher: null,
timer: null,
pendingProgress: false,
pendingPromotion: false,
syncing: false,
flushPromise: null,
lastSyncedFingerprint: null,
lastSyncedPromotionFingerprint: null,
};
states.set(project.id, state);
try {
state.watcher = watchDirectory(project.path, (filename) => {
if (isSyncRelevantFile(filename)) scheduleProject(project.id, false, filename);
});
} catch (error) {
logger.warn(`[project-progress-sync] Failed to watch ${project.path}`, error);
}
scheduleProject(project.id, true);
}
async function pushProjectProgress(
identity: ProjectProgressIdentity,
progressText: string,
accessToken: string,
): Promise<void> {
if (progressText.length > PROJECT_PROGRESS_MAX_CHARS) {
throw new Error(`Merged project progress exceeds ${PROJECT_PROGRESS_MAX_CHARS} characters`);
}
const payload: ProjectProgressPayload = {
project_type: identity.projectType,
project_key: identity.projectKey,
progress_text: progressText,
};
const response = await fetchImpl(`${apiBaseUrl}${PROJECT_PROGRESS_ENDPOINT}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Project progress sync failed (${response.status})`);
}
}
async function pushProjectAgentPrompt(
appId: string,
prompt: string,
accessToken: string,
): Promise<void> {
if (prompt.length > PROJECT_PROGRESS_MAX_CHARS) {
throw new Error(`Project Agent prompt exceeds ${PROJECT_PROGRESS_MAX_CHARS} characters`);
}
const response = await fetchImpl(
`${apiBaseUrl}${PROJECT_AGENT_PROMPT_ENDPOINT_PREFIX}/${encodeURIComponent(appId)}/agent/prompt`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ prompt }),
},
);
if (!response.ok) {
throw new Error(`Project Agent prompt sync failed (${response.status})`);
}
}
async function flushProject(state: ProjectProgressState): Promise<void> {
if ((!state.pendingProgress && !state.pendingPromotion) || state.syncing || !started) return;
const progressRequested = state.pendingProgress;
const promotionRequested = state.pendingPromotion;
state.pendingProgress = false;
state.pendingPromotion = false;
state.syncing = true;
let progressCompleted = !progressRequested;
let promotionCompleted = !promotionRequested;
let accessToken: string | null | undefined;
const requireAccessToken = async (): Promise<string> => {
if (accessToken !== undefined) {
if (accessToken) return accessToken;
throw new Error('No Works Square access token available');
}
accessToken = await getAccessToken();
if (!accessToken) {
throw new Error('No Works Square access token available');
}
return accessToken;
};
try {
const identity = await resolveProjectProgressIdentity(state.project);
if (progressRequested) {
const progressText = await readProjectProgressContent(state.project.path);
const nextFingerprint = fingerprint(progressText);
if (nextFingerprint !== state.lastSyncedFingerprint) {
await pushProjectProgress(identity, progressText, await requireAccessToken());
state.lastSyncedFingerprint = nextFingerprint;
}
progressCompleted = true;
}
if (promotionRequested) {
const productOverview = await readProductOverview(state.project.path);
if (identity.projectType === 'server' && productOverview.trim()) {
const nextPromotionFingerprint = fingerprint(productOverview);
if (nextPromotionFingerprint !== state.lastSyncedPromotionFingerprint) {
await pushProjectAgentPrompt(
identity.projectKey,
productOverview,
await requireAccessToken(),
);
state.lastSyncedPromotionFingerprint = nextPromotionFingerprint;
}
}
promotionCompleted = true;
}
if (progressRequested || promotionRequested) {
logger.debug(`[project-progress-sync] Synced ${state.project.id}`);
}
} catch (error) {
if (!progressCompleted) state.pendingProgress = true;
if (!promotionCompleted) state.pendingPromotion = true;
logger.warn(`[project-progress-sync] Sync failed for ${state.project.id}`, error);
} finally {
state.syncing = false;
}
}
function startFlush(state: ProjectProgressState): Promise<void> {
if (state.flushPromise) return state.flushPromise;
const promise = flushProject(state).finally(() => {
if (state.flushPromise === promise) state.flushPromise = null;
});
state.flushPromise = promise;
return promise;
}
async function start(): Promise<void> {
if (started) return;
started = true;
unsubscribeProjectStore = projectStore.subscribe((change) => {
if (change.type === 'remove') {
disposeProject(change.projectId);
return;
}
attachProject(change.project);
});
unsubscribeSession = subscribeWorksSquareSession((session) => {
if (!session) return;
for (const projectId of states.keys()) scheduleProject(projectId, true);
});
const projects = await projectStore.listProjects();
for (const project of projects) attachProject(project);
}
function stop(): void {
if (!started) return;
started = false;
unsubscribeProjectStore?.();
unsubscribeSession?.();
unsubscribeProjectStore = null;
unsubscribeSession = null;
for (const state of states.values()) closeWatcher(state);
states.clear();
}
return {
start,
stop,
flushProject: async (projectId: string): Promise<void> => {
const state = states.get(projectId);
if (!state) return;
state.pendingProgress = true;
state.pendingPromotion = true;
await startFlush(state);
},
getWatchedProjectIds: (): string[] => [...states.keys()],
};
}