收敛客户端静态发布链路

This commit is contained in:
2026-08-10 17:04:29 +08:00
parent 2e737dee31
commit 4df047708b
37 changed files with 604 additions and 3071 deletions

View File

@@ -3,7 +3,7 @@ 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 { readWorksSubmissionBinding } from './works-submission-binding';
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
import { proxyAwareFetch } from '../utils/proxy-fetch';
import { logger } from '../utils/logger';
@@ -15,13 +15,13 @@ import {
getValidWorksSquareAccessToken,
subscribeWorksSquareSession,
} from './works-square-session';
import { WORKS_SUBMISSION_BINDING_FILE_NAME } from '../../shared/works-submission-binding';
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';
@@ -89,7 +89,7 @@ function isSyncRelevantFile(filename: string | null): boolean {
return PROJECT_PROGRESS_DOCUMENT_NAMES.includes(normalized as ProjectProgressDocumentName)
|| normalized === PRODUCT_OVERVIEW_FILENAME
|| normalized === LEGACY_PROMOTION_PLAN_FILENAME
|| normalized === WORKS_PUBLISH_FILE_NAME;
|| normalized === WORKS_SUBMISSION_BINDING_FILE_NAME;
}
export function mergeProjectProgressDocuments(gdd: string, tasks: string): string {
@@ -139,9 +139,9 @@ async function readProductOverview(projectPath: string): Promise<string> {
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 };
const binding = await readWorksSubmissionBinding(project.path);
if (binding?.status === 'submitted' && binding.app_id) {
return { projectType: 'server', projectKey: binding.app_id };
}
return { projectType: 'local', projectKey: project.id };
}
@@ -189,7 +189,7 @@ export function createProjectProgressSync(
if (!normalized || normalized === PRODUCT_OVERVIEW_FILENAME || normalized === LEGACY_PROMOTION_PLAN_FILENAME) {
state.pendingPromotion = true;
}
if (normalized === WORKS_PUBLISH_FILE_NAME) {
if (normalized === WORKS_SUBMISSION_BINDING_FILE_NAME) {
state.pendingProgress = true;
state.pendingPromotion = true;
state.lastSyncedFingerprint = null;

View File

@@ -1,481 +0,0 @@
import { watch, type FSWatcher } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { basename, extname, join, resolve } from 'node:path';
import type { OpencodeProject, OpencodeProjectStore } from '../opencode/project-store';
import { readWorksDeployCheck } from '../opencode/works-square-deploy-check';
import { readWorksPublishFile, type WorksPublishData } 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 {
WORKS_CLOUD_DEPLOYMENT_FILE_NAME,
WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION,
type WorksCloudDeploymentRecord,
type WorksCloudDeploymentStatus,
} from '../../shared/works-cloud-deployment';
import { getValidWorksSquareAccessToken, subscribeWorksSquareSession } from './works-square-session';
const DEFAULT_DEBOUNCE_MS = 700;
type ProjectStore = Pick<OpencodeProjectStore, 'listProjects' | 'subscribe'>;
type ProjectWatcher = Pick<FSWatcher, 'close'>;
type WatchDirectory = (projectPath: string, onChange: (filename: string | null) => void) => ProjectWatcher;
export type WorksCloudDeploymentOptions = {
apiBaseUrl?: string;
debounceMs?: number;
fetchImpl?: (input: string | URL, init?: RequestInit) => Promise<Response>;
getAccessToken?: () => Promise<string | null>;
watchDirectory?: WatchDirectory;
};
type DeploymentState = {
project: OpencodeProject;
watcher: ProjectWatcher | null;
timer: ReturnType<typeof setTimeout> | null;
running: boolean;
record: WorksCloudDeploymentRecord | null;
};
type DeploymentPatch = {
status: WorksCloudDeploymentStatus;
app_id?: string;
version_name?: string;
zip_sha256?: string;
version_id?: string;
review_status?: string;
error?: string | null;
};
type SubmittedDeploymentInput = {
appId: string;
versionId: string;
versionName: string;
reviewStatus: string;
zipSha256?: string;
};
function normalizeApiBaseUrl(value: string): string {
const baseUrl = value.trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(baseUrl)) {
throw new Error('Works Square API base URL must start with http:// or https://');
}
return baseUrl;
}
function defaultWatchDirectory(projectPath: string, onChange: (filename: string | null) => void): ProjectWatcher {
return watch(projectPath, { persistent: false }, (_event, filename) => {
onChange(typeof filename === 'string' ? filename : filename ? filename.toString() : null);
});
}
function normalizeFilename(filename: string | null): string | null {
return filename ? filename.replaceAll('\\', '/') : null;
}
function isRelevantFile(filename: string | null): boolean {
const normalized = normalizeFilename(filename);
if (!normalized) return true;
return normalized === 'works-publish.json'
|| normalized === 'works-deploy-check.json'
|| normalized === '部署报告.md'
|| extname(normalized).toLowerCase() === '.zip';
}
function resolveProjectFilePath(projectPath: string, value: string): string {
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('/') ? value : resolve(projectPath, value);
}
function readString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
async function readPayload(response: Response): Promise<unknown> {
const text = await response.text();
if (!text.trim()) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
function responseMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
const record = payload as Record<string, unknown>;
for (const key of ['message', 'msg', 'detail', 'error']) {
const value = readString(record[key]);
if (value) return value;
}
}
return typeof payload === 'string' && payload.trim() ? payload.trim() : fallback;
}
function isPendingStatus(status: WorksCloudDeploymentStatus): boolean {
return status === 'armed'
|| status === 'waiting_for_package'
|| status === 'waiting_for_login'
|| status === 'uploading';
}
async function readDeploymentRecord(projectPath: string): Promise<WorksCloudDeploymentRecord | null> {
try {
const parsed = JSON.parse(await readFile(join(projectPath, WORKS_CLOUD_DEPLOYMENT_FILE_NAME), 'utf8')) as Partial<WorksCloudDeploymentRecord>;
if (parsed.schema_version !== WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION
|| typeof parsed.project_id !== 'string'
|| typeof parsed.status !== 'string'
|| typeof parsed.requested_at !== 'string'
|| typeof parsed.updated_at !== 'string') {
return null;
}
return parsed as WorksCloudDeploymentRecord;
} catch {
return null;
}
}
export function createWorksCloudDeployment(
projectStore: ProjectStore,
options: WorksCloudDeploymentOptions = {},
) {
const apiBaseUrl = normalizeApiBaseUrl(options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl);
const debounceMs = Math.max(0, options.debounceMs ?? DEFAULT_DEBOUNCE_MS);
const fetchImpl = options.fetchImpl ?? proxyAwareFetch;
const getAccessToken = options.getAccessToken ?? getValidWorksSquareAccessToken;
const watchDirectory = options.watchDirectory ?? defaultWatchDirectory;
const states = new Map<string, DeploymentState>();
let started = false;
let unsubscribeProjectStore: (() => void) | null = null;
let unsubscribeSession: (() => void) | null = null;
function clearTimer(state: DeploymentState): void {
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
}
function closeState(state: DeploymentState): void {
clearTimer(state);
state.watcher?.close();
state.watcher = null;
}
function disposeProject(projectId: string): void {
const state = states.get(projectId);
if (!state) return;
closeState(state);
states.delete(projectId);
}
function attachWatcher(state: DeploymentState): void {
if (state.watcher) return;
try {
state.watcher = watchDirectory(state.project.path, (filename) => {
if (isRelevantFile(filename)) schedule(state, false);
});
} catch (error) {
logger.warn(`[works-cloud-deployment] Failed to watch ${state.project.path}`, error);
}
}
function schedule(state: DeploymentState, immediate: boolean): void {
if (!started || !isPendingStatus(state.record?.status ?? 'armed')) return;
clearTimer(state);
state.timer = setTimeout(() => {
state.timer = null;
void attempt(state);
}, immediate ? 0 : debounceMs);
}
async function persist(state: DeploymentState, record: WorksCloudDeploymentRecord): Promise<WorksCloudDeploymentRecord> {
state.record = record;
await writeFile(
join(state.project.path, WORKS_CLOUD_DEPLOYMENT_FILE_NAME),
`${JSON.stringify(record, null, 2)}\n`,
'utf8',
);
return record;
}
async function updateStatus(state: DeploymentState, patch: DeploymentPatch): Promise<WorksCloudDeploymentRecord> {
const now = new Date().toISOString();
const previous = state.record;
const next: WorksCloudDeploymentRecord = {
schema_version: WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION,
project_id: state.project.id,
status: patch.status,
requested_at: previous?.requested_at ?? now,
updated_at: now,
...(previous?.app_id ? { app_id: previous.app_id } : {}),
...(previous?.version_name ? { version_name: previous.version_name } : {}),
...(previous?.zip_sha256 ? { zip_sha256: previous.zip_sha256 } : {}),
...(previous?.version_id ? { version_id: previous.version_id } : {}),
...(previous?.review_status ? { review_status: previous.review_status } : {}),
...(previous?.error ? { error: previous.error } : {}),
...(patch.app_id ? { app_id: patch.app_id } : {}),
...(patch.version_name ? { version_name: patch.version_name } : {}),
...(patch.zip_sha256 ? { zip_sha256: patch.zip_sha256 } : {}),
...(patch.version_id ? { version_id: patch.version_id } : {}),
...(patch.review_status ? { review_status: patch.review_status } : {}),
};
if (patch.error === null) delete next.error;
if (patch.error) next.error = patch.error;
return await persist(state, next);
}
function getOrCreateState(project: OpencodeProject, record: WorksCloudDeploymentRecord | null = null): DeploymentState {
const existing = states.get(project.id);
if (existing) {
existing.project = project;
if (record) existing.record = record;
attachWatcher(existing);
return existing;
}
const state: DeploymentState = {
project,
watcher: null,
timer: null,
running: false,
record,
};
states.set(project.id, state);
attachWatcher(state);
return state;
}
async function arm(projectId: string): Promise<WorksCloudDeploymentRecord> {
const project = (await projectStore.listProjects()).find((item) => item.id === projectId);
if (!project) throw new Error('Project not found');
const state = getOrCreateState(project);
const now = new Date().toISOString();
const record: WorksCloudDeploymentRecord = {
schema_version: WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION,
project_id: project.id,
status: 'armed',
requested_at: now,
updated_at: now,
};
await persist(state, record);
schedule(state, true);
return record;
}
async function recordSubmitted(
projectId: string,
input: SubmittedDeploymentInput,
): Promise<WorksCloudDeploymentRecord> {
const project = (await projectStore.listProjects()).find((item) => item.id === projectId);
if (!project) throw new Error('Project not found');
const state = getOrCreateState(project, await readDeploymentRecord(project.path));
closeState(state);
const now = new Date().toISOString();
return await persist(state, {
schema_version: WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION,
project_id: project.id,
status: 'submitted',
requested_at: now,
updated_at: now,
app_id: input.appId,
version_id: input.versionId,
version_name: input.versionName,
review_status: input.reviewStatus,
...(input.zipSha256 ? { zip_sha256: input.zipSha256 } : {}),
});
}
async function ensureRemoteProject(accessToken: string, publish: WorksPublishData): Promise<void> {
const headers = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
};
const response = await fetchImpl(`${apiBaseUrl}/api/projects`, {
method: 'POST',
headers,
body: JSON.stringify({
app_id: publish.app_id,
title: publish.title,
summary: publish.summary,
cover_url: null,
category: publish.category,
age_band: publish.age_band.trim() || null,
difficulty: publish.difficulty,
}),
});
if (response.ok) return;
const payload = await readPayload(response);
if (response.status !== 409) {
throw new Error(`Works Square project create failed (${response.status}): ${responseMessage(payload, 'unknown error')}`);
}
const ownership = await fetchImpl(`${apiBaseUrl}/api/projects/mine/${encodeURIComponent(publish.app_id)}/status`, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!ownership.ok) {
const ownershipPayload = await readPayload(ownership);
throw new Error(`Works Square project ownership check failed (${ownership.status}): ${responseMessage(ownershipPayload, 'unknown error')}`);
}
}
async function uploadArchive(
accessToken: string,
projectPath: string,
publish: WorksPublishData,
): Promise<{ versionId: string; reviewStatus: string }> {
const zipPath = resolveProjectFilePath(projectPath, publish.zip_file_path);
const archive = await readFile(zipPath);
const form = new FormData();
form.set('version_name', publish.version_name || 'v1.0.0');
form.set('change_log', publish.change_log || '提交部署程序包');
form.set('archive', new Blob([new Uint8Array(archive)], { type: 'application/zip' }), basename(zipPath));
const response = await fetchImpl(`${apiBaseUrl}/api/projects/${encodeURIComponent(publish.app_id)}/versions/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body: form,
});
const payload = await readPayload(response);
if (!response.ok) {
throw new Error(`Works Square cloud upload failed (${response.status}): ${responseMessage(payload, 'unknown error')}`);
}
const record = payload && typeof payload === 'object' && !Array.isArray(payload)
? payload as Record<string, unknown>
: {};
const versionId = readString(record.version_id);
if (!versionId) throw new Error('Works Square cloud upload returned no version_id');
return {
versionId,
reviewStatus: readString(record.review_status) ?? 'building',
};
}
async function attempt(state: DeploymentState): Promise<void> {
if (!started || state.running || !isPendingStatus(state.record?.status ?? 'armed')) return;
state.running = true;
try {
const publish = await readWorksPublishFile(state.project.path);
if (publish.status !== 'ready') {
await updateStatus(state, { status: 'waiting_for_package', error: null });
return;
}
const deploymentCheck = await readWorksDeployCheck(state.project.path, publish.publish, { mode: 'cloud' });
if (deploymentCheck.status === 'missing') {
await updateStatus(state, { status: 'waiting_for_package', app_id: publish.publish.app_id, version_name: publish.publish.version_name, error: null });
return;
}
if (deploymentCheck.status !== 'pass' || !deploymentCheck.zip_sha256) {
await updateStatus(state, {
status: 'failed',
app_id: publish.publish.app_id,
version_name: publish.publish.version_name,
error: deploymentCheck.error || 'ZIP 静态安全检查未通过,未提交云端',
});
closeState(state);
return;
}
const accessToken = await getAccessToken();
if (!accessToken) {
await updateStatus(state, {
status: 'waiting_for_login',
app_id: publish.publish.app_id,
version_name: publish.publish.version_name,
zip_sha256: deploymentCheck.zip_sha256,
error: '请先登录 Works Square云端部署会在登录态恢复后继续',
});
return;
}
await updateStatus(state, {
status: 'uploading',
app_id: publish.publish.app_id,
version_name: publish.publish.version_name,
zip_sha256: deploymentCheck.zip_sha256,
error: null,
});
await ensureRemoteProject(accessToken, publish.publish);
const uploaded = await uploadArchive(accessToken, state.project.path, publish.publish);
await updateStatus(state, {
status: 'submitted',
app_id: publish.publish.app_id,
version_name: publish.publish.version_name,
zip_sha256: deploymentCheck.zip_sha256,
version_id: uploaded.versionId,
review_status: uploaded.reviewStatus,
error: null,
});
closeState(state);
logger.info(`[works-cloud-deployment] Submitted ${state.project.id} as ${uploaded.versionId}`);
} catch (error) {
await updateStatus(state, {
status: 'failed',
error: error instanceof Error ? error.message : String(error),
});
closeState(state);
logger.warn(`[works-cloud-deployment] Failed for ${state.project.id}`, error);
} finally {
state.running = false;
}
}
async function get(projectId: string): Promise<WorksCloudDeploymentRecord | null> {
const state = states.get(projectId);
if (state?.record) return state.record;
const project = (await projectStore.listProjects()).find((item) => item.id === projectId);
return project ? await readDeploymentRecord(project.path) : null;
}
async function start(): Promise<void> {
if (started) return;
started = true;
unsubscribeProjectStore = projectStore.subscribe((change) => {
if (change.type === 'remove') {
disposeProject(change.projectId);
return;
}
const state = states.get(change.project.id);
if (state) {
state.project = change.project;
attachWatcher(state);
}
});
unsubscribeSession = subscribeWorksSquareSession((session) => {
if (!session) return;
for (const state of states.values()) {
if (state.record?.status === 'waiting_for_login') schedule(state, true);
}
});
for (const project of await projectStore.listProjects()) {
const record = await readDeploymentRecord(project.path);
if (!record || record.project_id !== project.id || !isPendingStatus(record.status)) continue;
const state = getOrCreateState(project, record);
if (record.status === 'uploading') {
await updateStatus(state, { status: 'armed', error: '应用重启后恢复云端部署任务' });
}
schedule(state, true);
}
}
function stop(): void {
if (!started) return;
started = false;
unsubscribeProjectStore?.();
unsubscribeSession?.();
unsubscribeProjectStore = null;
unsubscribeSession = null;
for (const state of states.values()) closeState(state);
states.clear();
}
return {
arm,
recordSubmitted,
get,
start,
stop,
getWatchedProjectIds: (): string[] => [...states.keys()],
};
}

View File

@@ -0,0 +1,179 @@
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { OpencodeProjectStore } from '../opencode/project-store';
import { logger } from '../utils/logger';
import {
WORKS_SUBMISSION_BINDING_FILE_NAME,
WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
type WorksSubmissionBindingRecord,
} from '../../shared/works-submission-binding';
const LEGACY_PENDING_STATUSES = new Set([
'armed',
'waiting_for_package',
'waiting_for_login',
'uploading',
'failed',
]);
const LEGACY_RETIRED_MESSAGE = '旧版自动部署任务已停用,请在项目配置中点击“提交审核”重新提交。';
type ProjectStore = Pick<OpencodeProjectStore, 'listProjects'>;
type SubmittedBindingInput = {
appId: string;
versionId: string;
versionName: string;
reviewStatus: string;
zipSha256?: string;
};
type LegacyBindingRecord = {
schema_version: 1;
project_id: string;
status: string;
requested_at: string;
updated_at: string;
app_id?: string;
version_name?: string;
zip_sha256?: string;
version_id?: string;
review_status?: string;
};
function readString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function parseCurrentRecord(value: unknown): WorksSubmissionBindingRecord | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (
record.schema_version !== WORKS_SUBMISSION_BINDING_SCHEMA_VERSION
|| (record.status !== 'submitted' && record.status !== 'legacy_retired')
) return null;
const projectId = readString(record.project_id);
const requestedAt = readString(record.requested_at);
const updatedAt = readString(record.updated_at);
if (!projectId || !requestedAt || !updatedAt) return null;
return record as WorksSubmissionBindingRecord;
}
function parseLegacyRecord(value: unknown): LegacyBindingRecord | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const projectId = readString(record.project_id);
const status = readString(record.status);
const requestedAt = readString(record.requested_at);
const updatedAt = readString(record.updated_at);
if (record.schema_version !== 1 || !projectId || !status || !requestedAt || !updatedAt) {
return null;
}
return record as LegacyBindingRecord;
}
function migrateLegacyRecord(record: LegacyBindingRecord): WorksSubmissionBindingRecord {
const now = new Date().toISOString();
if (
record.status === 'submitted'
&& readString(record.app_id)
&& readString(record.version_id)
&& readString(record.version_name)
) {
return {
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
project_id: record.project_id,
status: 'submitted',
requested_at: record.requested_at,
updated_at: now,
app_id: record.app_id,
version_id: record.version_id,
version_name: record.version_name,
...(readString(record.review_status) ? { review_status: record.review_status } : {}),
...(readString(record.zip_sha256) ? { zip_sha256: record.zip_sha256 } : {}),
};
}
return {
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
project_id: record.project_id,
status: 'legacy_retired',
requested_at: record.requested_at,
updated_at: now,
error_code: 'LEGACY_AUTO_DEPLOY_RETIRED',
message: LEGACY_PENDING_STATUSES.has(record.status)
? LEGACY_RETIRED_MESSAGE
: '旧版部署记录已停用,请使用项目配置中的“提交审核”。',
};
}
async function persistRecord(
projectPath: string,
record: WorksSubmissionBindingRecord,
): Promise<WorksSubmissionBindingRecord> {
await writeFile(
join(projectPath, WORKS_SUBMISSION_BINDING_FILE_NAME),
`${JSON.stringify(record, null, 2)}\n`,
'utf8',
);
return record;
}
export async function readWorksSubmissionBinding(
projectPath: string,
): Promise<WorksSubmissionBindingRecord | null> {
let parsed: unknown;
try {
parsed = JSON.parse(await readFile(
join(projectPath, WORKS_SUBMISSION_BINDING_FILE_NAME),
'utf8',
)) as unknown;
} catch {
return null;
}
const current = parseCurrentRecord(parsed);
if (current) return current;
const legacy = parseLegacyRecord(parsed);
if (!legacy) return null;
return persistRecord(projectPath, migrateLegacyRecord(legacy));
}
export function createWorksSubmissionBindingStore(projectStore: ProjectStore) {
async function recordSubmitted(
projectId: string,
input: SubmittedBindingInput,
): Promise<WorksSubmissionBindingRecord> {
const project = (await projectStore.listProjects()).find((item) => item.id === projectId);
if (!project) throw new Error('Project not found');
const now = new Date().toISOString();
return persistRecord(project.path, {
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
project_id: project.id,
status: 'submitted',
requested_at: now,
updated_at: now,
app_id: input.appId,
version_id: input.versionId,
version_name: input.versionName,
review_status: input.reviewStatus,
...(input.zipSha256 ? { zip_sha256: input.zipSha256 } : {}),
});
}
async function get(projectId: string): Promise<WorksSubmissionBindingRecord | null> {
const project = (await projectStore.listProjects()).find((item) => item.id === projectId);
return project ? readWorksSubmissionBinding(project.path) : null;
}
async function start(): Promise<void> {
for (const project of await projectStore.listProjects()) {
try {
await readWorksSubmissionBinding(project.path);
} catch (error) {
logger.warn(`[works-submission-binding] Failed to migrate ${project.id}`, error);
}
}
}
return { recordSubmitted, get, start };
}