416 lines
14 KiB
TypeScript
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()],
|
|
};
|
|
}
|