@easyai-canvas/canvas-plugin-sdk 0.1.0

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.
@@ -0,0 +1,296 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+ import { fork } from 'node:child_process';
4
+ import { RuntimeStore } from './store.js';
5
+ import { acquireInstanceLock } from './instance-lock.js';
6
+ import { fieldsConfig } from './plugin-config.js';
7
+ import { PROTOCOL, ContractError, validateManifest, validateExecution, validateResult, validatePluginRuntimeContext } from '../protocol/index.js';
8
+ const defaultPlugins = process.env.PLUGIN_BUILTINS === '0'
9
+ ? []
10
+ : (await import('../plugins/catalog.js')).getPlugins();
11
+ export class Runtime {
12
+ presentations;
13
+ executionEntries;
14
+ store;
15
+ plugins;
16
+ queue = new Map();
17
+ active = new Map();
18
+ /** Task grants stay in memory only. They are deliberately excluded from SQLite records and snapshots. */
19
+ hostFiles = new Map();
20
+ draining = false;
21
+ releaseInstanceLock;
22
+ constructor(path, plugins = defaultPlugins, presentations = {}, executionEntries = {}) {
23
+ this.presentations = presentations;
24
+ this.executionEntries = executionEntries;
25
+ this.plugins = plugins;
26
+ if (new Set(plugins.map(p => p.manifest.id)).size !== plugins.length)
27
+ throw new ContractError('插件标识重复');
28
+ this.releaseInstanceLock = acquireInstanceLock(path);
29
+ try {
30
+ this.store = new RuntimeStore(path);
31
+ this.store.recover();
32
+ for (const item of this.store.queued())
33
+ this.enqueue(item.request.pluginId, item.id);
34
+ const catalogPath = new URL('../plugins/catalog' + (import.meta.url.endsWith('.ts') ? '.ts' : '.js'), import.meta.url);
35
+ this.store.syncDeployment(createHash('sha256').update(JSON.stringify(this.plugins.map(p => p.manifest))).update(existsSync(catalogPath) ? readFileSync(catalogPath) : '').update(existsSync('public/ui/manifest.json') ? readFileSync('public/ui/manifest.json') : '').digest('hex'));
36
+ for (const pluginId of this.queue.keys())
37
+ this.pump(pluginId);
38
+ }
39
+ catch (error) {
40
+ this.releaseInstanceLock();
41
+ throw error;
42
+ }
43
+ }
44
+ plugin(id) { const p = this.plugins.find(p => p.manifest.id === id); if (!p)
45
+ throw new ContractError('插件不存在'); return p; }
46
+ configure(id, patch) {
47
+ const p = this.plugin(id), prev = this.store.getPlugin(id);
48
+ if (patch.enabled !== undefined && typeof patch.enabled !== 'boolean')
49
+ throw new ContractError('enabled 必须是布尔值');
50
+ if (patch.expectedVersion !== undefined && (!Number.isSafeInteger(patch.expectedVersion) || patch.expectedVersion < 0))
51
+ throw new ContractError('expectedVersion 必须是非负整数');
52
+ const config = fieldsConfig(p.manifest.configFields ?? [], prev.config, patch.config ?? {});
53
+ if (patch.enabled ?? prev.enabled)
54
+ for (const f of p.manifest.configFields ?? [])
55
+ if (f.required && (config[f.key] === undefined || config[f.key] === ''))
56
+ throw new ContractError(`缺少配置 ${f.label}`);
57
+ const manifest = validateManifest(p.resolveManifest?.(structuredClone(config), structuredClone(p.manifest)) ?? p.manifest);
58
+ if (manifest.id !== id)
59
+ throw new ContractError('配置不能改变插件标识');
60
+ this.store.setPlugin(id, { enabled: patch.enabled, config, expectedVersion: patch.expectedVersion });
61
+ return this.catalog().plugins.find(p => p.manifest.id === id);
62
+ }
63
+ catalog() {
64
+ return { protocol: PROTOCOL, revision: this.store.revision, instanceId: this.store.instanceId, plugins: this.plugins.map(p => {
65
+ const stored = this.store.getPlugin(p.manifest.id);
66
+ const config = fieldsConfig(p.manifest.configFields ?? [], stored.config, {});
67
+ const manifest = validateManifest(p.resolveManifest?.(structuredClone(config), structuredClone(p.manifest)) ?? structuredClone(p.manifest));
68
+ if (manifest.id !== p.manifest.id)
69
+ throw new ContractError('配置不能改变插件标识');
70
+ const secretConfigured = Object.fromEntries((manifest.configFields ?? [])
71
+ .filter(field => field.type === 'secret')
72
+ .map(field => [field.key, typeof stored.config[field.key] === 'string' && Boolean(stored.config[field.key])]));
73
+ for (const f of manifest.configFields ?? [])
74
+ if (f.type === 'secret')
75
+ delete config[f.key];
76
+ return { ...this.presentations[p.manifest.id], manifest, enabled: stored.enabled, config, configVersion: stored.configVersion, secretConfigured, configCheckAvailable: Boolean(p.checkConfig) };
77
+ }) };
78
+ }
79
+ async checkConfig(id) {
80
+ const plugin = this.plugin(id);
81
+ const state = this.store.getPlugin(id);
82
+ const config = fieldsConfig(plugin.manifest.configFields ?? [], state.config, {});
83
+ if (!plugin.checkConfig)
84
+ return { supported: false, validation: 'passed', message: '配置格式校验通过;插件未声明外部连接检查能力' };
85
+ return { supported: true, validation: 'passed', result: await plugin.checkConfig(structuredClone(config)) };
86
+ }
87
+ query(id, name, input, context, signal) {
88
+ const p = this.plugin(id), state = this.store.getPlugin(id);
89
+ if (!state.enabled)
90
+ throw new ContractError('插件已停用', 409, 'PLUGIN_DISABLED');
91
+ if (!p.queries?.[name])
92
+ throw new ContractError('查询不存在');
93
+ return p.queries[name](input, state.config, validatePluginRuntimeContext(context), signal);
94
+ }
95
+ normalize(id, nodeType, parameters) {
96
+ const p = this.plugin(id);
97
+ if (!this.store.getPlugin(id).enabled)
98
+ throw new ContractError('插件已停用', 409, 'PLUGIN_DISABLED');
99
+ return p.normalizeParameters?.(nodeType, parameters) ?? parameters;
100
+ }
101
+ submit(request) {
102
+ const p = this.plugin(request.pluginId);
103
+ const node = p.manifest.contributes.nodes?.find(n => n.type === request.nodeType);
104
+ if (!node || !['remote', 'confirmed'].includes(node.execution))
105
+ throw new ContractError('节点不支持后台执行');
106
+ validateExecution(request, node);
107
+ const persistedRequest = structuredClone(request);
108
+ delete persistedRequest.host;
109
+ // Repeated acceptance is stable even when the plugin was disabled afterwards. A
110
+ // fresh task grant may be supplied after a process restart without changing the
111
+ // idempotent business request.
112
+ const existing = this.store.getExecution(request.executionId);
113
+ if (existing) {
114
+ if (request.host?.files && ['queued', 'running'].includes(existing.status))
115
+ this.hostFiles.set(request.executionId, structuredClone(request.host.files));
116
+ return this.store.accept(persistedRequest);
117
+ }
118
+ if (this.draining)
119
+ throw new ContractError('服务正在排空', 503, 'DRAINING');
120
+ const state = this.store.getPlugin(request.pluginId);
121
+ if (!state.enabled)
122
+ throw new ContractError('插件已停用', 409, 'PLUGIN_DISABLED');
123
+ const waiting = this.store.queued().filter(item => item.request.pluginId === request.pluginId && item.request.nodeType === request.nodeType);
124
+ const waitingForUser = waiting.filter(item => item.request.context.userId === request.context.userId).length;
125
+ if (waitingForUser >= (node.runtime?.maxQueuedPerUser ?? Number.POSITIVE_INFINITY))
126
+ throw new ContractError('当前用户的插件排队任务已达上限', 429, 'USER_QUEUE_LIMIT');
127
+ if (waiting.length >= (node.runtime?.maxQueued ?? Number.POSITIVE_INFINITY))
128
+ throw new ContractError('插件排队任务已达上限', 429, 'QUEUE_LIMIT');
129
+ const accepted = this.store.accept(persistedRequest, {
130
+ config: structuredClone(state.config),
131
+ release: p.manifest.release,
132
+ safeRetry: node.runtime?.safeRetry === true,
133
+ maxAttempts: node.runtime?.safeRetry ? node.runtime.maxAttempts ?? 3 : 1,
134
+ ...(this.executionEntries[request.pluginId] ? {
135
+ packageEntry: this.executionEntries[request.pluginId].entry,
136
+ packageDigest: this.executionEntries[request.pluginId].digest,
137
+ packageEntryHash: this.executionEntries[request.pluginId].entryHash,
138
+ } : {}),
139
+ });
140
+ if (request.host?.files)
141
+ this.hostFiles.set(request.executionId, structuredClone(request.host.files));
142
+ this.enqueue(request.pluginId, request.executionId);
143
+ this.pump(request.pluginId);
144
+ return accepted;
145
+ }
146
+ enqueue(pluginId, executionId) {
147
+ const queue = this.queue.get(pluginId) ?? [];
148
+ if (!queue.includes(executionId))
149
+ this.queue.set(pluginId, [...queue, executionId]);
150
+ }
151
+ cancel(id) {
152
+ const record = this.store.getExecution(id);
153
+ if (!record)
154
+ throw new ContractError('任务不存在', 404, 'TASK_NOT_FOUND');
155
+ this.store.update(id, { cancelRequested: true });
156
+ const running = this.active.get(record.pluginId);
157
+ if (running?.executionId === id) {
158
+ running.child.send({ type: 'cancel' });
159
+ running.forceTimer ??= setTimeout(() => this.killWorker(running.child, 'SIGKILL'), 5000);
160
+ running.forceTimer.unref();
161
+ }
162
+ else {
163
+ this.store.update(id, { status: 'cancelled' });
164
+ this.hostFiles.delete(id);
165
+ }
166
+ return this.store.getExecution(id);
167
+ }
168
+ pump(pluginId) {
169
+ if (this.draining || this.active.has(pluginId))
170
+ return;
171
+ const id = this.queue.get(pluginId)?.shift();
172
+ if (!id)
173
+ return;
174
+ if (this.store.getExecution(id)?.status !== 'queued') {
175
+ this.pump(pluginId);
176
+ return;
177
+ }
178
+ const request = this.store.getRequest(id);
179
+ const node = this.plugins.find(plugin => plugin.manifest.id === pluginId)?.manifest.contributes.nodes?.find(n => n.type === request.nodeType);
180
+ if (!node) {
181
+ this.store.update(id, { status: 'failed', phase: '插件不可用', error: { code: 'PLUGIN_UNAVAILABLE', message: '该任务所需的插件或节点已移除,请检查插件配置后重新创建任务' } });
182
+ this.hostFiles.delete(id);
183
+ this.pump(pluginId);
184
+ return;
185
+ }
186
+ const isTs = import.meta.url.endsWith('.ts');
187
+ const child = fork(new URL(isTs ? './worker.ts' : './worker.js', import.meta.url), [], { execArgv: isTs ? ['--import', 'tsx'] : [], stdio: ['ignore', 'ignore', 'inherit', 'ipc'], serialization: 'json', detached: process.platform !== 'win32' });
188
+ const claimed = this.store.claim(id);
189
+ if (!claimed) {
190
+ child.kill();
191
+ this.pump(pluginId);
192
+ return;
193
+ }
194
+ this.active.set(pluginId, { child, executionId: id });
195
+ let ended = false;
196
+ const finish = (patch, retryable = false) => {
197
+ if (ended)
198
+ return;
199
+ ended = true;
200
+ clearTimeout(timer);
201
+ const active = this.active.get(pluginId);
202
+ if (active?.forceTimer)
203
+ clearTimeout(active.forceTimer);
204
+ const current = this.store.getExecution(id);
205
+ const snapshot = this.store.getSnapshot(id);
206
+ const cancelled = current.cancelRequested;
207
+ if (retryable && snapshot.safeRetry && current.attempt < snapshot.maxAttempts && !cancelled) {
208
+ this.store.requeue(id, patch.error);
209
+ this.enqueue(pluginId, id);
210
+ }
211
+ else {
212
+ this.store.update(id, patch.status === 'failed' && patch.error?.code === 'PLUGIN_ERROR' && cancelled ? { status: 'cancelled', phase: '已取消', error: patch.error } : patch);
213
+ this.hostFiles.delete(id);
214
+ }
215
+ this.killWorker(child, 'SIGTERM');
216
+ this.active.delete(pluginId);
217
+ this.pump(pluginId);
218
+ };
219
+ const timer = setTimeout(() => finish({ status: 'failed', phase: '执行超时', error: { code: 'TIMEOUT', message: '插件执行超时,请核实外部副作用后重试' } }), node.timeoutMs ?? 300_000);
220
+ child.on('message', (m) => {
221
+ if (ended)
222
+ return;
223
+ if (m.type === 'progress' && Number.isFinite(m.value))
224
+ this.store.update(id, { progress: Math.max(0, Math.min(100, m.value)), ...(typeof m.phase === 'string' && m.phase ? { phase: m.phase.slice(0, 100) } : {}) });
225
+ if (m.type === 'error')
226
+ finish({ status: 'failed', phase: '执行失败', error: { code: 'PLUGIN_ERROR', message: String(m.message).slice(0, 2000) } }, m.retryable === true);
227
+ if (m.type === 'result') {
228
+ try {
229
+ const result = validateResult(m.result, node);
230
+ finish({ status: 'succeeded', phase: result.phase ?? '完成', progress: 100, result });
231
+ }
232
+ catch (e) {
233
+ finish({ status: 'failed', phase: '结果无效', error: { code: 'INVALID_RESULT', message: e.message } });
234
+ }
235
+ }
236
+ });
237
+ child.on('exit', () => finish({ status: 'failed', phase: '进程退出', error: { code: 'WORKER_EXIT', message: '插件进程意外退出' } }, true));
238
+ child.on('error', e => finish({ status: 'failed', phase: '进程错误', error: { code: 'WORKER_ERROR', message: e.message } }, true));
239
+ const remoteFiles = this.hostFiles.get(id);
240
+ const localFiles = request.context.runtime === undefined
241
+ ? { mode: 'local' }
242
+ : { mode: 'unavailable', reason: '宿主未提供文件能力,平台执行不能降级到插件本地存储' };
243
+ child.send({
244
+ type: 'run', request, config: this.store.getSnapshot(id).config,
245
+ packageEntry: this.store.getSnapshot(id).packageEntry,
246
+ packageEntryHash: this.store.getSnapshot(id).packageEntryHash,
247
+ hostFiles: remoteFiles ? { mode: 'remote', ...remoteFiles } : localFiles,
248
+ });
249
+ }
250
+ killWorker(child, signal) {
251
+ if (!child.pid)
252
+ return;
253
+ try {
254
+ if (process.platform !== 'win32')
255
+ process.kill(-child.pid, signal);
256
+ else
257
+ child.kill(signal);
258
+ }
259
+ catch { /* The process group has already stopped. */ }
260
+ }
261
+ async close() {
262
+ this.draining = true;
263
+ const deadline = Date.now() + 30_000;
264
+ while (this.active.size && Date.now() < deadline)
265
+ await new Promise(r => setTimeout(r, 50));
266
+ for (const active of this.active.values()) {
267
+ active.child.send?.({ type: 'cancel' });
268
+ this.killWorker(active.child, 'SIGTERM');
269
+ }
270
+ const forceDeadline = Date.now() + 5_000;
271
+ while (this.active.size && Date.now() < forceDeadline)
272
+ await new Promise(r => setTimeout(r, 50));
273
+ for (const active of this.active.values())
274
+ this.killWorker(active.child, 'SIGKILL');
275
+ // Keep the store alive until exit handlers have observed the forced stop.
276
+ // Otherwise a late child `exit` event can try to requeue/update a task after
277
+ // SQLite has already been closed.
278
+ const exitDeadline = Date.now() + 2_000;
279
+ while (this.active.size && Date.now() < exitDeadline)
280
+ await new Promise(r => setTimeout(r, 25));
281
+ if (this.active.size) {
282
+ // Preserve `running` in SQLite so startup recovery applies the node's
283
+ // declared retry policy. No process callback may touch the closed store.
284
+ for (const active of this.active.values()) {
285
+ if (active.forceTimer)
286
+ clearTimeout(active.forceTimer);
287
+ active.child.removeAllListeners('message');
288
+ active.child.removeAllListeners('exit');
289
+ active.child.removeAllListeners('error');
290
+ }
291
+ this.active.clear();
292
+ }
293
+ this.store.close();
294
+ this.releaseInstanceLock();
295
+ }
296
+ }
@@ -0,0 +1,16 @@
1
+ import type { HostFileService } from '../sdk/index.js';
2
+ export type HostFilesDescriptor = {
3
+ mode: 'local';
4
+ dataDirectory?: string;
5
+ publicUrl?: string;
6
+ } | {
7
+ mode: 'remote';
8
+ baseUrl: string;
9
+ grant: string;
10
+ expiresAt: number;
11
+ path?: '/v1/plugin-artifacts';
12
+ } | {
13
+ mode: 'unavailable';
14
+ reason: string;
15
+ };
16
+ export declare function createHostFileService(descriptor: HostFilesDescriptor | undefined, executionId: string): HostFileService;
@@ -0,0 +1,208 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { copyFile, mkdir, readFile, rename, rm, stat } from 'node:fs/promises';
4
+ import { basename, dirname, join } from 'node:path';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { Writable } from 'node:stream';
7
+ function safeSegment(value) {
8
+ const segment = basename(value).replace(/[^\p{L}\p{N}._-]/gu, '_').slice(0, 160);
9
+ if (!segment || segment === '.' || segment === '..')
10
+ throw new Error('宿主文件名称无效');
11
+ return segment;
12
+ }
13
+ function safeKey(value) {
14
+ const parts = value.split('/').filter(Boolean).map(safeSegment);
15
+ if (!parts.length || parts.length > 8)
16
+ throw new Error('宿主文件标识无效');
17
+ return parts.join('/');
18
+ }
19
+ async function integrity(path) {
20
+ const hash = createHash('sha256');
21
+ await pipeline(createReadStream(path), new Writable({
22
+ write(chunk, _encoding, callback) { hash.update(chunk); callback(); },
23
+ }));
24
+ return { sha256: hash.digest('hex'), size: (await stat(path)).size };
25
+ }
26
+ class LocalHostFileService {
27
+ executionId;
28
+ root;
29
+ publicUrl;
30
+ constructor(descriptor, executionId) {
31
+ this.executionId = safeSegment(executionId);
32
+ this.root = join(descriptor.dataDirectory ?? process.env.PLUGIN_DATA_DIR ?? './data', 'files', 'artifacts', this.executionId);
33
+ this.publicUrl = (descriptor.publicUrl ?? process.env.PLUGIN_PUBLIC_URL ?? 'http://127.0.0.1:4310').replace(/\/$/, '');
34
+ }
35
+ path(key) { return join(this.root, ...safeKey(key).split('/')); }
36
+ async put(input) {
37
+ const key = safeKey(input.key);
38
+ const target = this.path(key);
39
+ const temporary = `${target}.${randomUUID()}.tmp`;
40
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
41
+ await copyFile(input.path, temporary);
42
+ await rename(temporary, target).catch(async (error) => {
43
+ await rm(temporary, { force: true });
44
+ if (error.code !== 'EEXIST')
45
+ throw error;
46
+ });
47
+ const result = await integrity(target);
48
+ return {
49
+ id: `local:${result.sha256}`,
50
+ url: `${this.publicUrl}/files/artifacts/${encodeURIComponent(this.executionId)}/${key.split('/').map(encodeURIComponent).join('/')}`,
51
+ name: basename(key),
52
+ mimeType: input.mimeType,
53
+ path: target,
54
+ ...result,
55
+ };
56
+ }
57
+ async readJson(key) {
58
+ try {
59
+ const file = this.path(key);
60
+ if ((await stat(file)).size > 5_000_000)
61
+ throw new Error('宿主 JSON 文件超过大小限制');
62
+ return JSON.parse(await readFile(file, 'utf8'));
63
+ }
64
+ catch (error) {
65
+ if (error.code === 'ENOENT')
66
+ return;
67
+ throw error;
68
+ }
69
+ }
70
+ async info(key) {
71
+ try {
72
+ const metadata = await integrity(this.path(key));
73
+ const safe = safeKey(key);
74
+ return {
75
+ id: `local:${metadata.sha256}`,
76
+ url: `${this.publicUrl}/files/artifacts/${encodeURIComponent(this.executionId)}/${safe.split('/').map(encodeURIComponent).join('/')}`,
77
+ ...metadata,
78
+ };
79
+ }
80
+ catch (error) {
81
+ if (error.code === 'ENOENT')
82
+ return;
83
+ throw error;
84
+ }
85
+ }
86
+ async exists(key) {
87
+ return Boolean(await this.info(key));
88
+ }
89
+ }
90
+ class RemoteHostFileService {
91
+ descriptor;
92
+ executionId;
93
+ baseUrl;
94
+ capabilityPath;
95
+ renewing;
96
+ constructor(descriptor, executionId) {
97
+ this.descriptor = descriptor;
98
+ this.executionId = executionId;
99
+ const url = new URL(descriptor.baseUrl);
100
+ const loopbackHttp = url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]', '::1'].includes(url.hostname);
101
+ if (url.protocol !== 'https:' && !loopbackHttp)
102
+ throw new Error('远程宿主文件能力必须使用 HTTPS 或本机回环地址');
103
+ if (url.username || url.password || url.search || url.hash)
104
+ throw new Error('远程宿主文件地址无效');
105
+ if (!descriptor.grant || descriptor.grant.length > 8_192)
106
+ throw new Error('宿主文件授权无效');
107
+ if (!Number.isSafeInteger(descriptor.expiresAt))
108
+ throw new Error('宿主文件授权有效期无效');
109
+ this.baseUrl = url.toString().replace(/\/$/, '');
110
+ this.capabilityPath = descriptor.path ?? '/v1/plugin-artifacts';
111
+ }
112
+ endpoint(key) {
113
+ return `${this.baseUrl}${this.capabilityPath}/${encodeURIComponent(this.executionId)}?key=${encodeURIComponent(safeKey(key))}`;
114
+ }
115
+ async ensureGrant() {
116
+ if (this.descriptor.expiresAt > Date.now() + 30_000)
117
+ return;
118
+ this.renewing ??= (async () => {
119
+ const response = await fetch(`${this.baseUrl}${this.capabilityPath}/${encodeURIComponent(this.executionId)}/renew`, {
120
+ method: 'POST', headers: { authorization: `Bearer ${this.descriptor.grant}` }, redirect: 'error',
121
+ });
122
+ if (!response.ok)
123
+ throw new Error(`宿主文件授权续期失败(${response.status})`);
124
+ const value = await response.json();
125
+ const expiresAt = Number(value.expiresAt);
126
+ if (typeof value.grant !== 'string' || !value.grant || value.grant.length > 8_192
127
+ || !Number.isSafeInteger(expiresAt) || expiresAt <= Date.now() || expiresAt > Date.now() + 2 * 60 * 60 * 1_000 + 5_000)
128
+ throw new Error('宿主文件授权续期响应无效');
129
+ this.descriptor.grant = value.grant;
130
+ this.descriptor.expiresAt = expiresAt;
131
+ })().finally(() => { this.renewing = undefined; });
132
+ await this.renewing;
133
+ }
134
+ async headers(extra) {
135
+ await this.ensureGrant();
136
+ return { authorization: `Bearer ${this.descriptor.grant}`, ...extra };
137
+ }
138
+ async put(input) {
139
+ const metadata = await integrity(input.path);
140
+ const response = await fetch(this.endpoint(input.key), {
141
+ method: 'PUT',
142
+ headers: await this.headers({
143
+ 'content-type': input.mimeType,
144
+ 'content-length': String(metadata.size),
145
+ 'x-content-sha256': metadata.sha256,
146
+ }),
147
+ body: createReadStream(input.path),
148
+ duplex: 'half',
149
+ redirect: 'error',
150
+ });
151
+ if (!response.ok)
152
+ throw new Error(`宿主文件保存失败(${response.status})`);
153
+ const value = await response.json();
154
+ if (typeof value.id !== 'string' || typeof value.url !== 'string' || value.sha256 !== metadata.sha256
155
+ || value.size !== metadata.size)
156
+ throw new Error('宿主文件响应无效');
157
+ return { ...metadata, id: value.id, url: value.url, name: basename(safeKey(input.key)), mimeType: input.mimeType };
158
+ }
159
+ async readJson(key) {
160
+ const response = await fetch(this.endpoint(key), { headers: await this.headers({ accept: 'application/json' }), redirect: 'error' });
161
+ if (response.status === 404)
162
+ return;
163
+ if (!response.ok)
164
+ throw new Error(`宿主文件读取失败(${response.status})`);
165
+ const length = Number(response.headers.get('content-length') ?? 0);
166
+ if (length > 5_000_000)
167
+ throw new Error('宿主 JSON 文件超过大小限制');
168
+ const buffer = await response.arrayBuffer();
169
+ if (buffer.byteLength > 5_000_000)
170
+ throw new Error('宿主 JSON 文件超过大小限制');
171
+ return JSON.parse(Buffer.from(buffer).toString('utf8'));
172
+ }
173
+ async info(key) {
174
+ const response = await fetch(this.endpoint(key), { method: 'HEAD', headers: await this.headers(), redirect: 'error' });
175
+ if (response.status === 404)
176
+ return;
177
+ if (!response.ok)
178
+ throw new Error(`宿主文件查询失败(${response.status})`);
179
+ const size = Number(response.headers.get('content-length'));
180
+ const sha256 = response.headers.get('x-content-sha256') ?? '';
181
+ const id = response.headers.get('x-resource-id') ?? '';
182
+ const url = response.headers.get('x-resource-url') ?? '';
183
+ if (!Number.isSafeInteger(size) || size < 0 || !/^[a-f0-9]{64}$/.test(sha256) || !id || !url)
184
+ throw new Error('宿主文件元数据无效');
185
+ return { id, url, size, sha256 };
186
+ }
187
+ async exists(key) {
188
+ return Boolean(await this.info(key));
189
+ }
190
+ }
191
+ class UnavailableHostFileService {
192
+ reason;
193
+ constructor(reason) {
194
+ this.reason = reason;
195
+ }
196
+ fail() { throw new Error(this.reason || '宿主未提供文件能力'); }
197
+ async put() { return this.fail(); }
198
+ async readJson() { return this.fail(); }
199
+ async info() { return this.fail(); }
200
+ async exists() { return this.fail(); }
201
+ }
202
+ export function createHostFileService(descriptor, executionId) {
203
+ if (!descriptor || descriptor.mode === 'local')
204
+ return new LocalHostFileService(descriptor ?? { mode: 'local' }, executionId);
205
+ if (descriptor.mode === 'unavailable')
206
+ return new UnavailableHostFileService(descriptor.reason);
207
+ return new RemoteHostFileService(descriptor, executionId);
208
+ }
@@ -0,0 +1 @@
1
+ export declare function acquireInstanceLock(databasePath: string): () => void;
@@ -0,0 +1,52 @@
1
+ import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ function processExists(pid) {
5
+ try {
6
+ process.kill(pid, 0);
7
+ return true;
8
+ }
9
+ catch (error) {
10
+ return error.code === 'EPERM';
11
+ }
12
+ }
13
+ export function acquireInstanceLock(databasePath) {
14
+ mkdirSync(dirname(databasePath), { recursive: true, mode: 0o700 });
15
+ const path = `${databasePath}.instance-lock`;
16
+ const record = { pid: process.pid, token: randomUUID() };
17
+ for (let attempt = 0; attempt < 2; attempt += 1) {
18
+ try {
19
+ const fd = openSync(path, 'wx', 0o600);
20
+ try {
21
+ writeFileSync(fd, JSON.stringify(record));
22
+ }
23
+ finally {
24
+ closeSync(fd);
25
+ }
26
+ return () => {
27
+ try {
28
+ const current = JSON.parse(readFileSync(path, 'utf8'));
29
+ if (current.token === record.token)
30
+ unlinkSync(path);
31
+ }
32
+ catch { /* Missing or replaced locks are not ours to remove. */ }
33
+ };
34
+ }
35
+ catch (error) {
36
+ if (error.code !== 'EEXIST')
37
+ throw error;
38
+ let owner;
39
+ try {
40
+ owner = JSON.parse(readFileSync(path, 'utf8'));
41
+ }
42
+ catch { /* stale malformed lock */ }
43
+ if (owner?.pid && processExists(owner.pid))
44
+ throw new Error('数据目录已被另一个插件服务实例使用');
45
+ try {
46
+ unlinkSync(path);
47
+ }
48
+ catch { /* another starter may have replaced it */ }
49
+ }
50
+ }
51
+ throw new Error('无法取得插件服务数据目录锁');
52
+ }
@@ -0,0 +1,44 @@
1
+ import { type PluginManifest, type PluginRuntimeContext } from '../protocol/index.js';
2
+ export type PluginPackageDescriptor = {
3
+ format: 'easyai.plugin-package/v1' | 'easyai.plugin-package/v2';
4
+ manifest: PluginManifest;
5
+ runtime: {
6
+ nodeMajor: number;
7
+ capabilities: string[];
8
+ entry: string;
9
+ queries: string[];
10
+ handlers?: string[];
11
+ };
12
+ ui: {
13
+ entry: string;
14
+ globalName: string;
15
+ css?: string;
16
+ };
17
+ files: Record<string, string>;
18
+ };
19
+ export type InspectedPluginPackage = {
20
+ digest: string;
21
+ descriptor: PluginPackageDescriptor;
22
+ files: Map<string, Buffer>;
23
+ };
24
+ export type MaterializedPluginPackage = {
25
+ digest: string;
26
+ descriptor: PluginPackageDescriptor;
27
+ directory: string;
28
+ };
29
+ export type PluginPackageCall = {
30
+ operation: 'inspect' | 'query' | 'check' | 'resolve' | 'execute';
31
+ name?: string;
32
+ config?: Record<string, unknown>;
33
+ input?: Record<string, unknown>;
34
+ context?: PluginRuntimeContext;
35
+ request?: unknown;
36
+ };
37
+ export declare const PLUGIN_PACKAGE_MAX_BYTES: number;
38
+ export declare function packageSha256(bytes: Buffer | string): string;
39
+ export declare function inspectPluginPackage(bytes: Buffer): Promise<InspectedPluginPackage>;
40
+ export declare function materializePluginPackage(pkg: InspectedPluginPackage, root: string): Promise<MaterializedPluginPackage>;
41
+ export declare function invokePluginPackage(pkg: MaterializedPluginPackage, call: PluginPackageCall, options?: {
42
+ timeoutMs?: number;
43
+ signal?: AbortSignal;
44
+ }): Promise<any>;