Makelore 2.0 initial clean snapshot
This commit is contained in:
415
electron/services/project-progress-sync.ts
Normal file
415
electron/services/project-progress-sync.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
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()],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user