openworkgraph 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/dist/api-image-agent.d.ts +32 -0
- package/dist/api-image-agent.js +132 -0
- package/dist/api-image-output.d.ts +8 -0
- package/dist/api-image-output.js +41 -0
- package/dist/api.d.ts +35 -0
- package/dist/api.js +153 -0
- package/dist/auth.d.ts +58 -0
- package/dist/auth.js +273 -0
- package/dist/backend/adapter.d.ts +35 -0
- package/dist/backend/adapter.js +439 -0
- package/dist/backend/capabilities.d.ts +23 -0
- package/dist/backend/capabilities.js +75 -0
- package/dist/backend/diagnostics.d.ts +9 -0
- package/dist/backend/diagnostics.js +80 -0
- package/dist/backend/index.d.ts +6 -0
- package/dist/backend/index.js +7 -0
- package/dist/backend/models.d.ts +15 -0
- package/dist/backend/models.js +43 -0
- package/dist/backend/paths.d.ts +6 -0
- package/dist/backend/paths.js +69 -0
- package/dist/backend/stdio.d.ts +66 -0
- package/dist/backend/stdio.js +172 -0
- package/dist/backend/types.d.ts +90 -0
- package/dist/backend/types.js +11 -0
- package/dist/backup-api.d.ts +10 -0
- package/dist/backup-api.js +40 -0
- package/dist/blob-store.d.ts +40 -0
- package/dist/blob-store.js +218 -0
- package/dist/cli-help.d.ts +1 -0
- package/dist/cli-help.js +63 -0
- package/dist/cli-output.d.ts +4 -0
- package/dist/cli-output.js +130 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +284 -0
- package/dist/client-pairing.d.ts +8 -0
- package/dist/client-pairing.js +30 -0
- package/dist/directories.d.ts +14 -0
- package/dist/directories.js +34 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +7 -0
- package/dist/events.d.ts +22 -0
- package/dist/events.js +155 -0
- package/dist/execution-chains.d.ts +24 -0
- package/dist/execution-chains.js +231 -0
- package/dist/execution-settings.d.ts +10 -0
- package/dist/execution-settings.js +38 -0
- package/dist/graph-document-history.d.ts +43 -0
- package/dist/graph-document-history.js +275 -0
- package/dist/graph-files.d.ts +14 -0
- package/dist/graph-files.js +59 -0
- package/dist/graph-transfer.d.ts +53 -0
- package/dist/graph-transfer.js +501 -0
- package/dist/graphs.d.ts +68 -0
- package/dist/graphs.js +771 -0
- package/dist/history.d.ts +20 -0
- package/dist/history.js +61 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +42 -0
- package/dist/image-credentials.d.ts +11 -0
- package/dist/image-credentials.js +130 -0
- package/dist/image-providers.d.ts +51 -0
- package/dist/image-providers.js +271 -0
- package/dist/input-materialization.d.ts +17 -0
- package/dist/input-materialization.js +95 -0
- package/dist/inputs.d.ts +74 -0
- package/dist/inputs.js +301 -0
- package/dist/local-control.d.ts +13 -0
- package/dist/local-control.js +37 -0
- package/dist/network.d.ts +11 -0
- package/dist/network.js +48 -0
- package/dist/openai-image-driver.d.ts +42 -0
- package/dist/openai-image-driver.js +368 -0
- package/dist/operations/backups.d.ts +40 -0
- package/dist/operations/backups.js +391 -0
- package/dist/operations/capacity.d.ts +7 -0
- package/dist/operations/capacity.js +35 -0
- package/dist/operations/cli-context.d.ts +7 -0
- package/dist/operations/cli-context.js +58 -0
- package/dist/operations/daemon.d.ts +36 -0
- package/dist/operations/daemon.js +159 -0
- package/dist/operations/files.d.ts +12 -0
- package/dist/operations/files.js +84 -0
- package/dist/operations/lifecycle.d.ts +28 -0
- package/dist/operations/lifecycle.js +155 -0
- package/dist/operations/local-admin.d.ts +6 -0
- package/dist/operations/local-admin.js +38 -0
- package/dist/operations/logs.d.ts +16 -0
- package/dist/operations/logs.js +37 -0
- package/dist/operations/plugins.d.ts +10 -0
- package/dist/operations/plugins.js +11 -0
- package/dist/operations/snapshot-worker.d.ts +1 -0
- package/dist/operations/snapshot-worker.js +18 -0
- package/dist/operations/snapshot.d.ts +1 -0
- package/dist/operations/snapshot.js +14 -0
- package/dist/operations/update.d.ts +26 -0
- package/dist/operations/update.js +216 -0
- package/dist/persistence/asset-business-schema.d.ts +2 -0
- package/dist/persistence/asset-business-schema.js +36 -0
- package/dist/persistence/database.d.ts +7 -0
- package/dist/persistence/database.js +91 -0
- package/dist/persistence/plugin-schema.d.ts +9 -0
- package/dist/persistence/plugin-schema.js +38 -0
- package/dist/persistence/repositories.d.ts +20 -0
- package/dist/persistence/repositories.js +106 -0
- package/dist/persistence/resource-schema.d.ts +2 -0
- package/dist/persistence/resource-schema.js +44 -0
- package/dist/persistence/schema.d.ts +6 -0
- package/dist/persistence/schema.js +236 -0
- package/dist/persistence/workflow-schema.d.ts +2 -0
- package/dist/persistence/workflow-schema.js +19 -0
- package/dist/plugin-api.d.ts +10 -0
- package/dist/plugin-api.js +40 -0
- package/dist/plugin-management.d.ts +36 -0
- package/dist/plugin-management.js +112 -0
- package/dist/plugins.d.ts +55 -0
- package/dist/plugins.js +239 -0
- package/dist/project-discovery.d.ts +3 -0
- package/dist/project-discovery.js +57 -0
- package/dist/project-files-api.d.ts +16 -0
- package/dist/project-files-api.js +243 -0
- package/dist/project-files.d.ts +97 -0
- package/dist/project-files.js +557 -0
- package/dist/project-lock.d.ts +4 -0
- package/dist/project-lock.js +35 -0
- package/dist/project-probe-worker.d.ts +1 -0
- package/dist/project-probe-worker.js +17 -0
- package/dist/project-probe.d.ts +22 -0
- package/dist/project-probe.js +67 -0
- package/dist/projects.d.ts +29 -0
- package/dist/projects.js +219 -0
- package/dist/publication.d.ts +31 -0
- package/dist/publication.js +642 -0
- package/dist/resource-api.d.ts +15 -0
- package/dist/resource-api.js +256 -0
- package/dist/resources-orphans.d.ts +41 -0
- package/dist/resources-orphans.js +235 -0
- package/dist/resources.d.ts +246 -0
- package/dist/resources.js +714 -0
- package/dist/run-api.d.ts +10 -0
- package/dist/run-api.js +308 -0
- package/dist/run-context.d.ts +54 -0
- package/dist/run-context.js +137 -0
- package/dist/runs.d.ts +82 -0
- package/dist/runs.js +506 -0
- package/dist/runtime-backend.d.ts +24 -0
- package/dist/runtime-backend.js +214 -0
- package/dist/runtime.d.ts +97 -0
- package/dist/runtime.js +553 -0
- package/dist/scheduler.d.ts +85 -0
- package/dist/scheduler.js +344 -0
- package/dist/service.d.ts +29 -0
- package/dist/service.js +515 -0
- package/dist/skills-api.d.ts +18 -0
- package/dist/skills-api.js +120 -0
- package/dist/storage-api.d.ts +21 -0
- package/dist/storage-api.js +92 -0
- package/dist/transfer-api.d.ts +10 -0
- package/dist/transfer-api.js +60 -0
- package/dist/vendor/protocol/contracts.typecheck.d.ts +1 -0
- package/dist/vendor/protocol/contracts.typecheck.js +35 -0
- package/dist/vendor/protocol/execution-chain.d.ts +6 -0
- package/dist/vendor/protocol/execution-chain.js +26 -0
- package/dist/vendor/protocol/graph-binary.d.ts +15 -0
- package/dist/vendor/protocol/graph-binary.js +121 -0
- package/dist/vendor/protocol/graph-title.d.ts +2 -0
- package/dist/vendor/protocol/graph-title.js +23 -0
- package/dist/vendor/protocol/index.d.ts +617 -0
- package/dist/vendor/protocol/index.js +20 -0
- package/dist/vendor/protocol/pairing.d.ts +29 -0
- package/dist/vendor/protocol/pairing.js +71 -0
- package/dist/vendor/protocol/preview.d.ts +2 -0
- package/dist/vendor/protocol/preview.js +9 -0
- package/dist/vendor/protocol/resource-mime.d.ts +2 -0
- package/dist/vendor/protocol/resource-mime.js +35 -0
- package/dist/video-thumbnail.d.ts +2 -0
- package/dist/video-thumbnail.js +34 -0
- package/package.json +9 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
export declare const PROJECT_FILE_PREVIEW_MAX_BYTES: number;
|
|
3
|
+
export declare const PROJECT_IMAGE_THUMBNAIL_LEVELS: readonly [80, 160, 320, 640, 1280, 2560, 4096];
|
|
4
|
+
export interface ProjectFileEntry {
|
|
5
|
+
name: string;
|
|
6
|
+
relativePath: string;
|
|
7
|
+
kind: 'file' | 'directory';
|
|
8
|
+
hidden: boolean;
|
|
9
|
+
bytes?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface ProjectFileObservation {
|
|
12
|
+
path: string;
|
|
13
|
+
state: 'available' | 'missing' | 'unavailable';
|
|
14
|
+
name: string;
|
|
15
|
+
mime: string | null;
|
|
16
|
+
bytes: number | null;
|
|
17
|
+
changeToken: string | null;
|
|
18
|
+
}
|
|
19
|
+
export declare function classifyProjectFile(name: string): {
|
|
20
|
+
mime: string;
|
|
21
|
+
type: 'text' | 'image' | 'file';
|
|
22
|
+
};
|
|
23
|
+
export type HiddenFileReader = (directory: string) => Promise<ReadonlySet<string>>;
|
|
24
|
+
export declare function normalizeProjectPath(value: unknown): string;
|
|
25
|
+
export declare class ProjectFiles {
|
|
26
|
+
private readonly db;
|
|
27
|
+
private readonly hiddenFiles;
|
|
28
|
+
private readonly searches;
|
|
29
|
+
private readonly videoFrames;
|
|
30
|
+
constructor(db: DatabaseSync, hiddenFiles?: HiddenFileReader);
|
|
31
|
+
private root;
|
|
32
|
+
resolve(projectId: string, path: string, kind: 'file' | 'directory'): Promise<{
|
|
33
|
+
path: string;
|
|
34
|
+
relativePath: string;
|
|
35
|
+
}>;
|
|
36
|
+
readRange(projectId: string, path: string, range?: {
|
|
37
|
+
start: number;
|
|
38
|
+
end?: number;
|
|
39
|
+
}, maxBytes?: number): Promise<{
|
|
40
|
+
bytes: Buffer;
|
|
41
|
+
start: number;
|
|
42
|
+
end: number;
|
|
43
|
+
total: number;
|
|
44
|
+
}>;
|
|
45
|
+
private readRangeImpl;
|
|
46
|
+
read(projectId: string, path: string, maxBytes?: number): Promise<Buffer>;
|
|
47
|
+
/** Verify a live binary deliverable without retaining its entire contents. */
|
|
48
|
+
fingerprint(projectId: string, path: string, expectedBytes: number): Promise<{
|
|
49
|
+
sha256: string;
|
|
50
|
+
prefix: Buffer;
|
|
51
|
+
changeToken: string;
|
|
52
|
+
}>;
|
|
53
|
+
readLinkedFile(projectId: string, rawPath: unknown, runsDirectory: string, options?: {
|
|
54
|
+
metadataOnly?: boolean;
|
|
55
|
+
range?: {
|
|
56
|
+
start: number;
|
|
57
|
+
end: number;
|
|
58
|
+
};
|
|
59
|
+
}): Promise<{
|
|
60
|
+
name: string;
|
|
61
|
+
mime: string;
|
|
62
|
+
bytes: Buffer;
|
|
63
|
+
total: number;
|
|
64
|
+
start: number;
|
|
65
|
+
end: number;
|
|
66
|
+
scope: 'project' | 'run';
|
|
67
|
+
relativePath?: string;
|
|
68
|
+
}>;
|
|
69
|
+
readLinkedImageThumbnail(projectId: string, rawPath: unknown, runsDirectory: string, size: number): Promise<{
|
|
70
|
+
bytes: Buffer;
|
|
71
|
+
mime: string;
|
|
72
|
+
name: string;
|
|
73
|
+
total: number;
|
|
74
|
+
start: number;
|
|
75
|
+
end: number;
|
|
76
|
+
scope: 'project' | 'run';
|
|
77
|
+
relativePath?: string;
|
|
78
|
+
}>;
|
|
79
|
+
thumbnail(projectId: string, path: string, size?: number): Promise<{
|
|
80
|
+
bytes: Buffer;
|
|
81
|
+
mime: string;
|
|
82
|
+
}>;
|
|
83
|
+
private videoThumbnail;
|
|
84
|
+
list(projectId: string, path: string, showHidden: boolean, limit?: number, cursor?: string | null): Promise<{
|
|
85
|
+
path: string;
|
|
86
|
+
items: ProjectFileEntry[];
|
|
87
|
+
nextCursor: string | null;
|
|
88
|
+
}>;
|
|
89
|
+
private listImpl;
|
|
90
|
+
search(projectId: string, query: string, showHidden: boolean, limit?: number, cursor?: string | null, signal?: AbortSignal): Promise<{
|
|
91
|
+
items: ProjectFileEntry[];
|
|
92
|
+
hasMore: boolean;
|
|
93
|
+
nextCursor: string | null;
|
|
94
|
+
}>;
|
|
95
|
+
stat(projectId: string, paths: string[]): Promise<ProjectFileObservation[]>;
|
|
96
|
+
private statImpl;
|
|
97
|
+
}
|
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import { basename, isAbsolute, join, relative, resolve, sep, win32 } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { ServiceError } from './errors.js';
|
|
8
|
+
import { atomic } from './persistence/database.js';
|
|
9
|
+
import sharp from 'sharp';
|
|
10
|
+
import { videoThumbnail as extractVideoThumbnail } from './video-thumbnail.js';
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
class ProjectFileIoSemaphore {
|
|
13
|
+
limit;
|
|
14
|
+
active = 0;
|
|
15
|
+
waiters = [];
|
|
16
|
+
constructor(limit) {
|
|
17
|
+
this.limit = limit;
|
|
18
|
+
}
|
|
19
|
+
async run(operation) {
|
|
20
|
+
if (this.active >= this.limit)
|
|
21
|
+
await new Promise(resolve => this.waiters.push(resolve));
|
|
22
|
+
this.active++;
|
|
23
|
+
try {
|
|
24
|
+
return await operation();
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
this.active--;
|
|
28
|
+
this.waiters.shift()?.();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const projectFileIo = new ProjectFileIoSemaphore(8);
|
|
33
|
+
const videoThumbnailIo = new ProjectFileIoSemaphore(2);
|
|
34
|
+
const isVideoPath = (path) => /[.](mp4|m4v|mov|webm|mkv|avi|mpg|mpeg|ogv)$/i.test(path);
|
|
35
|
+
export const PROJECT_FILE_PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
|
|
36
|
+
export const PROJECT_IMAGE_THUMBNAIL_LEVELS = [80, 160, 320, 640, 1280, 2560, 4096];
|
|
37
|
+
export function classifyProjectFile(name) {
|
|
38
|
+
const extension = name.split('.').at(-1)?.toLowerCase() ?? '';
|
|
39
|
+
const mime = { md: 'text/markdown', markdown: 'text/markdown', txt: 'text/plain', json: 'application/json', xml: 'application/xml', yaml: 'application/yaml', yml: 'application/yaml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', csv: 'text/csv', pdf: 'application/pdf' };
|
|
40
|
+
const videoMime = { mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mkv: 'video/x-matroska', avi: 'video/x-msvideo' };
|
|
41
|
+
const value = mime[extension] ?? videoMime[extension] ?? 'application/octet-stream';
|
|
42
|
+
return { mime: value, type: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(extension) ? 'image' : ['md', 'markdown', 'txt', 'json', 'xml', 'yaml', 'yml'].includes(extension) ? 'text' : 'file' };
|
|
43
|
+
}
|
|
44
|
+
async function windowsHiddenFiles(directory) {
|
|
45
|
+
if (process.platform !== 'win32')
|
|
46
|
+
return new Set();
|
|
47
|
+
try {
|
|
48
|
+
const { stdout } = await execFileAsync('attrib', [join(directory, '*')], { windowsHide: true, timeout: 2_000, maxBuffer: 1024 * 1024 });
|
|
49
|
+
const hidden = new Set();
|
|
50
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
51
|
+
const match = /^([A-Z ]+?)\s{2,}(.+)$/.exec(line);
|
|
52
|
+
if (match?.[1]?.includes('H'))
|
|
53
|
+
hidden.add(basename(match[2].trim()).toLocaleLowerCase());
|
|
54
|
+
}
|
|
55
|
+
return hidden;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return new Set();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function decodeListCursor(value) {
|
|
62
|
+
try {
|
|
63
|
+
if (!value || value.length > 4096 || !/^[A-Za-z0-9_-]+$/.test(value))
|
|
64
|
+
throw new Error();
|
|
65
|
+
const bytes = Buffer.from(value, 'base64url');
|
|
66
|
+
if (bytes.toString('base64url') !== value)
|
|
67
|
+
throw new Error();
|
|
68
|
+
const cursor = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
69
|
+
if (cursor.v !== 1 || typeof cursor.path !== 'string' || typeof cursor.showHidden !== 'boolean' || typeof cursor.fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(cursor.fingerprint) || !Number.isSafeInteger(cursor.offset) || Number(cursor.offset) < 1)
|
|
70
|
+
throw new Error();
|
|
71
|
+
return cursor;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new ServiceError('INVALID_REQUEST', '文件分页游标无效。');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function normalizeProjectPath(value) {
|
|
78
|
+
if (typeof value !== 'string' || value.length > 4096 || value.includes(String.fromCharCode(0)) || value.includes(String.fromCharCode(92)) || isAbsolute(value) || value.startsWith('/') || /^[a-zA-Z]:/.test(value))
|
|
79
|
+
throw new ServiceError('INVALID_REQUEST', '无效的项目相对路径。');
|
|
80
|
+
if (!value)
|
|
81
|
+
return '';
|
|
82
|
+
const parts = value.split('/');
|
|
83
|
+
if (parts.some(part => !part || part === '.' || part === '..' || part.includes(':')))
|
|
84
|
+
throw new ServiceError('INVALID_REQUEST', '项目路径不得越界或包含空路径段。');
|
|
85
|
+
return parts.join('/');
|
|
86
|
+
}
|
|
87
|
+
function inside(root, target) {
|
|
88
|
+
const rest = relative(root, target);
|
|
89
|
+
return rest === '' || (rest !== '..' && !rest.startsWith('..' + sep) && !isAbsolute(rest));
|
|
90
|
+
}
|
|
91
|
+
async function resizeImageThumbnail(bytes, mime, size) {
|
|
92
|
+
const image = sharp(bytes, { animated: false });
|
|
93
|
+
const info = await image.metadata();
|
|
94
|
+
const maxDimension = Math.max(info.width ?? 0, info.height ?? 0);
|
|
95
|
+
const target = PROJECT_IMAGE_THUMBNAIL_LEVELS.includes(size) ? size : 320;
|
|
96
|
+
if (maxDimension > 0 && maxDimension <= target)
|
|
97
|
+
return { bytes, mime };
|
|
98
|
+
return { bytes: await image.rotate().resize({ width: target, height: target, fit: 'inside', withoutEnlargement: true }).webp({ quality: 82 }).toBuffer(), mime: 'image/webp' };
|
|
99
|
+
}
|
|
100
|
+
function linkedPath(value) {
|
|
101
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 4096 || value.includes(String.fromCharCode(0)))
|
|
102
|
+
throw new ServiceError('INVALID_REQUEST', '无法查看此文件。');
|
|
103
|
+
const raw = value.trim();
|
|
104
|
+
if (/^file:/i.test(raw)) {
|
|
105
|
+
try {
|
|
106
|
+
return { path: fileURLToPath(new URL(raw)), projectFallback: false };
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new ServiceError('INVALID_REQUEST', '无法查看此文件。');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(raw) && !win32.isAbsolute(raw))
|
|
113
|
+
throw new ServiceError('INVALID_REQUEST', '无法查看此文件。');
|
|
114
|
+
const suffix = Math.min(...[raw.indexOf('?'), raw.indexOf('#')].filter(index => index >= 0), raw.length);
|
|
115
|
+
let path;
|
|
116
|
+
try {
|
|
117
|
+
path = decodeURIComponent(raw.slice(0, suffix));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
throw new ServiceError('INVALID_REQUEST', '无法查看此文件。');
|
|
121
|
+
}
|
|
122
|
+
if (!path)
|
|
123
|
+
throw new ServiceError('INVALID_REQUEST', '无法查看此文件。');
|
|
124
|
+
return { path, projectFallback: !win32.isAbsolute(path) || path.startsWith('/') };
|
|
125
|
+
}
|
|
126
|
+
export class ProjectFiles {
|
|
127
|
+
db;
|
|
128
|
+
hiddenFiles;
|
|
129
|
+
searches = new Map();
|
|
130
|
+
videoFrames = new Map();
|
|
131
|
+
constructor(db, hiddenFiles = windowsHiddenFiles) {
|
|
132
|
+
this.db = db;
|
|
133
|
+
this.hiddenFiles = hiddenFiles;
|
|
134
|
+
}
|
|
135
|
+
async root(projectId) {
|
|
136
|
+
const row = this.db.prepare('SELECT canonical_path,state FROM projects WHERE id=?').get(projectId);
|
|
137
|
+
if (!row)
|
|
138
|
+
throw new ServiceError('NOT_FOUND', '项目不存在。');
|
|
139
|
+
if (row['state'] !== 'active')
|
|
140
|
+
throw new ServiceError('PROJECT_INACTIVE', '项目已停用。');
|
|
141
|
+
try {
|
|
142
|
+
const root = await fs.realpath(String(row['canonical_path']));
|
|
143
|
+
if (root !== row['canonical_path'] || !(await fs.stat(root)).isDirectory())
|
|
144
|
+
throw new Error('Project root changed');
|
|
145
|
+
return root;
|
|
146
|
+
}
|
|
147
|
+
catch (cause) {
|
|
148
|
+
throw new ServiceError('PROJECT_UNAVAILABLE', '项目目录不可访问;请修复路径。', { cause });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async resolve(projectId, path, kind) {
|
|
152
|
+
const relativePath = normalizeProjectPath(path);
|
|
153
|
+
const root = await this.root(projectId);
|
|
154
|
+
let target;
|
|
155
|
+
try {
|
|
156
|
+
target = await fs.realpath(resolve(root, relativePath));
|
|
157
|
+
}
|
|
158
|
+
catch (cause) {
|
|
159
|
+
if (cause.code === 'ENOENT')
|
|
160
|
+
throw new ServiceError('NOT_FOUND', '项目文件不存在。');
|
|
161
|
+
throw new ServiceError('PROJECT_UNAVAILABLE', '项目文件不可访问。', { cause });
|
|
162
|
+
}
|
|
163
|
+
if (!inside(root, target))
|
|
164
|
+
throw new ServiceError('INVALID_REQUEST', '项目路径越界。');
|
|
165
|
+
let stat;
|
|
166
|
+
try {
|
|
167
|
+
stat = await fs.stat(target);
|
|
168
|
+
}
|
|
169
|
+
catch (cause) {
|
|
170
|
+
throw new ServiceError('PROJECT_UNAVAILABLE', '项目文件不可访问。', { cause });
|
|
171
|
+
}
|
|
172
|
+
if (kind === 'file' ? !stat.isFile() : !stat.isDirectory())
|
|
173
|
+
throw new ServiceError('INVALID_REQUEST', '项目路径类型不匹配。');
|
|
174
|
+
return { path: target, relativePath };
|
|
175
|
+
}
|
|
176
|
+
async readRange(projectId, path, range, maxBytes = PROJECT_FILE_PREVIEW_MAX_BYTES) {
|
|
177
|
+
return projectFileIo.run(() => this.readRangeImpl(projectId, path, range, maxBytes));
|
|
178
|
+
}
|
|
179
|
+
async readRangeImpl(projectId, path, range, maxBytes = PROJECT_FILE_PREVIEW_MAX_BYTES) {
|
|
180
|
+
const file = await this.resolve(projectId, path, 'file');
|
|
181
|
+
const handle = await fs.open(file.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
182
|
+
try {
|
|
183
|
+
const before = await handle.stat();
|
|
184
|
+
if (!before.isFile())
|
|
185
|
+
throw new ServiceError('INVALID_REQUEST', '项目路径不是普通文件。');
|
|
186
|
+
const start = range?.start ?? 0;
|
|
187
|
+
if (!Number.isSafeInteger(start) || start < 0 || (range?.end !== undefined && (!Number.isSafeInteger(range.end) || range.end < start)))
|
|
188
|
+
throw new ServiceError('INVALID_REQUEST', '无效的文件读取范围。');
|
|
189
|
+
if (range && start >= before.size)
|
|
190
|
+
throw new ServiceError('INVALID_REQUEST', '文件读取范围超出内容长度。');
|
|
191
|
+
const end = before.size === 0 ? -1 : Math.min(range?.end ?? before.size - 1, before.size - 1);
|
|
192
|
+
const length = end < start ? 0 : end - start + 1;
|
|
193
|
+
if (length > maxBytes || (!range && before.size > maxBytes))
|
|
194
|
+
throw new ServiceError('PAYLOAD_TOO_LARGE', '项目文件超过 50 MiB,无法预览。');
|
|
195
|
+
const current = await this.resolve(projectId, path, 'file');
|
|
196
|
+
const linked = await fs.stat(current.path);
|
|
197
|
+
if (current.path !== file.path || linked.dev !== before.dev || linked.ino !== before.ino)
|
|
198
|
+
throw new ServiceError('CONFLICT', '文件在读取前已变化,请重试。');
|
|
199
|
+
const bytes = Buffer.alloc(length);
|
|
200
|
+
if (length) {
|
|
201
|
+
const result = await handle.read(bytes, 0, length, start);
|
|
202
|
+
if (result.bytesRead !== length)
|
|
203
|
+
throw new ServiceError('CONFLICT', '文件在读取期间已变化,请重试。');
|
|
204
|
+
}
|
|
205
|
+
const after = await handle.stat();
|
|
206
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs)
|
|
207
|
+
throw new ServiceError('CONFLICT', '文件在读取期间已变化,请重试。');
|
|
208
|
+
return { bytes, start, end, total: before.size };
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
await handle.close();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async read(projectId, path, maxBytes = PROJECT_FILE_PREVIEW_MAX_BYTES) {
|
|
215
|
+
return (await this.readRange(projectId, path, undefined, maxBytes)).bytes;
|
|
216
|
+
}
|
|
217
|
+
/** Verify a live binary deliverable without retaining its entire contents. */
|
|
218
|
+
async fingerprint(projectId, path, expectedBytes) {
|
|
219
|
+
if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0)
|
|
220
|
+
throw new ServiceError('INVALID_REQUEST', 'Invalid project output size');
|
|
221
|
+
return projectFileIo.run(async () => {
|
|
222
|
+
const file = await this.resolve(projectId, path, 'file');
|
|
223
|
+
const handle = await fs.open(file.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | constants.O_NONBLOCK);
|
|
224
|
+
try {
|
|
225
|
+
const before = await handle.stat({ bigint: true });
|
|
226
|
+
if (!before.isFile() || before.size !== BigInt(expectedBytes))
|
|
227
|
+
throw new ServiceError('INVALID_REQUEST', 'Project output size mismatch');
|
|
228
|
+
const hash = createHash('sha256');
|
|
229
|
+
const chunk = Buffer.alloc(1024 * 1024);
|
|
230
|
+
const prefix = Buffer.alloc(Math.min(expectedBytes, 64 * 1024));
|
|
231
|
+
let offset = 0;
|
|
232
|
+
while (offset < expectedBytes) {
|
|
233
|
+
const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, expectedBytes - offset), offset);
|
|
234
|
+
if (!bytesRead)
|
|
235
|
+
throw new ServiceError('CONFLICT', 'Project output changed during verification');
|
|
236
|
+
hash.update(chunk.subarray(0, bytesRead));
|
|
237
|
+
if (offset < prefix.length)
|
|
238
|
+
chunk.copy(prefix, offset, 0, Math.min(bytesRead, prefix.length - offset));
|
|
239
|
+
offset += bytesRead;
|
|
240
|
+
}
|
|
241
|
+
const after = await handle.stat({ bigint: true });
|
|
242
|
+
const current = await this.resolve(projectId, path, 'file');
|
|
243
|
+
const linked = await fs.stat(current.path, { bigint: true });
|
|
244
|
+
if (current.path !== file.path || [after, linked].some(info => info.dev !== before.dev || info.ino !== before.ino || info.size !== before.size || info.mtimeNs !== before.mtimeNs || info.ctimeNs !== before.ctimeNs))
|
|
245
|
+
throw new ServiceError('CONFLICT', 'Project output changed during verification');
|
|
246
|
+
return { sha256: hash.digest('hex'), prefix, changeToken: [before.dev, before.ino, before.size, before.mtimeNs].join(':') };
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
await handle.close();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
async readLinkedFile(projectId, rawPath, runsDirectory, options = {}) {
|
|
254
|
+
return projectFileIo.run(async () => {
|
|
255
|
+
const projectRoot = await this.root(projectId);
|
|
256
|
+
let runsRoot;
|
|
257
|
+
try {
|
|
258
|
+
runsRoot = await fs.realpath(runsDirectory);
|
|
259
|
+
}
|
|
260
|
+
catch (cause) {
|
|
261
|
+
throw new ServiceError('PROJECT_UNAVAILABLE', '无法查看此文件。', { cause });
|
|
262
|
+
}
|
|
263
|
+
const input = linkedPath(rawPath);
|
|
264
|
+
const candidates = [];
|
|
265
|
+
if (isAbsolute(input.path) || win32.isAbsolute(input.path))
|
|
266
|
+
candidates.push(input.path);
|
|
267
|
+
if (input.projectFallback) {
|
|
268
|
+
const relativePath = input.path.replace(/^[\/]+/, '').replaceAll('\\', '/');
|
|
269
|
+
try {
|
|
270
|
+
candidates.push(resolve(projectRoot, normalizeProjectPath(relativePath)));
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (!candidates.length)
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (!candidates.length) {
|
|
278
|
+
const relativePath = input.path.replaceAll('\\', '/');
|
|
279
|
+
candidates.push(resolve(projectRoot, normalizeProjectPath(relativePath)));
|
|
280
|
+
}
|
|
281
|
+
let target;
|
|
282
|
+
let scope;
|
|
283
|
+
for (const candidate of candidates) {
|
|
284
|
+
try {
|
|
285
|
+
const canonical = await fs.realpath(candidate);
|
|
286
|
+
if (inside(projectRoot, canonical)) {
|
|
287
|
+
target = canonical;
|
|
288
|
+
scope = 'project';
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
if (inside(runsRoot, canonical)) {
|
|
292
|
+
target = canonical;
|
|
293
|
+
scope = 'run';
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
throw new ServiceError('INVALID_REQUEST', '无法查看此文件。');
|
|
297
|
+
}
|
|
298
|
+
catch (cause) {
|
|
299
|
+
if (cause instanceof ServiceError)
|
|
300
|
+
throw cause;
|
|
301
|
+
if (cause.code !== 'ENOENT')
|
|
302
|
+
throw new ServiceError('PROJECT_UNAVAILABLE', '无法查看此文件。', { cause });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (!target || !scope)
|
|
306
|
+
throw new ServiceError('NOT_FOUND', '无法查看此文件。');
|
|
307
|
+
const handle = await fs.open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)).catch(cause => {
|
|
308
|
+
throw new ServiceError('PROJECT_UNAVAILABLE', '无法查看此文件。', { cause });
|
|
309
|
+
});
|
|
310
|
+
try {
|
|
311
|
+
const before = await handle.stat();
|
|
312
|
+
if (!before.isFile())
|
|
313
|
+
throw new ServiceError('INVALID_REQUEST', '无法查看此文件。');
|
|
314
|
+
if (!options.metadataOnly && !options.range && before.size > PROJECT_FILE_PREVIEW_MAX_BYTES)
|
|
315
|
+
throw new ServiceError('PAYLOAD_TOO_LARGE', '文件超过 50 MiB,无法预览。');
|
|
316
|
+
const canonical = await fs.realpath(target);
|
|
317
|
+
const linked = await fs.stat(canonical);
|
|
318
|
+
const allowed = scope === 'project' ? inside(projectRoot, canonical) : inside(runsRoot, canonical);
|
|
319
|
+
if (!allowed || canonical !== target || linked.dev !== before.dev || linked.ino !== before.ino)
|
|
320
|
+
throw new ServiceError('CONFLICT', '文件在读取前已变化,请重试。');
|
|
321
|
+
const start = options.range?.start ?? 0;
|
|
322
|
+
const end = Math.min(options.range?.end ?? before.size - 1, before.size - 1);
|
|
323
|
+
if (options.range && (!Number.isSafeInteger(start) || !Number.isSafeInteger(options.range.end) || start < 0 || start >= before.size || end < start || end - start + 1 > 4 * 1024 * 1024))
|
|
324
|
+
throw new ServiceError('INVALID_REQUEST', '文件字节范围无效或超过 4 MiB。');
|
|
325
|
+
const bytes = Buffer.alloc(options.metadataOnly ? 0 : Math.max(0, end - start + 1));
|
|
326
|
+
if (bytes.length) {
|
|
327
|
+
const result = await handle.read(bytes, 0, bytes.length, start);
|
|
328
|
+
if (result.bytesRead !== bytes.length)
|
|
329
|
+
throw new ServiceError('CONFLICT', '文件在读取期间已变化,请重试。');
|
|
330
|
+
}
|
|
331
|
+
const after = await handle.stat();
|
|
332
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs)
|
|
333
|
+
throw new ServiceError('CONFLICT', '文件在读取期间已变化,请重试。');
|
|
334
|
+
const name = basename(target);
|
|
335
|
+
return { name, mime: classifyProjectFile(name).mime, bytes, total: before.size, start, end, scope, ...(scope === 'project' ? { relativePath: relative(projectRoot, target).split(sep).join('/') } : {}) };
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
await handle.close();
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
async readLinkedImageThumbnail(projectId, rawPath, runsDirectory, size) {
|
|
343
|
+
const file = await this.readLinkedFile(projectId, rawPath, runsDirectory);
|
|
344
|
+
if (!file.mime.startsWith('image/'))
|
|
345
|
+
throw new ServiceError('INVALID_REQUEST', '仅支持图片文件缩略图。');
|
|
346
|
+
try {
|
|
347
|
+
const thumbnail = await resizeImageThumbnail(file.bytes, file.mime, size);
|
|
348
|
+
return { ...file, ...thumbnail };
|
|
349
|
+
}
|
|
350
|
+
catch (cause) {
|
|
351
|
+
throw new ServiceError('INVALID_REQUEST', '无法生成图片缩略图。', { cause });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async thumbnail(projectId, path, size = 320) {
|
|
355
|
+
const file = await this.resolve(projectId, path, 'file');
|
|
356
|
+
if (isVideoPath(file.relativePath))
|
|
357
|
+
return videoThumbnailIo.run(() => this.videoThumbnail(projectId, file.relativePath, size));
|
|
358
|
+
const classified = classifyProjectFile(file.relativePath);
|
|
359
|
+
if (!classified.mime.startsWith('image/'))
|
|
360
|
+
throw new ServiceError('INVALID_REQUEST', '仅支持图片或视频项目文件缩略图。');
|
|
361
|
+
const bytes = await this.read(projectId, file.relativePath);
|
|
362
|
+
return resizeImageThumbnail(bytes, classified.mime, size);
|
|
363
|
+
}
|
|
364
|
+
async videoThumbnail(projectId, path, requestedSize) {
|
|
365
|
+
const size = [80, 160, 320].includes(requestedSize) ? requestedSize : 320;
|
|
366
|
+
// Download tools commonly save an identically named cover alongside a video.
|
|
367
|
+
// Resolve every candidate through the same project-boundary checks.
|
|
368
|
+
const stem = path.slice(0, path.lastIndexOf('.'));
|
|
369
|
+
for (const extension of ['jpg', 'jpeg', 'png', 'webp']) {
|
|
370
|
+
try {
|
|
371
|
+
const cover = await this.read(projectId, stem + '.' + extension, 8 * 1024 * 1024);
|
|
372
|
+
return { bytes: await sharp(cover).rotate().resize({ width: size, height: size, fit: 'inside', withoutEnlargement: true }).webp({ quality: 75 }).toBuffer(), mime: 'image/webp' };
|
|
373
|
+
}
|
|
374
|
+
catch { /* Missing, invalid or inaccessible covers fall back to a frame. */ }
|
|
375
|
+
}
|
|
376
|
+
const file = await this.resolve(projectId, path, 'file');
|
|
377
|
+
const before = await fs.stat(file.path, { bigint: true });
|
|
378
|
+
const key = [file.path, before.dev, before.ino, before.size, before.mtimeNs, before.ctimeNs, size].join(':');
|
|
379
|
+
const cached = this.videoFrames.get(key);
|
|
380
|
+
if (cached)
|
|
381
|
+
return cached;
|
|
382
|
+
try {
|
|
383
|
+
// ffmpeg reads the local file directly; no full-video Buffer or HTTP download.
|
|
384
|
+
const bytes = await extractVideoThumbnail(file.path, size);
|
|
385
|
+
const current = await this.resolve(projectId, path, 'file');
|
|
386
|
+
const after = await fs.stat(current.path, { bigint: true });
|
|
387
|
+
if (current.path !== file.path || after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs)
|
|
388
|
+
throw new ServiceError('CONFLICT', '视频文件在生成封面时已变化,请重试。');
|
|
389
|
+
const result = { bytes, mime: 'image/webp' };
|
|
390
|
+
if (this.videoFrames.size >= 32)
|
|
391
|
+
this.videoFrames.delete(this.videoFrames.keys().next().value);
|
|
392
|
+
this.videoFrames.set(key, result);
|
|
393
|
+
return result;
|
|
394
|
+
}
|
|
395
|
+
catch (cause) {
|
|
396
|
+
throw new ServiceError('INVALID_REQUEST', '无法生成视频封面;请提供同名图片封面,或确认运行时可使用 FFmpeg。', { cause });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async list(projectId, path, showHidden, limit = 200, cursor) {
|
|
400
|
+
return projectFileIo.run(() => this.listImpl(projectId, path, showHidden, limit, cursor));
|
|
401
|
+
}
|
|
402
|
+
async listImpl(projectId, path, showHidden, limit = 200, cursor) {
|
|
403
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200 || (cursor !== undefined && cursor !== null && typeof cursor !== 'string'))
|
|
404
|
+
throw new ServiceError('INVALID_REQUEST', '无效的文件分页参数。');
|
|
405
|
+
const { path: directory, relativePath } = await this.resolve(projectId, path, 'directory');
|
|
406
|
+
const names = await fs.readdir(directory, { withFileTypes: true });
|
|
407
|
+
const entries = [];
|
|
408
|
+
const fingerprints = [];
|
|
409
|
+
const root = await this.root(projectId);
|
|
410
|
+
const platformHidden = await this.hiddenFiles(directory);
|
|
411
|
+
for (const entry of names) {
|
|
412
|
+
const hidden = entry.name.startsWith('.') || platformHidden.has(entry.name.toLocaleLowerCase());
|
|
413
|
+
if (hidden && !showHidden)
|
|
414
|
+
continue;
|
|
415
|
+
const childPath = relativePath ? relativePath + '/' + entry.name : entry.name;
|
|
416
|
+
try {
|
|
417
|
+
const target = await fs.realpath(join(directory, entry.name));
|
|
418
|
+
if (!inside(root, target))
|
|
419
|
+
continue;
|
|
420
|
+
const stat = await fs.stat(target);
|
|
421
|
+
if (!stat.isFile() && !stat.isDirectory())
|
|
422
|
+
continue;
|
|
423
|
+
entries.push({ name: entry.name, relativePath: childPath, kind: stat.isDirectory() ? 'directory' : 'file', hidden, ...(stat.isFile() ? { bytes: stat.size } : {}) });
|
|
424
|
+
fingerprints.push([entry.name, stat.isDirectory() ? 'd' : 'f', Number(hidden), stat.size, stat.mtimeMs].join('\u0000'));
|
|
425
|
+
}
|
|
426
|
+
catch (cause) {
|
|
427
|
+
if (cause.code === 'ENOENT')
|
|
428
|
+
continue;
|
|
429
|
+
throw new ServiceError('PROJECT_UNAVAILABLE', '项目目录读取失败,请重试。', { cause });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
entries.sort((a, b) => Number(a.kind === 'file') - Number(b.kind === 'file') || a.name.localeCompare(b.name) || a.relativePath.localeCompare(b.relativePath));
|
|
433
|
+
fingerprints.sort();
|
|
434
|
+
const fingerprint = createHash('sha256').update(fingerprints.join('\u0001')).digest('hex');
|
|
435
|
+
const decoded = cursor ? decodeListCursor(cursor) : undefined;
|
|
436
|
+
if (decoded && (decoded.path !== relativePath || decoded.showHidden !== showHidden))
|
|
437
|
+
throw new ServiceError('INVALID_REQUEST', '文件分页游标不属于当前目录或筛选条件。');
|
|
438
|
+
if (decoded && decoded.fingerprint !== fingerprint)
|
|
439
|
+
throw new ServiceError('CURSOR_EXPIRED', '目录内容已变化,请从第一页重新读取。');
|
|
440
|
+
const offset = decoded?.offset ?? 0;
|
|
441
|
+
if (offset > entries.length)
|
|
442
|
+
throw new ServiceError('CURSOR_EXPIRED', '目录内容已变化,请从第一页重新读取。');
|
|
443
|
+
const next = offset + limit;
|
|
444
|
+
const nextCursor = next < entries.length ? Buffer.from(JSON.stringify({ v: 1, path: relativePath, showHidden, fingerprint, offset: next })).toString('base64url') : null;
|
|
445
|
+
return { path: relativePath, items: entries.slice(offset, next), nextCursor };
|
|
446
|
+
}
|
|
447
|
+
async search(projectId, query, showHidden, limit = 50, cursor, signal) {
|
|
448
|
+
const normalizedQuery = query.trim().toLocaleLowerCase();
|
|
449
|
+
if (!normalizedQuery || normalizedQuery.length > 256 || !Number.isSafeInteger(limit) || limit < 1 || limit > 50 || (cursor !== undefined && cursor !== null && typeof cursor !== 'string'))
|
|
450
|
+
throw new ServiceError('INVALID_REQUEST', '无效的文件搜索参数。');
|
|
451
|
+
const now = Date.now();
|
|
452
|
+
for (const [key, value] of this.searches)
|
|
453
|
+
if (value.expiresAt <= now)
|
|
454
|
+
this.searches.delete(key);
|
|
455
|
+
let token = cursor ?? randomUUID();
|
|
456
|
+
let session = cursor ? this.searches.get(cursor) : undefined;
|
|
457
|
+
if (cursor && !session)
|
|
458
|
+
throw new ServiceError('CURSOR_EXPIRED', '文件搜索游标已过期,请重新搜索。');
|
|
459
|
+
if (session && (session.projectId !== projectId || session.query !== normalizedQuery || session.showHidden !== showHidden))
|
|
460
|
+
throw new ServiceError('INVALID_REQUEST', '文件搜索游标不属于当前查询。');
|
|
461
|
+
if (!session) {
|
|
462
|
+
session = { projectId, query: normalizedQuery, showHidden, queue: [''], seen: new Set(), currentDirectory: undefined, nextListCursor: undefined, pending: [], pendingIndex: 0, expiresAt: now + 120_000 };
|
|
463
|
+
this.searches.set(token, session);
|
|
464
|
+
}
|
|
465
|
+
const items = [];
|
|
466
|
+
const startedAt = Date.now();
|
|
467
|
+
let scanned = 0;
|
|
468
|
+
try {
|
|
469
|
+
while (items.length < limit && scanned < 2_000 && Date.now() - startedAt < 100) {
|
|
470
|
+
if (signal?.aborted)
|
|
471
|
+
throw new ServiceError('CONFLICT', '项目文件搜索已取消。');
|
|
472
|
+
if (session.pendingIndex < session.pending.length) {
|
|
473
|
+
const item = session.pending[session.pendingIndex++];
|
|
474
|
+
scanned++;
|
|
475
|
+
if (item.kind === 'directory')
|
|
476
|
+
session.queue.push(item.relativePath);
|
|
477
|
+
if (item.name.toLocaleLowerCase().includes(session.query) || item.relativePath.toLocaleLowerCase().includes(session.query))
|
|
478
|
+
items.push(item);
|
|
479
|
+
if (session.pendingIndex >= session.pending.length && session.nextListCursor === null)
|
|
480
|
+
session.currentDirectory = undefined;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
session.pending = [];
|
|
484
|
+
session.pendingIndex = 0;
|
|
485
|
+
if (session.currentDirectory !== undefined && session.nextListCursor) {
|
|
486
|
+
const page = await this.list(projectId, session.currentDirectory, showHidden, 200, session.nextListCursor);
|
|
487
|
+
session.pending = page.items;
|
|
488
|
+
session.nextListCursor = page.nextCursor;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
session.currentDirectory = undefined;
|
|
492
|
+
session.nextListCursor = undefined;
|
|
493
|
+
let directory;
|
|
494
|
+
while (session.queue.length && directory === undefined) {
|
|
495
|
+
const candidate = session.queue.shift();
|
|
496
|
+
const canonical = (await this.resolve(projectId, candidate, 'directory')).path;
|
|
497
|
+
if (!session.seen.has(canonical)) {
|
|
498
|
+
session.seen.add(canonical);
|
|
499
|
+
directory = candidate;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (directory === undefined)
|
|
503
|
+
break;
|
|
504
|
+
session.currentDirectory = directory;
|
|
505
|
+
const page = await this.list(projectId, directory, showHidden, 200);
|
|
506
|
+
session.pending = page.items;
|
|
507
|
+
session.nextListCursor = page.nextCursor;
|
|
508
|
+
if (!session.pending.length && session.nextListCursor === null)
|
|
509
|
+
session.currentDirectory = undefined;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
if (error instanceof ServiceError && error.code === 'CURSOR_EXPIRED')
|
|
514
|
+
this.searches.delete(token);
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
session.expiresAt = Date.now() + 120_000;
|
|
518
|
+
const hasMore = session.pendingIndex < session.pending.length || session.currentDirectory !== undefined || session.queue.length > 0;
|
|
519
|
+
if (!hasMore)
|
|
520
|
+
this.searches.delete(token);
|
|
521
|
+
return { items, hasMore, nextCursor: hasMore ? token : null };
|
|
522
|
+
}
|
|
523
|
+
async stat(projectId, paths) {
|
|
524
|
+
return projectFileIo.run(() => this.statImpl(projectId, paths));
|
|
525
|
+
}
|
|
526
|
+
async statImpl(projectId, paths) {
|
|
527
|
+
if (!Array.isArray(paths) || paths.length > 100)
|
|
528
|
+
throw new ServiceError('INVALID_REQUEST', '最多校验 100 个路径。');
|
|
529
|
+
const observations = await Promise.all(paths.map(async (path) => {
|
|
530
|
+
const normalized = normalizeProjectPath(path);
|
|
531
|
+
if (!normalized)
|
|
532
|
+
throw new ServiceError('INVALID_REQUEST', '状态校验需要文件路径。');
|
|
533
|
+
const name = normalized.split('/').at(-1);
|
|
534
|
+
const mime = classifyProjectFile(name).mime;
|
|
535
|
+
try {
|
|
536
|
+
const resolved = await this.resolve(projectId, normalized, 'file');
|
|
537
|
+
const info = await fs.stat(resolved.path, { bigint: true });
|
|
538
|
+
return { path: normalized, state: 'available', name, mime, bytes: Number(info.size), changeToken: [info.dev, info.ino, info.size, info.mtimeNs].join(':') };
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
if (error instanceof ServiceError && (error.code === 'NOT_FOUND' || error.code === 'INVALID_REQUEST' && error.message === '项目路径类型不匹配。'))
|
|
542
|
+
return { path: normalized, state: 'missing', name, mime, bytes: null, changeToken: null };
|
|
543
|
+
if (error instanceof ServiceError && error.code === 'INVALID_REQUEST')
|
|
544
|
+
throw error;
|
|
545
|
+
return { path: normalized, state: 'unavailable', name, mime, bytes: null, changeToken: null };
|
|
546
|
+
}
|
|
547
|
+
}));
|
|
548
|
+
atomic(this.db, () => {
|
|
549
|
+
const statement = this.db.prepare('INSERT INTO project_file_observations(project_id,relative_path,state,name,mime,bytes,change_token,observed_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(project_id,relative_path) DO UPDATE SET state=excluded.state,name=excluded.name,mime=excluded.mime,bytes=excluded.bytes,change_token=excluded.change_token,observed_at=excluded.observed_at');
|
|
550
|
+
const observedAt = Date.now();
|
|
551
|
+
for (const item of observations)
|
|
552
|
+
statement.run(projectId, item.path, item.state, item.name, item.mime, item.bytes, item.changeToken, observedAt);
|
|
553
|
+
});
|
|
554
|
+
return observations;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
//# sourceMappingURL=project-files.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function projectLockPort(canonicalPath: string): number;
|
|
2
|
+
/** Kernel lifetime lock shared by service roots. Port collision is contention,
|
|
3
|
+
* never evidence that a PID can be killed. Does not write into the source tree. */
|
|
4
|
+
export declare function acquireProjectLock(canonicalPath: string): Promise<(() => void) | undefined>;
|