收敛客户端静态发布链路
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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()],
|
||||
};
|
||||
}
|
||||
179
electron/services/works-submission-binding.ts
Normal file
179
electron/services/works-submission-binding.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user