@mearl/client 2.20.6 → 2.22.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.
- package/README.md +153 -3
- package/dist/actionTimeouts.js +1 -1
- package/dist/artifacts.d.ts +1 -1
- package/dist/browserRegistry.d.ts +1 -1
- package/dist/check.d.ts +1 -1
- package/dist/cli.js +14 -8
- package/dist/commands.d.ts +23 -14
- package/dist/commands.js +24 -4
- package/dist/{generated-app-profile-definition.d.ts → generated/app-profile-definition.d.ts} +2 -2
- package/dist/{generated-app-profile-runtime-types.d.ts → generated/app-profile-runtime-types.d.ts} +2 -2
- package/dist/{generated-browser-action-protocol.d.ts → generated/browser-action-protocol.d.ts} +11 -1
- package/dist/{generated-browser-action-protocol.js → generated/browser-action-protocol.js} +3 -0
- package/dist/generated/plugin-diagnostics.d.ts +110 -0
- package/dist/generated/plugin-diagnostics.js +1 -0
- package/dist/generated/plugin-execution.d.ts +27 -0
- package/dist/generated/plugin-execution.js +1 -0
- package/dist/generated/plugin-models.d.ts +64 -0
- package/dist/generated/plugin-models.js +1 -0
- package/dist/generated/plugin-pages.d.ts +76 -0
- package/dist/generated/plugin-pages.js +59 -0
- package/dist/generated/plugin-recovery.d.ts +32 -0
- package/dist/generated/plugin-recovery.js +1 -0
- package/dist/generated/plugin-state.d.ts +50 -0
- package/dist/generated/plugin-state.js +1 -0
- package/dist/generated/plugin-tasks.d.ts +74 -0
- package/dist/generated/plugin-tasks.js +7 -0
- package/dist/generated/toolbox-icon.d.ts +10 -0
- package/dist/generated/toolbox-icon.js +37 -0
- package/dist/index.d.ts +15 -4
- package/dist/index.js +7 -1
- package/dist/pageEvalArguments.d.ts +1 -1
- package/dist/pageEvalArguments.js +1 -1
- package/dist/plugin-task-host.d.ts +1 -0
- package/dist/plugin-task-host.js +14 -0
- package/dist/plugin-worker.d.ts +1 -0
- package/dist/plugin-worker.js +17 -0
- package/dist/plugin.d.ts +1 -65
- package/dist/plugin.js +1 -391
- package/dist/pluginCliOutput.d.ts +2 -0
- package/dist/pluginCliOutput.js +7 -0
- package/dist/pluginModelSessionWorker.d.ts +23 -0
- package/dist/pluginModelSessionWorker.js +60 -0
- package/dist/plugins/clients/pluginHostConnection.d.ts +35 -0
- package/dist/plugins/clients/pluginHostConnection.js +256 -0
- package/dist/plugins/clients/pluginModels.d.ts +17 -0
- package/dist/plugins/clients/pluginModels.js +47 -0
- package/dist/plugins/clients/pluginPages.d.ts +21 -0
- package/dist/plugins/clients/pluginPages.js +46 -0
- package/dist/plugins/clients/pluginState.d.ts +26 -0
- package/dist/plugins/clients/pluginState.js +28 -0
- package/dist/plugins/clients/pluginTasks.d.ts +51 -0
- package/dist/plugins/clients/pluginTasks.js +181 -0
- package/dist/{officialPluginInstaller.js → plugins/officialPluginInstaller.js} +1 -3
- package/dist/{pluginPageBundle.js → plugins/pluginPageBundle.js} +74 -17
- package/dist/{pluginRegistry.d.ts → plugins/pluginRegistry.d.ts} +3 -4
- package/dist/{pluginRegistry.js → plugins/pluginRegistry.js} +16 -15
- package/dist/plugins/pluginTaskProtocol.d.ts +47 -0
- package/dist/plugins/pluginTaskProtocol.js +1 -0
- package/dist/plugins/pluginTaskWire.d.ts +4 -0
- package/dist/plugins/pluginTaskWire.js +42 -0
- package/dist/plugins/runtime/pluginCommandWorker.d.ts +2 -0
- package/dist/plugins/runtime/pluginCommandWorker.js +174 -0
- package/dist/plugins/runtime/pluginDiagnostics.d.ts +26 -0
- package/dist/plugins/runtime/pluginDiagnostics.js +309 -0
- package/dist/plugins/runtime/pluginExecution.d.ts +21 -0
- package/dist/plugins/runtime/pluginExecution.js +43 -0
- package/dist/plugins/runtime/pluginExecutor.d.ts +29 -0
- package/dist/plugins/runtime/pluginExecutor.js +223 -0
- package/dist/plugins/runtime/pluginExecutorProtocol.d.ts +55 -0
- package/dist/plugins/runtime/pluginExecutorProtocol.js +47 -0
- package/dist/plugins/runtime/pluginExecutorSupervisor.d.ts +4 -0
- package/dist/plugins/runtime/pluginExecutorSupervisor.js +63 -0
- package/dist/plugins/runtime/pluginHost.d.ts +20 -0
- package/dist/plugins/runtime/pluginHost.js +53 -0
- package/dist/plugins/runtime/pluginHostFiles.d.ts +1 -0
- package/dist/plugins/runtime/pluginHostFiles.js +21 -0
- package/dist/plugins/runtime/pluginHostPaths.d.ts +11 -0
- package/dist/plugins/runtime/pluginHostPaths.js +22 -0
- package/dist/plugins/runtime/pluginHostServer.d.ts +7 -0
- package/dist/plugins/runtime/pluginHostServer.js +252 -0
- package/dist/plugins/runtime/pluginModelCatalog.d.ts +14 -0
- package/dist/plugins/runtime/pluginModelCatalog.js +51 -0
- package/dist/plugins/runtime/pluginModelSessions.d.ts +16 -0
- package/dist/plugins/runtime/pluginModelSessions.js +234 -0
- package/dist/plugins/runtime/pluginPageManager.d.ts +57 -0
- package/dist/plugins/runtime/pluginPageManager.js +877 -0
- package/dist/plugins/runtime/pluginProcessTree.d.ts +2 -0
- package/dist/plugins/runtime/pluginProcessTree.js +31 -0
- package/dist/plugins/runtime/pluginRevision.d.ts +14 -0
- package/dist/plugins/runtime/pluginRevision.js +227 -0
- package/dist/plugins/runtime/pluginRuntimeCleanup.d.ts +2 -0
- package/dist/plugins/runtime/pluginRuntimeCleanup.js +55 -0
- package/dist/plugins/runtime/pluginStateManager.d.ts +24 -0
- package/dist/plugins/runtime/pluginStateManager.js +132 -0
- package/dist/plugins/runtime/pluginStateStore.d.ts +57 -0
- package/dist/plugins/runtime/pluginStateStore.js +396 -0
- package/dist/plugins/runtime/pluginTaskManager.d.ts +92 -0
- package/dist/plugins/runtime/pluginTaskManager.js +1080 -0
- package/dist/plugins/runtime/pluginTaskStore.d.ts +53 -0
- package/dist/plugins/runtime/pluginTaskStore.js +319 -0
- package/dist/plugins/runtime/pythonPlugin.d.ts +31 -0
- package/dist/{pythonPlugin.js → plugins/runtime/pythonPlugin.js} +236 -31
- package/dist/plugins/sdk/plugin.d.ts +118 -0
- package/dist/plugins/sdk/plugin.js +672 -0
- package/dist/{pluginAppProfiles.d.ts → plugins/sdk/pluginAppProfiles.d.ts} +1 -1
- package/dist/{pluginAppProfiles.js → plugins/sdk/pluginAppProfiles.js} +2 -2
- package/dist/plugins/sdk/pluginModelDefinition.d.ts +6 -0
- package/dist/plugins/sdk/pluginModelDefinition.js +75 -0
- package/dist/plugins/sdk/pluginPageDefinition.d.ts +3 -0
- package/dist/plugins/sdk/pluginPageDefinition.js +62 -0
- package/dist/plugins/sdk/pluginRecoveryDefinition.d.ts +3 -0
- package/dist/plugins/sdk/pluginRecoveryDefinition.js +56 -0
- package/dist/plugins/sdk/pluginStateDefinition.d.ts +4 -0
- package/dist/plugins/sdk/pluginStateDefinition.js +68 -0
- package/dist/runtimePaths.d.ts +4 -0
- package/dist/runtimePaths.js +8 -0
- package/dist/socket.d.ts +2 -2
- package/dist/socket.js +10 -6
- package/dist/unified.d.ts +3 -2
- package/dist/unified.js +83 -2
- package/package.json +7 -3
- package/dist/pythonPlugin.d.ts +0 -20
- /package/dist/{generated-app-profile-definition.js → generated/app-profile-definition.js} +0 -0
- /package/dist/{generated-app-profile-runtime-types.js → generated/app-profile-runtime-types.js} +0 -0
- /package/dist/{officialPluginCatalog.d.ts → plugins/officialPluginCatalog.d.ts} +0 -0
- /package/dist/{officialPluginCatalog.js → plugins/officialPluginCatalog.js} +0 -0
- /package/dist/{officialPluginInstaller.d.ts → plugins/officialPluginInstaller.d.ts} +0 -0
- /package/dist/{pluginPageBundle.d.ts → plugins/pluginPageBundle.d.ts} +0 -0
- /package/dist/{pluginSource.d.ts → plugins/pluginSource.d.ts} +0 -0
- /package/dist/{pluginSource.js → plugins/pluginSource.js} +0 -0
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
import { normalizeModelDiagnostic, diagnosticBinding } from './pluginDiagnostics.js';
|
|
2
|
+
import { pluginModelCapabilities, normalizePluginModel, validatePluginModelResult, } from '../sdk/pluginModelDefinition.js';
|
|
3
|
+
import { pluginRecoverySignature } from '../sdk/pluginRecoveryDefinition.js';
|
|
4
|
+
import { compilePluginStateSchema } from '../sdk/pluginStateDefinition.js';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
8
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
9
|
+
import { localPluginExecutionHost, createPluginInvocationIdentity } from './pluginExecution.js';
|
|
10
|
+
import { executeRegisteredPlugin } from './pluginExecutor.js';
|
|
11
|
+
import { serializePluginExecutionError, pluginExecutionError } from './pluginExecutorProtocol.js';
|
|
12
|
+
import { capturePluginRevision, prunePluginRevisions, restorePluginRevision, } from './pluginRevision.js';
|
|
13
|
+
import { pluginHostPaths } from './pluginHostPaths.js';
|
|
14
|
+
import { PluginTaskStore, pluginTaskExecutionUnconfirmed } from './pluginTaskStore.js';
|
|
15
|
+
import { isPluginTaskActive } from '../../generated/plugin-tasks.js';
|
|
16
|
+
import { UnifiedClient, decodeBrowserTarget } from '../../unified.js';
|
|
17
|
+
import { COMMAND_MAP } from '../../commands.js';
|
|
18
|
+
function error(code, message) {
|
|
19
|
+
return Object.assign(new Error(message), { code });
|
|
20
|
+
}
|
|
21
|
+
function digest(value) {
|
|
22
|
+
return createHash('sha256')
|
|
23
|
+
.update(JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
|
|
24
|
+
? Object.fromEntries(Object.entries(item).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
25
|
+
: item))
|
|
26
|
+
.digest('hex');
|
|
27
|
+
}
|
|
28
|
+
function revisionEntry(revision) {
|
|
29
|
+
return relative(realpathSync(join(pluginHostPaths().revisions, revision.installationId, revision.revision)), realpathSync(revision.entry))
|
|
30
|
+
.split(sep)
|
|
31
|
+
.join('/');
|
|
32
|
+
}
|
|
33
|
+
export class PluginTaskManager {
|
|
34
|
+
configuration;
|
|
35
|
+
services;
|
|
36
|
+
host = localPluginExecutionHost();
|
|
37
|
+
store;
|
|
38
|
+
changes = new EventEmitter();
|
|
39
|
+
queue = new Map();
|
|
40
|
+
running = new Map();
|
|
41
|
+
resuming = new Map();
|
|
42
|
+
activeChildren = new Map();
|
|
43
|
+
closed = false;
|
|
44
|
+
maintenance;
|
|
45
|
+
descriptionsRunning = 0;
|
|
46
|
+
descriptions = new Map();
|
|
47
|
+
descriptionBytes = 0;
|
|
48
|
+
revisionPins = new Map();
|
|
49
|
+
recoveries = new Map();
|
|
50
|
+
pinRevision(revision) {
|
|
51
|
+
const key = `${revision.installationId}/${revision.revision}`;
|
|
52
|
+
this.revisionPins.set(key, (this.revisionPins.get(key) ?? 0) + 1);
|
|
53
|
+
let released = false;
|
|
54
|
+
return () => {
|
|
55
|
+
if (released)
|
|
56
|
+
return;
|
|
57
|
+
released = true;
|
|
58
|
+
const count = this.revisionPins.get(key) ?? 0;
|
|
59
|
+
if (count > 1)
|
|
60
|
+
this.revisionPins.set(key, count - 1);
|
|
61
|
+
else
|
|
62
|
+
this.revisionPins.delete(key);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async describeRevision(revision, signal, options = {}) {
|
|
66
|
+
signal.throwIfAborted();
|
|
67
|
+
if (this.closed)
|
|
68
|
+
throw new Error('Plugin description budget is exhausted');
|
|
69
|
+
const key = `${revision.installationId}/${revision.revision}`;
|
|
70
|
+
const cached = options.cache ? this.descriptions.get(key) : undefined;
|
|
71
|
+
if (cached) {
|
|
72
|
+
this.descriptions.delete(key);
|
|
73
|
+
this.descriptions.set(key, cached);
|
|
74
|
+
return structuredClone(cached.value);
|
|
75
|
+
}
|
|
76
|
+
if (this.descriptionsRunning >= 8)
|
|
77
|
+
throw new Error('Plugin description budget is exhausted');
|
|
78
|
+
this.descriptionsRunning++;
|
|
79
|
+
try {
|
|
80
|
+
const value = (await (this.configuration.execute ?? executeRegisteredPlugin)(revision.plugin, { command: 'describe', input: {} }, {
|
|
81
|
+
invoke: async () => {
|
|
82
|
+
throw new Error('Plugin declarations cannot invoke host actions');
|
|
83
|
+
},
|
|
84
|
+
}, {
|
|
85
|
+
describe: true,
|
|
86
|
+
signal,
|
|
87
|
+
source: 'runtime',
|
|
88
|
+
executionTimeoutMs: 10000,
|
|
89
|
+
workerPath: this.configuration.workerPath,
|
|
90
|
+
}, { registration: revision.registration }));
|
|
91
|
+
signal.throwIfAborted();
|
|
92
|
+
// Page and state readers reuse revision metadata; recovery admission re-executes declarations.
|
|
93
|
+
const bytes = Buffer.byteLength(JSON.stringify(value));
|
|
94
|
+
if (options.cache && !this.closed && bytes <= 8 * 1024 * 1024) {
|
|
95
|
+
const previous = this.descriptions.get(key);
|
|
96
|
+
if (previous) {
|
|
97
|
+
this.descriptions.delete(key);
|
|
98
|
+
this.descriptionBytes -= previous.bytes;
|
|
99
|
+
}
|
|
100
|
+
while (this.descriptions.size >= 64 || this.descriptionBytes + bytes > 8 * 1024 * 1024) {
|
|
101
|
+
const oldest = this.descriptions.keys().next().value;
|
|
102
|
+
this.descriptionBytes -= this.descriptions.get(oldest).bytes;
|
|
103
|
+
this.descriptions.delete(oldest);
|
|
104
|
+
}
|
|
105
|
+
this.descriptions.set(key, { value: structuredClone(value), bytes });
|
|
106
|
+
this.descriptionBytes += bytes;
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
this.descriptionsRunning--;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
constructor(configuration = {}, services) {
|
|
115
|
+
this.configuration = configuration;
|
|
116
|
+
this.services = services;
|
|
117
|
+
this.store = configuration.store ?? new PluginTaskStore();
|
|
118
|
+
this.store.recover(this.host.epoch);
|
|
119
|
+
this.changes.setMaxListeners(0);
|
|
120
|
+
const concurrency = configuration.concurrency ?? 4;
|
|
121
|
+
if (!Number.isInteger(configuration.modelConcurrency ?? 2) ||
|
|
122
|
+
(configuration.modelConcurrency ?? 2) < 1 ||
|
|
123
|
+
(configuration.modelConcurrency ?? 2) > 8)
|
|
124
|
+
throw new Error('Invalid model concurrency');
|
|
125
|
+
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 32)
|
|
126
|
+
throw new Error('Invalid plugin task concurrency');
|
|
127
|
+
const prune = () => {
|
|
128
|
+
this.store.prune();
|
|
129
|
+
prunePluginRevisions(new Set([
|
|
130
|
+
...this.revisionPins.keys(),
|
|
131
|
+
...this.store
|
|
132
|
+
.list()
|
|
133
|
+
.map(record => `${record.task.installationId}/${record.task.revision}`),
|
|
134
|
+
]));
|
|
135
|
+
};
|
|
136
|
+
prune();
|
|
137
|
+
this.maintenance = setInterval(() => {
|
|
138
|
+
try {
|
|
139
|
+
prune();
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
console.error('Plugin task retention failed:', error);
|
|
143
|
+
}
|
|
144
|
+
}, 60 * 60 * 1000);
|
|
145
|
+
this.maintenance.unref();
|
|
146
|
+
}
|
|
147
|
+
authorize(taskId, scope) {
|
|
148
|
+
if (!taskId.startsWith(`pt_${this.host.hostId}_`))
|
|
149
|
+
throw error('PLUGIN_TASK_WRONG_HOST', 'Plugin task belongs to another execution host');
|
|
150
|
+
const record = this.store.read(taskId);
|
|
151
|
+
if ((scope.plugin && record.task.plugin !== scope.plugin) ||
|
|
152
|
+
(scope.access === 'toolbox' && record.access === 'public'))
|
|
153
|
+
throw error('PLUGIN_TASK_NOT_FOUND', 'Plugin task was not found');
|
|
154
|
+
return record.task;
|
|
155
|
+
}
|
|
156
|
+
get(taskId, scope) {
|
|
157
|
+
return this.authorize(taskId, scope);
|
|
158
|
+
}
|
|
159
|
+
list(scope, activeOnly = false, cursor, limit = 100) {
|
|
160
|
+
if (!Number.isSafeInteger(limit) ||
|
|
161
|
+
limit < 1 ||
|
|
162
|
+
limit > 200 ||
|
|
163
|
+
(cursor !== undefined && typeof cursor !== 'string'))
|
|
164
|
+
throw new Error('Invalid plugin task list page');
|
|
165
|
+
const candidates = (activeOnly ? this.store.listActive() : this.store.list())
|
|
166
|
+
.filter(record => scope.access !== 'toolbox' || record.access !== 'public')
|
|
167
|
+
.map(record => record.task)
|
|
168
|
+
.filter(task => (!scope.plugin || task.plugin === scope.plugin) &&
|
|
169
|
+
(!activeOnly || isPluginTaskActive(task.state)))
|
|
170
|
+
.sort((a, b) => b.createdAt - a.createdAt || b.taskId.localeCompare(a.taskId));
|
|
171
|
+
const match = cursor?.match(/^(\d+):(pt_[0-9a-f-]{36}_[0-9a-f-]{36})$/i);
|
|
172
|
+
if (cursor !== undefined && (!match || !Number.isSafeInteger(Number(match[1]))))
|
|
173
|
+
throw new Error('Invalid plugin task list cursor');
|
|
174
|
+
const remaining = match
|
|
175
|
+
? candidates.filter(task => task.createdAt < Number(match[1]) ||
|
|
176
|
+
(task.createdAt === Number(match[1]) && task.taskId.localeCompare(match[2]) < 0))
|
|
177
|
+
: candidates;
|
|
178
|
+
const tasks = [];
|
|
179
|
+
let bytes = 0;
|
|
180
|
+
for (const task of remaining) {
|
|
181
|
+
const size = Buffer.byteLength(JSON.stringify(task));
|
|
182
|
+
if (tasks.length && (tasks.length >= limit || bytes + size > 384 * 1024))
|
|
183
|
+
break;
|
|
184
|
+
tasks.push(task);
|
|
185
|
+
bytes += size;
|
|
186
|
+
}
|
|
187
|
+
const last = tasks.at(-1);
|
|
188
|
+
return {
|
|
189
|
+
tasks,
|
|
190
|
+
...(last && tasks.length < remaining.length
|
|
191
|
+
? { nextCursor: `${last.createdAt}:${last.taskId}` }
|
|
192
|
+
: {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
start(request, bridge, binding = undefined) {
|
|
196
|
+
if (this.closed)
|
|
197
|
+
throw error('PLUGIN_TASK_HOST_STOPPING', 'Plugin execution service is stopping');
|
|
198
|
+
if (request.kind !== undefined && !['command', 'model'].includes(request.kind))
|
|
199
|
+
throw new Error('Invalid task kind');
|
|
200
|
+
if (request.kind === 'model') {
|
|
201
|
+
const model = normalizePluginModel(request.input?.request);
|
|
202
|
+
request = {
|
|
203
|
+
...request,
|
|
204
|
+
command: 'model',
|
|
205
|
+
input: { request: model },
|
|
206
|
+
executionTimeoutMs: request.executionTimeoutMs ?? 120000,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (!/^[a-z][a-z0-9-]*$/.test(request.plugin) || !/^[a-z][a-z0-9-]*$/.test(request.command))
|
|
210
|
+
throw new Error('Invalid plugin task command');
|
|
211
|
+
if (bridge.scope.plugin && request.plugin !== bridge.scope.plugin)
|
|
212
|
+
throw error('PLUGIN_COMMAND_NOT_ALLOWED', 'Plugin task is outside the caller scope');
|
|
213
|
+
if (!request.input ||
|
|
214
|
+
typeof request.input !== 'object' ||
|
|
215
|
+
Array.isArray(request.input) ||
|
|
216
|
+
Buffer.byteLength(JSON.stringify(request.input)) > 256 * 1024)
|
|
217
|
+
throw new Error('Plugin task input must be an object within 256 KiB');
|
|
218
|
+
if (!isAbsolute(request.cwd) || !statSync(request.cwd).isDirectory())
|
|
219
|
+
throw new Error('Plugin task cwd must be an absolute directory');
|
|
220
|
+
if (!request.environment ||
|
|
221
|
+
typeof request.environment !== 'object' ||
|
|
222
|
+
Array.isArray(request.environment) ||
|
|
223
|
+
Object.entries(request.environment).some(([key, value]) => !key ||
|
|
224
|
+
key.includes('=') ||
|
|
225
|
+
typeof value !== 'string' ||
|
|
226
|
+
key.includes('\0') ||
|
|
227
|
+
value.includes('\0')) ||
|
|
228
|
+
Buffer.byteLength(JSON.stringify(request.environment)) > 512 * 1024)
|
|
229
|
+
throw new Error('Invalid plugin task environment');
|
|
230
|
+
if (!request.options ||
|
|
231
|
+
typeof request.options !== 'object' ||
|
|
232
|
+
Array.isArray(request.options) ||
|
|
233
|
+
Object.keys(request.options).some(key => !['browser', 'session', 'connector', 'local', 'transport', 'timeoutSec'].includes(key)))
|
|
234
|
+
throw new Error('Invalid plugin task routing options');
|
|
235
|
+
for (const key of ['browser', 'session', 'connector'])
|
|
236
|
+
if (request.options[key] !== undefined &&
|
|
237
|
+
(typeof request.options[key] !== 'string' || !request.options[key]))
|
|
238
|
+
throw new Error('Invalid plugin task target');
|
|
239
|
+
if (request.options.timeoutSec !== undefined &&
|
|
240
|
+
(!Number.isFinite(request.options.timeoutSec) || request.options.timeoutSec <= 0))
|
|
241
|
+
throw new Error('Invalid plugin task invocation timeout');
|
|
242
|
+
if (request.options.local !== undefined && typeof request.options.local !== 'boolean')
|
|
243
|
+
throw new Error('Invalid plugin task local routing option');
|
|
244
|
+
if (request.options.transport !== undefined &&
|
|
245
|
+
!['extension', 'cdp'].includes(request.options.transport))
|
|
246
|
+
throw new Error('Invalid plugin task transport');
|
|
247
|
+
if (bridge.scope.bridge === 'service' &&
|
|
248
|
+
request.options.browser &&
|
|
249
|
+
!decodeBrowserTarget(request.options.browser))
|
|
250
|
+
throw error('PLUGIN_TASK_TARGET_REQUIRED', 'Background tasks require a stable global browser ID');
|
|
251
|
+
if (request.executionTimeoutMs !== undefined &&
|
|
252
|
+
(!Number.isSafeInteger(request.executionTimeoutMs) ||
|
|
253
|
+
request.executionTimeoutMs < 1 ||
|
|
254
|
+
request.executionTimeoutMs > 2_147_483_647))
|
|
255
|
+
throw new Error('Invalid plugin task execution deadline');
|
|
256
|
+
if (request.idempotencyKey !== undefined &&
|
|
257
|
+
(typeof request.idempotencyKey !== 'string' ||
|
|
258
|
+
!request.idempotencyKey ||
|
|
259
|
+
request.idempotencyKey.length > 128))
|
|
260
|
+
throw new Error('Invalid plugin task idempotency key');
|
|
261
|
+
const revision = binding?.revision ??
|
|
262
|
+
(this.configuration.captureRevision ?? capturePluginRevision)(request.plugin);
|
|
263
|
+
if (bridge.scope.access === 'runtime' && !binding)
|
|
264
|
+
throw new Error('Runtime task requires a document binding');
|
|
265
|
+
if (request.kind !== 'model' &&
|
|
266
|
+
bridge.scope.access === 'toolbox' &&
|
|
267
|
+
!revision.registration.toolbox?.page.commands.includes(request.command))
|
|
268
|
+
throw error('PLUGIN_COMMAND_NOT_ALLOWED', 'Tool page cannot invoke this plugin command');
|
|
269
|
+
request = {
|
|
270
|
+
...request,
|
|
271
|
+
cwd: realpathSync(request.cwd),
|
|
272
|
+
input: JSON.parse(JSON.stringify(request.input)),
|
|
273
|
+
};
|
|
274
|
+
const requestHash = binding?.recoveryRequestHash ??
|
|
275
|
+
digest([
|
|
276
|
+
request.kind ?? 'command',
|
|
277
|
+
request.command,
|
|
278
|
+
request.input,
|
|
279
|
+
request.cwd,
|
|
280
|
+
request.options,
|
|
281
|
+
request.executionTimeoutMs,
|
|
282
|
+
revision.revision,
|
|
283
|
+
binding?.recovery,
|
|
284
|
+
Object.entries(request.environment).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
285
|
+
]);
|
|
286
|
+
const dedupeKey = binding?.recovery
|
|
287
|
+
? `resume:${binding.recovery.fromTaskId}`
|
|
288
|
+
: request.idempotencyKey
|
|
289
|
+
? `explicit:${revision.installationId}:${bridge.scope.access}:${request.idempotencyKey}`
|
|
290
|
+
: bridge.scope.access === 'toolbox'
|
|
291
|
+
? `active:${digest(bridge.scope.scopeId ?? bridge.id)}:${revision.installationId}:${requestHash}`
|
|
292
|
+
: undefined;
|
|
293
|
+
if (dedupeKey) {
|
|
294
|
+
const existing = this.store.findDedupe(dedupeKey, request.idempotencyKey === undefined && binding?.recovery === undefined);
|
|
295
|
+
if (existing) {
|
|
296
|
+
if (existing.requestHash !== requestHash)
|
|
297
|
+
throw error('PLUGIN_TASK_IDEMPOTENCY_CONFLICT', 'Idempotency key refers to a different plugin task request');
|
|
298
|
+
return {
|
|
299
|
+
taskId: existing.task.taskId,
|
|
300
|
+
state: existing.task.state,
|
|
301
|
+
host: existing.task.host,
|
|
302
|
+
reused: true,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if ([...this.queue.values()].filter(item => (item.request.kind ?? 'command') === (request.kind ?? 'command')).length >= 128)
|
|
307
|
+
throw error('PLUGIN_TASK_QUEUE_FULL', 'Plugin task queue is full');
|
|
308
|
+
const now = Date.now();
|
|
309
|
+
const task = {
|
|
310
|
+
taskId: `pt_${this.host.hostId}_${randomUUID()}`,
|
|
311
|
+
host: this.host,
|
|
312
|
+
plugin: request.plugin,
|
|
313
|
+
installationId: revision.installationId,
|
|
314
|
+
revision: revision.revision,
|
|
315
|
+
command: request.command,
|
|
316
|
+
kind: request.kind ?? 'command',
|
|
317
|
+
...(binding?.parentTaskId ? { parentTaskId: binding.parentTaskId } : {}),
|
|
318
|
+
...(binding?.recovery
|
|
319
|
+
? {
|
|
320
|
+
resumedFrom: {
|
|
321
|
+
taskId: binding.recovery.fromTaskId,
|
|
322
|
+
revision: binding.recovery.fromRevision,
|
|
323
|
+
checkpointSequence: binding.recovery.sourceCheckpoint.sequence,
|
|
324
|
+
checkpointVersion: binding.recovery.sourceCheckpoint.version,
|
|
325
|
+
},
|
|
326
|
+
checkpoint: {
|
|
327
|
+
sequence: 0,
|
|
328
|
+
version: binding.recovery.checkpoint.version,
|
|
329
|
+
schemaHash: binding.recovery.checkpoint.schemaHash,
|
|
330
|
+
updatedAt: now,
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
: {}),
|
|
334
|
+
input: request.input,
|
|
335
|
+
source: bridge.scope.source,
|
|
336
|
+
cwd: request.cwd,
|
|
337
|
+
state: 'queued',
|
|
338
|
+
sequence: 0,
|
|
339
|
+
createdAt: now,
|
|
340
|
+
updatedAt: now,
|
|
341
|
+
};
|
|
342
|
+
this.store.write({
|
|
343
|
+
task,
|
|
344
|
+
events: [],
|
|
345
|
+
diagnostics: {
|
|
346
|
+
executorAttempted: false,
|
|
347
|
+
...(binding?.page
|
|
348
|
+
? {
|
|
349
|
+
page: diagnosticBinding({
|
|
350
|
+
instanceId: binding.page.instanceId,
|
|
351
|
+
target: binding.page.target,
|
|
352
|
+
}),
|
|
353
|
+
}
|
|
354
|
+
: {}),
|
|
355
|
+
},
|
|
356
|
+
access: bridge.scope.access,
|
|
357
|
+
...(dedupeKey ? { dedupeKey } : {}),
|
|
358
|
+
requestHash,
|
|
359
|
+
execution: {
|
|
360
|
+
entry: revisionEntry(revision),
|
|
361
|
+
options: JSON.parse(JSON.stringify(request.options)),
|
|
362
|
+
bridge: bridge.scope.bridge,
|
|
363
|
+
executionTimeoutMs: request.executionTimeoutMs,
|
|
364
|
+
},
|
|
365
|
+
...(binding?.recovery
|
|
366
|
+
? {
|
|
367
|
+
checkpoint: {
|
|
368
|
+
...structuredClone(binding.recovery.checkpoint),
|
|
369
|
+
sequence: 0,
|
|
370
|
+
updatedAt: now,
|
|
371
|
+
},
|
|
372
|
+
}
|
|
373
|
+
: {}),
|
|
374
|
+
});
|
|
375
|
+
this.update(task.taskId, { state: 'queued' });
|
|
376
|
+
if (task.parentTaskId) {
|
|
377
|
+
const children = this.activeChildren.get(task.parentTaskId) ?? new Set();
|
|
378
|
+
children.add(task.taskId);
|
|
379
|
+
this.activeChildren.set(task.parentTaskId, children);
|
|
380
|
+
}
|
|
381
|
+
this.queue.set(task.taskId, {
|
|
382
|
+
request,
|
|
383
|
+
revision,
|
|
384
|
+
bridge,
|
|
385
|
+
controller: new AbortController(),
|
|
386
|
+
guard: binding?.guard,
|
|
387
|
+
page: binding?.page,
|
|
388
|
+
recovery: binding?.recovery,
|
|
389
|
+
parentIdentity: binding?.parentIdentity,
|
|
390
|
+
documentTarget: binding?.documentTarget,
|
|
391
|
+
});
|
|
392
|
+
queueMicrotask(() => this.drain());
|
|
393
|
+
return { taskId: task.taskId, state: 'queued', host: this.host };
|
|
394
|
+
}
|
|
395
|
+
checkpoint(taskId, scope) {
|
|
396
|
+
this.authorize(taskId, scope);
|
|
397
|
+
return this.store.read(taskId).checkpoint ?? null;
|
|
398
|
+
}
|
|
399
|
+
resume(request, bridge) {
|
|
400
|
+
try {
|
|
401
|
+
if (this.closed)
|
|
402
|
+
throw error('PLUGIN_TASK_HOST_STOPPING', 'Plugin execution service is stopping');
|
|
403
|
+
this.authorize(request.taskId, bridge.scope);
|
|
404
|
+
if (bridge.scope.access !== 'public' || bridge.scope.bridge !== 'service')
|
|
405
|
+
throw error('PLUGIN_TASK_RECOVERY_UNAVAILABLE', 'Recovery requires public service-owned routing');
|
|
406
|
+
if (request.revision !== undefined && !['original', 'current'].includes(request.revision))
|
|
407
|
+
throw new Error('Recovery revision must be original or current');
|
|
408
|
+
if (!request.environment ||
|
|
409
|
+
typeof request.environment !== 'object' ||
|
|
410
|
+
Array.isArray(request.environment) ||
|
|
411
|
+
Object.entries(request.environment).some(([key, value]) => !key ||
|
|
412
|
+
key.includes('=') ||
|
|
413
|
+
typeof value !== 'string' ||
|
|
414
|
+
key.includes('\0') ||
|
|
415
|
+
value.includes('\0')) ||
|
|
416
|
+
Buffer.byteLength(JSON.stringify(request.environment)) > 512 * 1024)
|
|
417
|
+
throw new Error('Invalid plugin task environment');
|
|
418
|
+
const intent = digest([request.revision ?? 'original', request.environment]);
|
|
419
|
+
const existing = this.recoveries.get(request.taskId);
|
|
420
|
+
if (existing) {
|
|
421
|
+
if (existing.intent !== intent)
|
|
422
|
+
throw error('PLUGIN_TASK_IDEMPOTENCY_CONFLICT', 'A different recovery request is already in progress');
|
|
423
|
+
return existing.promise.then(result => ({ ...result, reused: true }));
|
|
424
|
+
}
|
|
425
|
+
const controller = new AbortController();
|
|
426
|
+
const operation = {
|
|
427
|
+
controller,
|
|
428
|
+
intent,
|
|
429
|
+
promise: this.resumeAttempt(request, bridge, controller.signal),
|
|
430
|
+
};
|
|
431
|
+
this.recoveries.set(request.taskId, operation);
|
|
432
|
+
void operation.promise.finally(() => this.recoveries.delete(request.taskId)).catch(() => { });
|
|
433
|
+
return operation.promise;
|
|
434
|
+
}
|
|
435
|
+
catch (cause) {
|
|
436
|
+
return Promise.reject(cause);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async resumeAttempt(request, bridge, signal) {
|
|
440
|
+
const task = this.authorize(request.taskId, bridge.scope);
|
|
441
|
+
const record = this.store.read(task.taskId);
|
|
442
|
+
if (!['error', 'cancelled', 'interrupted'].includes(task.state) ||
|
|
443
|
+
task.kind === 'model' ||
|
|
444
|
+
task.parentTaskId ||
|
|
445
|
+
record.access !== 'public' ||
|
|
446
|
+
record.execution?.bridge !== 'service' ||
|
|
447
|
+
!record.checkpoint)
|
|
448
|
+
throw error('PLUGIN_TASK_RECOVERY_UNAVAILABLE', 'Recovery requires a terminal root command with a checkpoint and service-owned routing');
|
|
449
|
+
this.assertExecutionTreeStopped(task.taskId);
|
|
450
|
+
const source = restorePluginRevision(task.plugin, task.installationId, task.revision, record.execution.entry);
|
|
451
|
+
const revision = request.revision === 'current'
|
|
452
|
+
? (this.configuration.captureRevision ?? capturePluginRevision)(task.plugin)
|
|
453
|
+
: source;
|
|
454
|
+
if (revision.installationId !== task.installationId)
|
|
455
|
+
throw error('PLUGIN_TASK_INSTALLATION_CHANGED', 'Recovery cannot change the plugin installation');
|
|
456
|
+
restorePluginRevision(task.plugin, task.installationId, revision.revision, revisionEntry(revision));
|
|
457
|
+
const releaseSource = this.pinRevision(source), releaseTarget = this.pinRevision(revision);
|
|
458
|
+
try {
|
|
459
|
+
const requestHash = digest([
|
|
460
|
+
request.revision ?? 'original',
|
|
461
|
+
task,
|
|
462
|
+
record.checkpoint,
|
|
463
|
+
record.execution,
|
|
464
|
+
revision.revision,
|
|
465
|
+
request.environment,
|
|
466
|
+
]);
|
|
467
|
+
const existing = this.store.findDedupe(`resume:${task.taskId}`);
|
|
468
|
+
if (existing) {
|
|
469
|
+
if (existing.requestHash !== requestHash)
|
|
470
|
+
throw error('PLUGIN_TASK_IDEMPOTENCY_CONFLICT', 'Source task already has a different recovery attempt');
|
|
471
|
+
return {
|
|
472
|
+
taskId: existing.task.taskId,
|
|
473
|
+
state: existing.task.state,
|
|
474
|
+
host: existing.task.host,
|
|
475
|
+
reused: true,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
const sourceCommands = (await this.describeRevision(source, signal)).taskRecovery?.commands;
|
|
479
|
+
const sourceDefinition = sourceCommands && Object.hasOwn(sourceCommands, task.command)
|
|
480
|
+
? sourceCommands[task.command]
|
|
481
|
+
: undefined;
|
|
482
|
+
signal.throwIfAborted();
|
|
483
|
+
if (!sourceDefinition ||
|
|
484
|
+
sourceDefinition.checkpointVersion !== record.checkpoint.version ||
|
|
485
|
+
digest(sourceDefinition.schema) !== record.checkpoint.schemaHash ||
|
|
486
|
+
!compilePluginStateSchema(sourceDefinition.schema)(record.checkpoint.data))
|
|
487
|
+
throw error('PLUGIN_TASK_RECOVERY_UNAVAILABLE', 'Checkpoint does not match the retained recovery declaration');
|
|
488
|
+
let checkpoint = structuredClone(record.checkpoint);
|
|
489
|
+
let declarationHash = pluginRecoverySignature(sourceDefinition);
|
|
490
|
+
if (revision.revision !== source.revision) {
|
|
491
|
+
const targetCommands = (await this.describeRevision(revision, signal)).taskRecovery
|
|
492
|
+
?.commands;
|
|
493
|
+
const target = targetCommands && Object.hasOwn(targetCommands, task.command)
|
|
494
|
+
? targetCommands[task.command]
|
|
495
|
+
: undefined;
|
|
496
|
+
signal.throwIfAborted();
|
|
497
|
+
if (!target)
|
|
498
|
+
throw error('PLUGIN_TASK_RECOVERY_UNAVAILABLE', 'Target revision does not declare recovery for the original command');
|
|
499
|
+
declarationHash = pluginRecoverySignature(target);
|
|
500
|
+
if (target.checkpointVersion === checkpoint.version) {
|
|
501
|
+
if (digest(target.schema) !== digest(sourceDefinition.schema))
|
|
502
|
+
throw error('PLUGIN_CHECKPOINT_MIGRATION_UNSUPPORTED', 'Checkpoint schema changed without a version increment');
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
if (!target.migrateFrom?.some(from => from.version === checkpoint.version &&
|
|
506
|
+
digest(from.schema) === digest(sourceDefinition.schema)))
|
|
507
|
+
throw error('PLUGIN_CHECKPOINT_MIGRATION_UNSUPPORTED', 'Target revision does not declare this checkpoint format');
|
|
508
|
+
if (this.descriptionsRunning >= 8)
|
|
509
|
+
throw error('PLUGIN_CHECKPOINT_MIGRATION_BUSY', 'Checkpoint migration budget is exhausted');
|
|
510
|
+
this.descriptionsRunning++;
|
|
511
|
+
let result;
|
|
512
|
+
try {
|
|
513
|
+
result = await (this.configuration.execute ?? executeRegisteredPlugin)(revision.plugin, {
|
|
514
|
+
command: task.command,
|
|
515
|
+
input: { checkpoint, fromRevision: source.revision, toRevision: revision.revision },
|
|
516
|
+
}, {
|
|
517
|
+
invoke: async () => {
|
|
518
|
+
throw error('PLUGIN_CHECKPOINT_MIGRATION_HOST_CALL', 'Checkpoint migration cannot invoke host actions');
|
|
519
|
+
},
|
|
520
|
+
}, {
|
|
521
|
+
migration: true,
|
|
522
|
+
expectedRecovery: declarationHash,
|
|
523
|
+
signal,
|
|
524
|
+
source: 'runtime',
|
|
525
|
+
cwd: task.cwd,
|
|
526
|
+
environment: request.environment,
|
|
527
|
+
executionTimeoutMs: 10000,
|
|
528
|
+
workerPath: this.configuration.workerPath,
|
|
529
|
+
}, { registration: revision.registration });
|
|
530
|
+
}
|
|
531
|
+
finally {
|
|
532
|
+
this.descriptionsRunning--;
|
|
533
|
+
}
|
|
534
|
+
signal.throwIfAborted();
|
|
535
|
+
const json = JSON.stringify(result);
|
|
536
|
+
if (json === undefined || Buffer.byteLength(json) > 64 * 1024)
|
|
537
|
+
throw error('PLUGIN_TASK_CHECKPOINT_INVALID', 'Migrated checkpoint must be JSON within 64 KiB');
|
|
538
|
+
checkpoint = {
|
|
539
|
+
...checkpoint,
|
|
540
|
+
version: target.checkpointVersion,
|
|
541
|
+
schemaHash: digest(target.schema),
|
|
542
|
+
data: JSON.parse(json),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
if (!compilePluginStateSchema(target.schema)(checkpoint.data))
|
|
546
|
+
throw error('PLUGIN_TASK_CHECKPOINT_INVALID', 'Migrated checkpoint does not match the target schema');
|
|
547
|
+
}
|
|
548
|
+
signal.throwIfAborted();
|
|
549
|
+
// Admission is synchronous after both immutable snapshots and execution ownership are verified.
|
|
550
|
+
restorePluginRevision(task.plugin, task.installationId, task.revision, record.execution.entry);
|
|
551
|
+
restorePluginRevision(task.plugin, task.installationId, revision.revision, revisionEntry(revision));
|
|
552
|
+
this.assertExecutionTreeStopped(task.taskId);
|
|
553
|
+
return this.start({
|
|
554
|
+
plugin: task.plugin,
|
|
555
|
+
command: task.command,
|
|
556
|
+
input: task.input,
|
|
557
|
+
cwd: task.cwd,
|
|
558
|
+
environment: request.environment,
|
|
559
|
+
options: record.execution.options,
|
|
560
|
+
executionTimeoutMs: record.execution.executionTimeoutMs,
|
|
561
|
+
}, bridge, {
|
|
562
|
+
revision,
|
|
563
|
+
recoveryRequestHash: requestHash,
|
|
564
|
+
recovery: {
|
|
565
|
+
fromTaskId: task.taskId,
|
|
566
|
+
fromRevision: source.revision,
|
|
567
|
+
declarationHash,
|
|
568
|
+
sourceCheckpoint: {
|
|
569
|
+
sequence: record.checkpoint.sequence,
|
|
570
|
+
version: record.checkpoint.version,
|
|
571
|
+
schemaHash: record.checkpoint.schemaHash,
|
|
572
|
+
updatedAt: record.checkpoint.updatedAt,
|
|
573
|
+
},
|
|
574
|
+
checkpoint,
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
finally {
|
|
579
|
+
releaseTarget();
|
|
580
|
+
releaseSource();
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
assertExecutionTreeStopped(taskId) {
|
|
584
|
+
const records = this.store.list();
|
|
585
|
+
const visited = new Set();
|
|
586
|
+
const pending = [taskId];
|
|
587
|
+
while (pending.length) {
|
|
588
|
+
const id = pending.pop();
|
|
589
|
+
if (visited.has(id))
|
|
590
|
+
continue;
|
|
591
|
+
visited.add(id);
|
|
592
|
+
const record = records.find(record => record.task.taskId === id);
|
|
593
|
+
if (!record)
|
|
594
|
+
throw error('PLUGIN_TASK_EXECUTION_STILL_ACTIVE', 'Previous execution ownership record is unavailable');
|
|
595
|
+
pending.push(...records
|
|
596
|
+
.filter(record => record.task.parentTaskId === id)
|
|
597
|
+
.map(record => record.task.taskId));
|
|
598
|
+
if (isPluginTaskActive(record.task.state) || pluginTaskExecutionUnconfirmed(record))
|
|
599
|
+
throw error('PLUGIN_TASK_EXECUTION_STILL_ACTIVE', 'Previous execution or descendant cleanup cannot be confirmed');
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
cancel(taskId, scope) {
|
|
603
|
+
const task = this.authorize(taskId, scope);
|
|
604
|
+
if (task.state === 'queued') {
|
|
605
|
+
this.queue.delete(taskId);
|
|
606
|
+
return this.update(taskId, {
|
|
607
|
+
state: 'cancelled',
|
|
608
|
+
error: {
|
|
609
|
+
code: 'PLUGIN_EXECUTION_CANCELLED',
|
|
610
|
+
message: 'Plugin task cancelled before execution',
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
if (task.state === 'running') {
|
|
615
|
+
const updated = this.update(taskId, { state: 'cancelling' });
|
|
616
|
+
this.running.get(taskId)?.controller.abort();
|
|
617
|
+
return updated;
|
|
618
|
+
}
|
|
619
|
+
return task;
|
|
620
|
+
}
|
|
621
|
+
async events(taskId, scope, cursor, waitMs = 0, signal) {
|
|
622
|
+
const task = this.authorize(taskId, scope);
|
|
623
|
+
if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > 25000)
|
|
624
|
+
throw new Error('Invalid plugin task event wait');
|
|
625
|
+
const current = this.store.events(taskId, cursor);
|
|
626
|
+
if (waitMs && cursor === task.sequence && isPluginTaskActive(task.state)) {
|
|
627
|
+
await new Promise((resolve, reject) => {
|
|
628
|
+
const finish = () => {
|
|
629
|
+
cleanup();
|
|
630
|
+
resolve();
|
|
631
|
+
};
|
|
632
|
+
const abort = () => {
|
|
633
|
+
cleanup();
|
|
634
|
+
reject(signal?.reason ?? new Error('Plugin task subscription closed'));
|
|
635
|
+
};
|
|
636
|
+
const timer = setTimeout(finish, waitMs);
|
|
637
|
+
const cleanup = () => {
|
|
638
|
+
clearTimeout(timer);
|
|
639
|
+
this.changes.off(taskId, finish);
|
|
640
|
+
signal?.removeEventListener('abort', abort);
|
|
641
|
+
};
|
|
642
|
+
this.changes.once(taskId, finish);
|
|
643
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
644
|
+
if (signal?.aborted)
|
|
645
|
+
abort();
|
|
646
|
+
else if (this.store.read(taskId).task.sequence !== cursor)
|
|
647
|
+
finish();
|
|
648
|
+
});
|
|
649
|
+
return this.store.events(taskId, cursor);
|
|
650
|
+
}
|
|
651
|
+
return current;
|
|
652
|
+
}
|
|
653
|
+
resultChunk(taskId, scope, index) {
|
|
654
|
+
this.authorize(taskId, scope);
|
|
655
|
+
return this.store.resultChunk(taskId, index);
|
|
656
|
+
}
|
|
657
|
+
update(taskId, update, type = 'state') {
|
|
658
|
+
const task = this.store.update(taskId, update, type);
|
|
659
|
+
if (!isPluginTaskActive(task.state) && task.parentTaskId) {
|
|
660
|
+
const children = this.activeChildren.get(task.parentTaskId);
|
|
661
|
+
children?.delete(taskId);
|
|
662
|
+
if (!children?.size)
|
|
663
|
+
this.activeChildren.delete(task.parentTaskId);
|
|
664
|
+
}
|
|
665
|
+
this.changes.emit(taskId);
|
|
666
|
+
return task;
|
|
667
|
+
}
|
|
668
|
+
drain() {
|
|
669
|
+
if (this.closed)
|
|
670
|
+
return;
|
|
671
|
+
for (const [taskId, resume] of this.resuming) {
|
|
672
|
+
const parent = this.running.get(taskId);
|
|
673
|
+
if (!parent) {
|
|
674
|
+
this.resuming.delete(taskId);
|
|
675
|
+
resume();
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
const limit = parent.kind === 'model'
|
|
679
|
+
? (this.configuration.modelConcurrency ?? 2)
|
|
680
|
+
: (this.configuration.concurrency ?? 4);
|
|
681
|
+
if ([...this.running.values()].filter(item => item.kind === parent.kind && !item.waiting)
|
|
682
|
+
.length < limit) {
|
|
683
|
+
this.resuming.delete(taskId);
|
|
684
|
+
resume();
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
for (const [taskId, pending] of this.queue) {
|
|
688
|
+
const kind = pending.request.kind ?? 'command';
|
|
689
|
+
const limit = kind === 'model'
|
|
690
|
+
? (this.configuration.modelConcurrency ?? 2)
|
|
691
|
+
: (this.configuration.concurrency ?? 4);
|
|
692
|
+
if ([...this.running.values()].filter(item => item.kind === kind && !item.waiting).length >=
|
|
693
|
+
limit)
|
|
694
|
+
continue;
|
|
695
|
+
this.queue.delete(taskId);
|
|
696
|
+
this.update(taskId, { state: 'running' });
|
|
697
|
+
const completed = this.execute(taskId, pending).finally(() => {
|
|
698
|
+
this.running.delete(taskId);
|
|
699
|
+
this.drain();
|
|
700
|
+
});
|
|
701
|
+
this.running.set(taskId, { controller: pending.controller, completed, kind, waiting: 0 });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
async execute(taskId, { request, revision, bridge, controller, guard, page, recovery, parentIdentity, documentTarget, }) {
|
|
705
|
+
const client = bridge.scope.bridge === 'service' ? new UnifiedClient() : undefined;
|
|
706
|
+
try {
|
|
707
|
+
await guard?.();
|
|
708
|
+
const identity = Object.freeze({
|
|
709
|
+
...createPluginInvocationIdentity(request.plugin, bridge.scope.source, parentIdentity),
|
|
710
|
+
taskId,
|
|
711
|
+
installationId: revision.installationId,
|
|
712
|
+
revision: revision.revision,
|
|
713
|
+
...(page ? { page } : {}),
|
|
714
|
+
...(recovery ? { recovery } : {}),
|
|
715
|
+
});
|
|
716
|
+
{
|
|
717
|
+
const record = this.store.read(taskId);
|
|
718
|
+
record.diagnostics = {
|
|
719
|
+
...record.diagnostics,
|
|
720
|
+
invocationId: identity.invocationId,
|
|
721
|
+
executorAttempted: true,
|
|
722
|
+
};
|
|
723
|
+
this.store.write(record);
|
|
724
|
+
}
|
|
725
|
+
const restrictedTarget = page?.target ?? documentTarget;
|
|
726
|
+
let diagnosticUpdates = 0;
|
|
727
|
+
let recoveryDescription;
|
|
728
|
+
const modelOwner = page?.instanceId ?? parentIdentity?.taskId;
|
|
729
|
+
const conversation = request.kind === 'model' &&
|
|
730
|
+
request.input.request.conversation;
|
|
731
|
+
if (conversation && !modelOwner)
|
|
732
|
+
throw error('PLUGIN_MODEL_CONVERSATION_SCOPE', 'Model conversations require a bound document or calling command');
|
|
733
|
+
const execute = this.configuration.execute ??
|
|
734
|
+
(conversation && this.services?.model
|
|
735
|
+
? this.services.model.bind(this.services, modelOwner)
|
|
736
|
+
: executeRegisteredPlugin);
|
|
737
|
+
let result = await execute(request.plugin, { command: request.command, input: request.input }, {
|
|
738
|
+
invocation: identity,
|
|
739
|
+
source: bridge.scope.source,
|
|
740
|
+
invoke: async (action, payload = {}, options = {}, onProgress) => {
|
|
741
|
+
controller.signal.throwIfAborted();
|
|
742
|
+
await guard?.();
|
|
743
|
+
controller.signal.throwIfAborted();
|
|
744
|
+
if (action === 'plugin_invoke_child') {
|
|
745
|
+
const name = payload.plugin;
|
|
746
|
+
const invocation = payload.invocation;
|
|
747
|
+
if (typeof name !== 'string' ||
|
|
748
|
+
!invocation ||
|
|
749
|
+
typeof invocation.command !== 'string' ||
|
|
750
|
+
!invocation.input ||
|
|
751
|
+
typeof invocation.input !== 'object' ||
|
|
752
|
+
Array.isArray(invocation.input))
|
|
753
|
+
throw new Error('Invalid nested plugin invocation');
|
|
754
|
+
this.assertRouting(options, request.options);
|
|
755
|
+
let ancestor = taskId, depth = 0;
|
|
756
|
+
while (ancestor) {
|
|
757
|
+
const task = this.store.read(ancestor).task;
|
|
758
|
+
if (task.plugin === name)
|
|
759
|
+
throw error('PLUGIN_INVOCATION_CYCLE', 'Plugin invocation forms a cycle');
|
|
760
|
+
if (++depth >= 16)
|
|
761
|
+
throw error('PLUGIN_INVOCATION_DEPTH', 'Plugin invocation depth exceeds 16');
|
|
762
|
+
ancestor = task.parentTaskId;
|
|
763
|
+
}
|
|
764
|
+
this.assertChildBudget(taskId);
|
|
765
|
+
const childRevision = (this.configuration.captureRevision ?? capturePluginRevision)(name);
|
|
766
|
+
const childBridge = {
|
|
767
|
+
...bridge,
|
|
768
|
+
scope: { ...bridge.scope, access: 'public', plugin: name },
|
|
769
|
+
};
|
|
770
|
+
const child = this.start({
|
|
771
|
+
...request,
|
|
772
|
+
plugin: name,
|
|
773
|
+
kind: 'command',
|
|
774
|
+
command: invocation.command,
|
|
775
|
+
input: invocation.input,
|
|
776
|
+
options: { ...request.options, ...options },
|
|
777
|
+
idempotencyKey: undefined,
|
|
778
|
+
}, childBridge, {
|
|
779
|
+
revision: childRevision,
|
|
780
|
+
guard,
|
|
781
|
+
parentTaskId: taskId,
|
|
782
|
+
parentIdentity: identity,
|
|
783
|
+
documentTarget: restrictedTarget,
|
|
784
|
+
});
|
|
785
|
+
return this.awaitChild(taskId, child.taskId, childBridge.scope, controller.signal);
|
|
786
|
+
}
|
|
787
|
+
// Plugin-owned services need the installation, revision and page guard held by this task.
|
|
788
|
+
if (['plugin_state', 'plugin_model', 'plugin_page', 'plugin_task_checkpoint'].includes(action) &&
|
|
789
|
+
payload.plugin !== request.plugin)
|
|
790
|
+
throw error('PLUGIN_CALLER_SCOPE_MISMATCH', 'Plugin-owned services require the executing plugin identity');
|
|
791
|
+
if (action === 'plugin_task_checkpoint') {
|
|
792
|
+
if (request.kind === 'model' ||
|
|
793
|
+
page ||
|
|
794
|
+
bridge.scope.access !== 'public' ||
|
|
795
|
+
bridge.scope.bridge !== 'service')
|
|
796
|
+
throw error('PLUGIN_TASK_RECOVERY_UNAVAILABLE', 'Checkpoints require a command with service-owned routing');
|
|
797
|
+
const json = JSON.stringify(payload.data);
|
|
798
|
+
if (!Number.isSafeInteger(payload.expectedSequence) ||
|
|
799
|
+
payload.expectedSequence < 0 ||
|
|
800
|
+
json === undefined ||
|
|
801
|
+
Buffer.byteLength(json) > 64 * 1024)
|
|
802
|
+
throw new Error('Checkpoint must have a CAS sequence and fit within 64 KiB');
|
|
803
|
+
const data = JSON.parse(json);
|
|
804
|
+
const commands = (await (recoveryDescription ??= this.describeRevision(revision, controller.signal))).taskRecovery?.commands;
|
|
805
|
+
const declaration = commands && Object.hasOwn(commands, request.command)
|
|
806
|
+
? commands[request.command]
|
|
807
|
+
: undefined;
|
|
808
|
+
controller.signal.throwIfAborted();
|
|
809
|
+
if (declaration &&
|
|
810
|
+
payload.expectedDeclaration !== pluginRecoverySignature(declaration))
|
|
811
|
+
throw error('PLUGIN_RECOVERY_DECLARATION_CHANGED', 'Checkpoint declaration differs from the executing command');
|
|
812
|
+
if (!declaration || !compilePluginStateSchema(declaration.schema)(data))
|
|
813
|
+
throw error('PLUGIN_TASK_CHECKPOINT_INVALID', 'Checkpoint is undeclared or does not match its schema');
|
|
814
|
+
const current = this.store.read(taskId);
|
|
815
|
+
if (current.task.state !== 'running' ||
|
|
816
|
+
payload.expectedSequence !== (current.checkpoint?.sequence ?? 0))
|
|
817
|
+
throw error('PLUGIN_TASK_CHECKPOINT_CONFLICT', 'Checkpoint sequence or execution state changed');
|
|
818
|
+
const checkpoint = {
|
|
819
|
+
data,
|
|
820
|
+
version: declaration.checkpointVersion,
|
|
821
|
+
schemaHash: digest(declaration.schema),
|
|
822
|
+
sequence: payload.expectedSequence + 1,
|
|
823
|
+
updatedAt: Date.now(),
|
|
824
|
+
};
|
|
825
|
+
this.store.update(taskId, {
|
|
826
|
+
checkpoint: {
|
|
827
|
+
sequence: checkpoint.sequence,
|
|
828
|
+
version: checkpoint.version,
|
|
829
|
+
schemaHash: checkpoint.schemaHash,
|
|
830
|
+
updatedAt: checkpoint.updatedAt,
|
|
831
|
+
},
|
|
832
|
+
}, 'checkpoint', { checkpoint });
|
|
833
|
+
this.changes.emit(taskId);
|
|
834
|
+
return checkpoint;
|
|
835
|
+
}
|
|
836
|
+
if (action === 'plugin_state') {
|
|
837
|
+
if (page &&
|
|
838
|
+
payload.instanceId !== undefined &&
|
|
839
|
+
payload.instanceId !== page.instanceId)
|
|
840
|
+
throw error('PLUGIN_PAGE_TARGET_CHANGED', 'State access cannot change the bound page');
|
|
841
|
+
if (!this.services)
|
|
842
|
+
throw new Error('Plugin host services are unavailable');
|
|
843
|
+
return this.services.state(request.plugin, { ...payload, ...(page ? { instanceId: page.instanceId } : {}) }, bridge, request.options, { revision, signal: controller.signal, expectedTarget: restrictedTarget });
|
|
844
|
+
}
|
|
845
|
+
if (action === 'plugin_model') {
|
|
846
|
+
if (payload.operation === 'capabilities')
|
|
847
|
+
return pluginModelCapabilities();
|
|
848
|
+
if (payload.operation === 'list') {
|
|
849
|
+
if (!this.services)
|
|
850
|
+
throw new Error('Plugin host services are unavailable');
|
|
851
|
+
return this.services.listModels(payload.provider);
|
|
852
|
+
}
|
|
853
|
+
if (payload.operation !== 'run')
|
|
854
|
+
throw new Error('Plugin commands use model.run; detached model tasks require an explicit task entry');
|
|
855
|
+
this.assertChildBudget(taskId);
|
|
856
|
+
const model = normalizePluginModel(payload.request);
|
|
857
|
+
const started = this.start({
|
|
858
|
+
...request,
|
|
859
|
+
kind: 'model',
|
|
860
|
+
command: 'model',
|
|
861
|
+
input: { request: model },
|
|
862
|
+
idempotencyKey: undefined,
|
|
863
|
+
}, bridge, {
|
|
864
|
+
revision,
|
|
865
|
+
guard,
|
|
866
|
+
page,
|
|
867
|
+
parentTaskId: taskId,
|
|
868
|
+
parentIdentity: identity,
|
|
869
|
+
documentTarget: restrictedTarget,
|
|
870
|
+
});
|
|
871
|
+
return this.awaitChild(taskId, started.taskId, bridge.scope, controller.signal, onProgress);
|
|
872
|
+
}
|
|
873
|
+
if (action === 'plugin_page') {
|
|
874
|
+
if (!this.services)
|
|
875
|
+
throw new Error('Plugin host services are unavailable');
|
|
876
|
+
if (page &&
|
|
877
|
+
!(payload.operation === 'activation' && payload.enabled === undefined) &&
|
|
878
|
+
(!['send', 'get', 'events'].includes(String(payload.operation)) ||
|
|
879
|
+
payload.instanceId !== page.instanceId))
|
|
880
|
+
throw error('PLUGIN_PAGE_TARGET_CHANGED', 'Runtime tasks can only access their bound document');
|
|
881
|
+
return this.services.page(request.plugin, payload, bridge, request.options, {
|
|
882
|
+
cwd: request.cwd,
|
|
883
|
+
environment: request.environment,
|
|
884
|
+
signal: controller.signal,
|
|
885
|
+
revision,
|
|
886
|
+
expectedTarget: restrictedTarget,
|
|
887
|
+
parentIdentity: identity,
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
if (restrictedTarget &&
|
|
891
|
+
!['send_request', 'send_mtop_request', 'browser_list', 'connector_list'].includes(action))
|
|
892
|
+
throw error('PLUGIN_PAGE_TARGET_CHANGED', 'Runtime tasks must access the document through context.page');
|
|
893
|
+
this.assertRouting(options, request.options);
|
|
894
|
+
if (client) {
|
|
895
|
+
if (COMMAND_MAP.has(action) &&
|
|
896
|
+
!['browser_list', 'connector_list', 'agent_chat_run'].includes(action) &&
|
|
897
|
+
!request.options.browser)
|
|
898
|
+
throw error('PLUGIN_TASK_TARGET_REQUIRED', 'Background browser tasks require an explicit browser target');
|
|
899
|
+
return client.invoke(action, payload, { ...request.options, ...options });
|
|
900
|
+
}
|
|
901
|
+
return bridge.invoke(action, payload, { ...request.options, ...options });
|
|
902
|
+
},
|
|
903
|
+
}, {
|
|
904
|
+
...(request.kind === 'model'
|
|
905
|
+
? { model: request.input.request }
|
|
906
|
+
: {}),
|
|
907
|
+
recovery: recovery !== undefined,
|
|
908
|
+
onDiagnostic: metadata => {
|
|
909
|
+
const record = this.store.read(taskId);
|
|
910
|
+
if (++diagnosticUpdates > 64) {
|
|
911
|
+
if (!record.diagnostics?.updatesDropped) {
|
|
912
|
+
record.diagnostics = { ...record.diagnostics, updatesDropped: true };
|
|
913
|
+
this.store.write(record);
|
|
914
|
+
}
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const model = normalizeModelDiagnostic(metadata, record.diagnostics?.model);
|
|
918
|
+
if (JSON.stringify(model) === JSON.stringify(record.diagnostics?.model))
|
|
919
|
+
return;
|
|
920
|
+
this.store.update(taskId, {}, 'diagnostic', {
|
|
921
|
+
diagnostics: { ...record.diagnostics, model },
|
|
922
|
+
});
|
|
923
|
+
this.changes.emit(taskId);
|
|
924
|
+
},
|
|
925
|
+
onExecutorStopped: () => {
|
|
926
|
+
const record = this.store.read(taskId);
|
|
927
|
+
record.diagnostics = { ...record.diagnostics, executorStoppedAt: Date.now() };
|
|
928
|
+
this.store.write(record);
|
|
929
|
+
},
|
|
930
|
+
onExecutorStarted: owner => {
|
|
931
|
+
const record = this.store.read(taskId);
|
|
932
|
+
record.executorOwner = owner;
|
|
933
|
+
this.store.write(record);
|
|
934
|
+
},
|
|
935
|
+
...request.options,
|
|
936
|
+
cwd: request.cwd,
|
|
937
|
+
environment: request.environment,
|
|
938
|
+
signal: controller.signal,
|
|
939
|
+
source: bridge.scope.source,
|
|
940
|
+
access: bridge.scope.access,
|
|
941
|
+
executionTimeoutMs: request.executionTimeoutMs,
|
|
942
|
+
workerPath: this.configuration.workerPath,
|
|
943
|
+
onProgress: progress => {
|
|
944
|
+
if (Buffer.byteLength(JSON.stringify(progress)) > 64 * 1024)
|
|
945
|
+
throw new Error('Plugin task progress exceeds 64 KiB');
|
|
946
|
+
this.update(taskId, { progress }, 'progress');
|
|
947
|
+
},
|
|
948
|
+
}, { registration: revision.registration });
|
|
949
|
+
await guard?.();
|
|
950
|
+
if (request.kind === 'model')
|
|
951
|
+
result = validatePluginModelResult(request.input.request, result);
|
|
952
|
+
await this.stopChildren(taskId);
|
|
953
|
+
if (!page && request.kind !== 'model')
|
|
954
|
+
await this.services?.releaseModels?.(taskId);
|
|
955
|
+
const reference = this.store.saveResult(taskId, result);
|
|
956
|
+
this.update(taskId, { state: 'success', result: reference });
|
|
957
|
+
}
|
|
958
|
+
catch (cause) {
|
|
959
|
+
await this.stopChildren(taskId);
|
|
960
|
+
let failureCause = cause;
|
|
961
|
+
if (!page && request.kind !== 'model') {
|
|
962
|
+
try {
|
|
963
|
+
await this.services?.releaseModels?.(taskId);
|
|
964
|
+
}
|
|
965
|
+
catch (cleanupError) {
|
|
966
|
+
failureCause = cleanupError;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
const failure = serializePluginExecutionError(failureCause);
|
|
970
|
+
const interrupted = this.closed || failure.code === 'PLUGIN_TASK_BRIDGE_UNAVAILABLE';
|
|
971
|
+
this.update(taskId, {
|
|
972
|
+
state: interrupted
|
|
973
|
+
? 'interrupted'
|
|
974
|
+
: failure.code === 'PLUGIN_EXECUTION_CANCELLED'
|
|
975
|
+
? 'cancelled'
|
|
976
|
+
: 'error',
|
|
977
|
+
error: failure,
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
finally {
|
|
981
|
+
client?.disconnect();
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
assertRouting(options, bound) {
|
|
985
|
+
if (Object.keys(options).some(key => !['browser', 'connector', 'session', 'local', 'transport', 'timeoutSec'].includes(key)))
|
|
986
|
+
throw error('PLUGIN_TASK_TARGET_CHANGED', 'Plugin task cannot override internal routing options');
|
|
987
|
+
for (const key of ['browser', 'connector', 'session', 'local', 'transport'])
|
|
988
|
+
if (options[key] !== undefined && options[key] !== bound[key])
|
|
989
|
+
throw error('PLUGIN_TASK_TARGET_CHANGED', 'Plugin task cannot change its bound browser routing');
|
|
990
|
+
}
|
|
991
|
+
assertChildBudget(parent) {
|
|
992
|
+
if (!this.running.get(parent)?.waiting &&
|
|
993
|
+
[...this.running.values()].filter(item => item.waiting).length >= 32)
|
|
994
|
+
throw error('PLUGIN_TASK_NESTING_BUDGET', 'Too many suspended plugin invocations');
|
|
995
|
+
}
|
|
996
|
+
async awaitChild(parent, child, scope, signal, onProgress) {
|
|
997
|
+
const running = this.running.get(parent);
|
|
998
|
+
if (running)
|
|
999
|
+
running.waiting++;
|
|
1000
|
+
queueMicrotask(() => this.drain());
|
|
1001
|
+
const cancel = () => this.cancel(child, scope);
|
|
1002
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
1003
|
+
if (signal.aborted)
|
|
1004
|
+
cancel();
|
|
1005
|
+
try {
|
|
1006
|
+
return await this.waitResult(child, scope, progress => {
|
|
1007
|
+
if (progress !== undefined && !signal.aborted)
|
|
1008
|
+
onProgress?.(progress);
|
|
1009
|
+
if (isPluginTaskActive(this.store.read(parent).task.state))
|
|
1010
|
+
this.update(parent, { progress }, 'progress');
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
finally {
|
|
1014
|
+
signal.removeEventListener('abort', cancel);
|
|
1015
|
+
if (running?.waiting === 1 && !signal.aborted && !this.closed) {
|
|
1016
|
+
await new Promise(resolve => {
|
|
1017
|
+
const resume = () => {
|
|
1018
|
+
this.resuming.delete(parent);
|
|
1019
|
+
signal.removeEventListener('abort', resume);
|
|
1020
|
+
running.waiting--;
|
|
1021
|
+
resolve();
|
|
1022
|
+
};
|
|
1023
|
+
this.resuming.set(parent, resume);
|
|
1024
|
+
signal.addEventListener('abort', resume, { once: true });
|
|
1025
|
+
this.drain();
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
else if (running)
|
|
1029
|
+
running.waiting--;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
async stopChildren(taskId) {
|
|
1033
|
+
const children = [...(this.activeChildren.get(taskId) ?? [])];
|
|
1034
|
+
for (const child of children)
|
|
1035
|
+
this.cancel(child, { access: 'public', source: 'runtime', bridge: 'service' });
|
|
1036
|
+
await Promise.allSettled(children.map(child => this.running.get(child)?.completed));
|
|
1037
|
+
}
|
|
1038
|
+
async waitResult(taskId, scope, onProgress) {
|
|
1039
|
+
let cursor = 0;
|
|
1040
|
+
while (true) {
|
|
1041
|
+
const page = await this.events(taskId, scope, cursor, 25000);
|
|
1042
|
+
cursor = page.nextCursor;
|
|
1043
|
+
for (const event of page.events)
|
|
1044
|
+
if (event.type === 'progress')
|
|
1045
|
+
onProgress?.(event.progress);
|
|
1046
|
+
if (page.task.state === 'success') {
|
|
1047
|
+
const chunks = [];
|
|
1048
|
+
for (let index = 0; index < page.task.result.chunks; index++)
|
|
1049
|
+
chunks.push(Buffer.from(this.resultChunk(taskId, scope, index).data, 'base64'));
|
|
1050
|
+
const bytes = Buffer.concat(chunks);
|
|
1051
|
+
if (bytes.length !== page.task.result.bytes ||
|
|
1052
|
+
createHash('sha256').update(bytes).digest('hex') !== page.task.result.sha256)
|
|
1053
|
+
throw new Error('Model task result integrity check failed');
|
|
1054
|
+
return JSON.parse(bytes.toString('utf8')).value;
|
|
1055
|
+
}
|
|
1056
|
+
if (!isPluginTaskActive(page.task.state))
|
|
1057
|
+
throw Object.assign(pluginExecutionError(page.task.error ?? { message: 'Model task failed' }), { childTaskId: taskId });
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
async close() {
|
|
1061
|
+
this.closed = true;
|
|
1062
|
+
this.descriptions.clear();
|
|
1063
|
+
this.descriptionBytes = 0;
|
|
1064
|
+
for (const resume of [...this.resuming.values()])
|
|
1065
|
+
resume();
|
|
1066
|
+
for (const operation of this.recoveries.values())
|
|
1067
|
+
operation.controller.abort();
|
|
1068
|
+
clearInterval(this.maintenance);
|
|
1069
|
+
await Promise.allSettled([...this.recoveries.values()].map(operation => operation.promise));
|
|
1070
|
+
for (const taskId of this.queue.keys())
|
|
1071
|
+
this.update(taskId, {
|
|
1072
|
+
state: 'interrupted',
|
|
1073
|
+
error: { code: 'PLUGIN_TASK_HOST_STOPPED', message: 'Plugin execution service stopped' },
|
|
1074
|
+
});
|
|
1075
|
+
this.queue.clear();
|
|
1076
|
+
for (const { controller } of this.running.values())
|
|
1077
|
+
controller.abort();
|
|
1078
|
+
await Promise.allSettled([...[...this.running.values()].map(item => item.completed)]);
|
|
1079
|
+
}
|
|
1080
|
+
}
|