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; type WatchDirectory = ( projectPath: string, onChange: (filename: string | null) => void, ) => ProjectProgressWatcher; type ProjectProgressProjectStore = Pick; export type ProjectProgressSyncOptions = { apiBaseUrl?: string; debounceMs?: number; fetchImpl?: (input: string | URL, init?: RequestInit) => Promise; getAccessToken?: () => Promise; watchDirectory?: WatchDirectory; }; type ProjectProgressState = { project: OpencodeProject; watcher: ProjectProgressWatcher | null; timer: ReturnType | null; pendingProgress: boolean; pendingPromotion: boolean; syncing: boolean; flushPromise: Promise | 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 { 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 { 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 { 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 { 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(); 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 { 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 { 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 { 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 => { 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 { 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 { 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 => { const state = states.get(projectId); if (!state) return; state.pendingProgress = true; state.pendingPromotion = true; await startFlush(state); }, getWatchedProjectIds: (): string[] => [...states.keys()], }; }