@cjhyy/code-shell-server 0.9.10 → 0.9.11
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/dist/index.panels.d.ts +8 -0
- package/dist/index.panels.js +8 -0
- package/dist/panels/bridge-contract.d.ts +72 -0
- package/dist/panels/bridge-contract.js +92 -0
- package/dist/panels/connections.d.ts +31 -0
- package/dist/panels/connections.js +118 -0
- package/dist/panels/process-entry.d.ts +2 -0
- package/dist/panels/process-entry.js +52 -0
- package/dist/panels/process-service.d.ts +61 -0
- package/dist/panels/process-service.js +367 -33
- package/dist/panels/process-state.d.ts +58 -0
- package/dist/panels/process-state.js +87 -0
- package/dist/panels/resources/directories.d.ts +12 -0
- package/dist/panels/resources/directories.js +89 -0
- package/dist/panels/resources/library.d.ts +63 -0
- package/dist/panels/resources/library.js +423 -0
- package/dist/panels/resources/service.d.ts +45 -0
- package/dist/panels/resources/service.js +317 -0
- package/dist/panels/resources/storage.d.ts +10 -0
- package/dist/panels/resources/storage.js +102 -0
- package/dist/panels/resources/types.d.ts +16 -0
- package/dist/panels/resources/types.js +1 -0
- package/dist/panels/resources/uploads.d.ts +64 -0
- package/dist/panels/resources/uploads.js +527 -0
- package/dist/panels/runtime.d.ts +1 -1
- package/dist/panels/runtime.js +137 -15
- package/dist/panels/tool-executor.d.ts +58 -0
- package/dist/panels/tool-executor.js +421 -0
- package/dist/panels/tool-jobs-storage.d.ts +16 -0
- package/dist/panels/tool-jobs-storage.js +169 -0
- package/dist/panels/tool-jobs.d.ts +103 -0
- package/dist/panels/tool-jobs.js +530 -0
- package/package.json +5 -5
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export declare const toolJobLimits: Readonly<{
|
|
2
|
+
maxJobs: 2000;
|
|
3
|
+
maxQueuedPerScope: 32;
|
|
4
|
+
maxConcurrent: 2;
|
|
5
|
+
maxInputBytes: number;
|
|
6
|
+
maxResultBytes: number;
|
|
7
|
+
maxRecordBytes: number;
|
|
8
|
+
maxProgressBytes: number;
|
|
9
|
+
maxExaminedEntries: 4096;
|
|
10
|
+
}>;
|
|
11
|
+
export interface ToolJobScope {
|
|
12
|
+
appId: string;
|
|
13
|
+
projectPath: string;
|
|
14
|
+
revision: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ToolJobEntry {
|
|
17
|
+
name: string;
|
|
18
|
+
sha256: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ToolJobProgress {
|
|
21
|
+
fraction?: number;
|
|
22
|
+
stage?: string;
|
|
23
|
+
message?: string;
|
|
24
|
+
}
|
|
25
|
+
export type ToolJobStatus = "queued" | "running" | "cancelling" | "succeeded" | "failed" | "cancelled" | "interrupted";
|
|
26
|
+
export interface ToolJob {
|
|
27
|
+
id: string;
|
|
28
|
+
scope: ToolJobScope;
|
|
29
|
+
entry: ToolJobEntry;
|
|
30
|
+
input: unknown;
|
|
31
|
+
recovery: "manual" | "retry";
|
|
32
|
+
status: ToolJobStatus;
|
|
33
|
+
attempt: number;
|
|
34
|
+
createdAt: number;
|
|
35
|
+
updatedAt: number;
|
|
36
|
+
startedAt?: number;
|
|
37
|
+
completedAt?: number;
|
|
38
|
+
sequence: number;
|
|
39
|
+
progress?: ToolJobProgress;
|
|
40
|
+
error?: {
|
|
41
|
+
code: string;
|
|
42
|
+
message: string;
|
|
43
|
+
retryable: boolean;
|
|
44
|
+
};
|
|
45
|
+
result?: unknown;
|
|
46
|
+
}
|
|
47
|
+
export interface ToolJobRequest {
|
|
48
|
+
entry: ToolJobEntry;
|
|
49
|
+
input: unknown;
|
|
50
|
+
recovery: "manual" | "retry";
|
|
51
|
+
requestKey?: string;
|
|
52
|
+
}
|
|
53
|
+
export interface ToolJobContext {
|
|
54
|
+
workDir: string;
|
|
55
|
+
signal: AbortSignal;
|
|
56
|
+
reportProgress(progress: ToolJobProgress): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
export interface PanelToolJobServiceOptions {
|
|
59
|
+
rootDir: string;
|
|
60
|
+
/** Must settle only after the actual native process exits, including on abort. */
|
|
61
|
+
execute(job: ToolJob, context: ToolJobContext): Promise<unknown>;
|
|
62
|
+
/** Freeze authorized inputs into workDir; persist only relative names and safe JSON. */
|
|
63
|
+
prepareInput?(scope: ToolJobScope, input: unknown, workDir: string, signal: AbortSignal): Promise<unknown>;
|
|
64
|
+
isAuthorized?(scope: ToolJobScope): boolean | Promise<boolean>;
|
|
65
|
+
onEvent?(job: ToolJob): void;
|
|
66
|
+
now?: () => number;
|
|
67
|
+
}
|
|
68
|
+
/** Durable generic orchestration. Native entry verification and execution are Host-injected. */
|
|
69
|
+
export declare class PanelToolJobService {
|
|
70
|
+
private readonly options;
|
|
71
|
+
private readonly storage;
|
|
72
|
+
private readonly jobs;
|
|
73
|
+
private readonly active;
|
|
74
|
+
private readonly preparing;
|
|
75
|
+
private ready?;
|
|
76
|
+
private serial;
|
|
77
|
+
private stopping;
|
|
78
|
+
private pumping;
|
|
79
|
+
private shutdownPromise?;
|
|
80
|
+
constructor(options: PanelToolJobServiceOptions);
|
|
81
|
+
private now;
|
|
82
|
+
private exclusive;
|
|
83
|
+
private authorize;
|
|
84
|
+
initialize(): Promise<void>;
|
|
85
|
+
private load;
|
|
86
|
+
private commit;
|
|
87
|
+
private lookup;
|
|
88
|
+
list(rawScope: ToolJobScope): Promise<Array<ToolJob & {
|
|
89
|
+
readOnly: boolean;
|
|
90
|
+
}>>;
|
|
91
|
+
get(rawScope: ToolJobScope, id: string): Promise<ToolJob & {
|
|
92
|
+
readOnly: boolean;
|
|
93
|
+
}>;
|
|
94
|
+
start(rawScope: ToolJobScope, request: ToolJobRequest): Promise<ToolJob>;
|
|
95
|
+
private schedule;
|
|
96
|
+
private pump;
|
|
97
|
+
private run;
|
|
98
|
+
cancel(rawScope: ToolJobScope, id: string): Promise<ToolJob>;
|
|
99
|
+
retry(rawScope: ToolJobScope, id: string): Promise<ToolJob>;
|
|
100
|
+
cancelApp(appId: string): Promise<void>;
|
|
101
|
+
shutdown(): Promise<void>;
|
|
102
|
+
private stop;
|
|
103
|
+
}
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { opendir } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { ToolJobStorage, toolJobJson } from "./tool-jobs-storage.js";
|
|
5
|
+
export const toolJobLimits = Object.freeze({
|
|
6
|
+
maxJobs: 2000,
|
|
7
|
+
maxQueuedPerScope: 32,
|
|
8
|
+
maxConcurrent: 2,
|
|
9
|
+
maxInputBytes: 2 * 1024 * 1024 + 8 * 1024,
|
|
10
|
+
maxResultBytes: 2 * 1024 * 1024,
|
|
11
|
+
maxRecordBytes: 4 * 1024 * 1024 + 32 * 1024,
|
|
12
|
+
maxProgressBytes: 4 * 1024,
|
|
13
|
+
maxExaminedEntries: 4096,
|
|
14
|
+
});
|
|
15
|
+
const TERMINAL = new Set(["succeeded", "failed", "cancelled", "interrupted"]);
|
|
16
|
+
const STATUSES = new Set([...TERMINAL, "queued", "running", "cancelling"]);
|
|
17
|
+
function scopeValue(scope) {
|
|
18
|
+
if (!scope ||
|
|
19
|
+
!/^[a-z][a-z0-9-]{0,63}$/.test(scope.appId) ||
|
|
20
|
+
typeof scope.projectPath !== "string" ||
|
|
21
|
+
!isAbsolute(scope.projectPath) ||
|
|
22
|
+
scope.projectPath.length > 4096 ||
|
|
23
|
+
scope.projectPath.includes("\0") ||
|
|
24
|
+
typeof scope.revision !== "string" ||
|
|
25
|
+
!/^[a-zA-Z0-9._-]{1,128}$/.test(scope.revision))
|
|
26
|
+
throw new Error("tool jobs require a Host-bound app, workspace and revision");
|
|
27
|
+
return { appId: scope.appId, projectPath: resolve(scope.projectPath), revision: scope.revision };
|
|
28
|
+
}
|
|
29
|
+
function sameScope(a, b, revision = true) {
|
|
30
|
+
return (a.appId === b.appId &&
|
|
31
|
+
a.projectPath === b.projectPath &&
|
|
32
|
+
(!revision || a.revision === b.revision));
|
|
33
|
+
}
|
|
34
|
+
function entryValue(entry) {
|
|
35
|
+
if (!entry ||
|
|
36
|
+
typeof entry.name !== "string" ||
|
|
37
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9._+-]{0,127}$/.test(entry.name) ||
|
|
38
|
+
typeof entry.sha256 !== "string" ||
|
|
39
|
+
!/^[a-f0-9]{64}$/.test(entry.sha256))
|
|
40
|
+
throw new Error("invalid reviewed tool job entry");
|
|
41
|
+
return { name: entry.name, sha256: entry.sha256 };
|
|
42
|
+
}
|
|
43
|
+
function progressValue(progress) {
|
|
44
|
+
if (!progress || typeof progress !== "object" || Array.isArray(progress))
|
|
45
|
+
throw new Error("invalid tool job progress");
|
|
46
|
+
if (progress.fraction !== undefined &&
|
|
47
|
+
(!Number.isFinite(progress.fraction) || progress.fraction < 0 || progress.fraction > 1))
|
|
48
|
+
throw new Error("invalid tool job progress fraction");
|
|
49
|
+
for (const [name, limit] of [
|
|
50
|
+
["stage", 100],
|
|
51
|
+
["message", 1000],
|
|
52
|
+
])
|
|
53
|
+
if (progress[name] !== undefined &&
|
|
54
|
+
(typeof progress[name] !== "string" || progress[name].length > limit))
|
|
55
|
+
throw new Error("invalid tool job progress text");
|
|
56
|
+
return toolJobJson({
|
|
57
|
+
...(progress.fraction === undefined ? {} : { fraction: progress.fraction }),
|
|
58
|
+
...(progress.stage === undefined ? {} : { stage: progress.stage }),
|
|
59
|
+
...(progress.message === undefined ? {} : { message: progress.message }),
|
|
60
|
+
}, toolJobLimits.maxProgressBytes);
|
|
61
|
+
}
|
|
62
|
+
function publicJob(job) {
|
|
63
|
+
const { version: _version, requestKey: _requestKey, requestDigest: _requestDigest, ...value } = job;
|
|
64
|
+
return toolJobJson(value, toolJobLimits.maxRecordBytes);
|
|
65
|
+
}
|
|
66
|
+
function storedValue(raw) {
|
|
67
|
+
const job = toolJobJson(raw, toolJobLimits.maxRecordBytes);
|
|
68
|
+
if (!job ||
|
|
69
|
+
job.version !== 1 ||
|
|
70
|
+
!/^[a-f0-9-]{36}$/.test(job.id) ||
|
|
71
|
+
!STATUSES.has(job.status) ||
|
|
72
|
+
!Number.isSafeInteger(job.attempt) ||
|
|
73
|
+
job.attempt < 0 ||
|
|
74
|
+
!Number.isSafeInteger(job.sequence) ||
|
|
75
|
+
job.sequence < 0 ||
|
|
76
|
+
!["manual", "retry"].includes(job.recovery) ||
|
|
77
|
+
!/^[a-f0-9]{64}$/.test(job.requestDigest) ||
|
|
78
|
+
!Number.isFinite(job.createdAt) ||
|
|
79
|
+
!Number.isFinite(job.updatedAt))
|
|
80
|
+
throw new Error("invalid tool job record");
|
|
81
|
+
job.scope = scopeValue(job.scope);
|
|
82
|
+
job.entry = entryValue(job.entry);
|
|
83
|
+
job.input = toolJobJson(job.input, toolJobLimits.maxInputBytes);
|
|
84
|
+
if (job.result !== undefined)
|
|
85
|
+
job.result = toolJobJson(job.result, toolJobLimits.maxResultBytes);
|
|
86
|
+
if (job.progress)
|
|
87
|
+
job.progress = progressValue(job.progress);
|
|
88
|
+
if (job.error &&
|
|
89
|
+
(typeof job.error.code !== "string" ||
|
|
90
|
+
job.error.code.length > 100 ||
|
|
91
|
+
typeof job.error.message !== "string" ||
|
|
92
|
+
job.error.message.length > 2000 ||
|
|
93
|
+
typeof job.error.retryable !== "boolean"))
|
|
94
|
+
throw new Error("invalid tool job error");
|
|
95
|
+
return job;
|
|
96
|
+
}
|
|
97
|
+
/** Durable generic orchestration. Native entry verification and execution are Host-injected. */
|
|
98
|
+
export class PanelToolJobService {
|
|
99
|
+
options;
|
|
100
|
+
storage;
|
|
101
|
+
jobs = new Map();
|
|
102
|
+
active = new Map();
|
|
103
|
+
preparing = new Map();
|
|
104
|
+
ready;
|
|
105
|
+
serial = Promise.resolve();
|
|
106
|
+
stopping = false;
|
|
107
|
+
pumping = false;
|
|
108
|
+
shutdownPromise;
|
|
109
|
+
constructor(options) {
|
|
110
|
+
this.options = options;
|
|
111
|
+
this.storage = new ToolJobStorage(options.rootDir);
|
|
112
|
+
}
|
|
113
|
+
now() {
|
|
114
|
+
return this.options.now?.() ?? Date.now();
|
|
115
|
+
}
|
|
116
|
+
exclusive(operation) {
|
|
117
|
+
const pending = this.serial.then(operation);
|
|
118
|
+
this.serial = pending.catch(() => { });
|
|
119
|
+
return pending;
|
|
120
|
+
}
|
|
121
|
+
async authorize(scope) {
|
|
122
|
+
if (this.options.isAuthorized && !(await this.options.isAuthorized(scope)))
|
|
123
|
+
throw new Error("tool job owner is no longer authorized");
|
|
124
|
+
}
|
|
125
|
+
initialize() {
|
|
126
|
+
return (this.ready ??= this.load());
|
|
127
|
+
}
|
|
128
|
+
async load() {
|
|
129
|
+
await this.storage.initialize();
|
|
130
|
+
try {
|
|
131
|
+
const listing = await opendir(await this.storage.directory());
|
|
132
|
+
let examined = 0;
|
|
133
|
+
for await (const item of listing) {
|
|
134
|
+
if (++examined > toolJobLimits.maxExaminedEntries)
|
|
135
|
+
throw new Error("tool job storage entry limit exceeded");
|
|
136
|
+
if (!item.isDirectory() || !/^[a-f0-9-]{36}$/.test(item.name))
|
|
137
|
+
continue;
|
|
138
|
+
let job;
|
|
139
|
+
try {
|
|
140
|
+
const directory = await this.storage.directory(item.name);
|
|
141
|
+
job = storedValue(await this.storage.read(join(directory, "job.json"), toolJobLimits.maxRecordBytes));
|
|
142
|
+
if (job.id !== item.name)
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
continue; /* One partial or invalid record cannot prevent other recovery. */
|
|
147
|
+
}
|
|
148
|
+
if (this.jobs.size >= toolJobLimits.maxJobs)
|
|
149
|
+
throw new Error("tool job record limit exceeded");
|
|
150
|
+
if (!TERMINAL.has(job.status)) {
|
|
151
|
+
job.status = "interrupted";
|
|
152
|
+
job.updatedAt = job.completedAt = this.now();
|
|
153
|
+
job.sequence++;
|
|
154
|
+
job.error = {
|
|
155
|
+
code: "HOST_INTERRUPTED",
|
|
156
|
+
message: "The previous Host stopped before this task completed.",
|
|
157
|
+
retryable: job.recovery === "retry",
|
|
158
|
+
};
|
|
159
|
+
await this.storage.write(job.id, job, toolJobLimits.maxRecordBytes);
|
|
160
|
+
}
|
|
161
|
+
this.jobs.set(job.id, job);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
await this.storage.close();
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async commit(job) {
|
|
170
|
+
job.updatedAt = this.now();
|
|
171
|
+
job.sequence++;
|
|
172
|
+
await this.storage.write(job.id, job, toolJobLimits.maxRecordBytes);
|
|
173
|
+
this.jobs.set(job.id, job);
|
|
174
|
+
try {
|
|
175
|
+
this.options.onEvent?.(publicJob(job));
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* A caller may reconnect via list/get. */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
lookup(scope, id, mutate = false) {
|
|
182
|
+
if (typeof id !== "string")
|
|
183
|
+
throw new Error("tool job ID is required");
|
|
184
|
+
const job = this.jobs.get(id);
|
|
185
|
+
if (!job || !sameScope(job.scope, scope, false))
|
|
186
|
+
throw new Error("tool job does not belong to this app and workspace");
|
|
187
|
+
if (mutate && !sameScope(job.scope, scope))
|
|
188
|
+
throw new Error("old-revision tool jobs are read-only; create a new compatible task");
|
|
189
|
+
return job;
|
|
190
|
+
}
|
|
191
|
+
async list(rawScope) {
|
|
192
|
+
const scope = scopeValue(rawScope);
|
|
193
|
+
await this.initialize();
|
|
194
|
+
await this.authorize(scope);
|
|
195
|
+
return [...this.jobs.values()]
|
|
196
|
+
.filter((job) => sameScope(job.scope, scope, false))
|
|
197
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
198
|
+
.map((job) => ({ ...publicJob(job), readOnly: job.scope.revision !== scope.revision }));
|
|
199
|
+
}
|
|
200
|
+
async get(rawScope, id) {
|
|
201
|
+
const scope = scopeValue(rawScope);
|
|
202
|
+
await this.initialize();
|
|
203
|
+
await this.authorize(scope);
|
|
204
|
+
const job = this.lookup(scope, id);
|
|
205
|
+
return { ...publicJob(job), readOnly: job.scope.revision !== scope.revision };
|
|
206
|
+
}
|
|
207
|
+
async start(rawScope, request) {
|
|
208
|
+
const scope = scopeValue(rawScope);
|
|
209
|
+
const entry = entryValue(request?.entry);
|
|
210
|
+
if (!["manual", "retry"].includes(request.recovery))
|
|
211
|
+
throw new Error("tool job recovery must be manual or retry");
|
|
212
|
+
if (request.requestKey !== undefined &&
|
|
213
|
+
(typeof request.requestKey !== "string" ||
|
|
214
|
+
!request.requestKey ||
|
|
215
|
+
request.requestKey.length > 160))
|
|
216
|
+
throw new Error("invalid tool job request key");
|
|
217
|
+
const input = toolJobJson(request.input, toolJobLimits.maxInputBytes);
|
|
218
|
+
request = {
|
|
219
|
+
entry,
|
|
220
|
+
input,
|
|
221
|
+
recovery: request.recovery,
|
|
222
|
+
...(request.requestKey === undefined ? {} : { requestKey: request.requestKey }),
|
|
223
|
+
};
|
|
224
|
+
const requestDigest = createHash("sha256")
|
|
225
|
+
.update(JSON.stringify({ entry, input, recovery: request.recovery }))
|
|
226
|
+
.digest("hex");
|
|
227
|
+
await this.initialize();
|
|
228
|
+
await this.authorize(scope);
|
|
229
|
+
let done;
|
|
230
|
+
const reservation = await this.exclusive(async () => {
|
|
231
|
+
if (this.stopping)
|
|
232
|
+
throw new Error("tool job service is shutting down");
|
|
233
|
+
if (request.requestKey) {
|
|
234
|
+
const existing = [...this.jobs.values()].find((job) => sameScope(job.scope, scope) && job.requestKey === request.requestKey);
|
|
235
|
+
if (existing) {
|
|
236
|
+
if (existing.requestDigest !== requestDigest)
|
|
237
|
+
throw new Error("tool job request key was already used for different input");
|
|
238
|
+
return { existing };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
while (this.jobs.size + this.preparing.size >= toolJobLimits.maxJobs) {
|
|
242
|
+
const oldest = [...this.jobs.values()]
|
|
243
|
+
.filter((job) => TERMINAL.has(job.status) && !this.active.has(job.id))
|
|
244
|
+
.sort((a, b) => (a.completedAt ?? a.updatedAt) - (b.completedAt ?? b.updatedAt))[0];
|
|
245
|
+
if (!oldest)
|
|
246
|
+
throw new Error("tool job capacity reached with unfinished tasks");
|
|
247
|
+
await this.storage.remove(oldest.id);
|
|
248
|
+
this.jobs.delete(oldest.id);
|
|
249
|
+
}
|
|
250
|
+
const pending = [...this.jobs.values()].filter((job) => sameScope(job.scope, scope) && !TERMINAL.has(job.status)).length + [...this.preparing.values()].filter((job) => sameScope(job.scope, scope)).length;
|
|
251
|
+
if (pending >= toolJobLimits.maxQueuedPerScope)
|
|
252
|
+
throw new Error("too many queued tool jobs for this workspace");
|
|
253
|
+
const id = randomUUID();
|
|
254
|
+
const controller = new AbortController();
|
|
255
|
+
this.preparing.set(id, {
|
|
256
|
+
scope,
|
|
257
|
+
controller,
|
|
258
|
+
done: new Promise((resolve) => {
|
|
259
|
+
done = resolve;
|
|
260
|
+
}),
|
|
261
|
+
});
|
|
262
|
+
return { id, controller };
|
|
263
|
+
});
|
|
264
|
+
if (reservation.existing)
|
|
265
|
+
return publicJob(reservation.existing);
|
|
266
|
+
const { id, controller } = reservation;
|
|
267
|
+
try {
|
|
268
|
+
const workDir = await this.storage.directory(id, true, true);
|
|
269
|
+
const prepared = this.options.prepareInput
|
|
270
|
+
? await this.options.prepareInput(scope, input, workDir, controller.signal)
|
|
271
|
+
: input;
|
|
272
|
+
const job = {
|
|
273
|
+
version: 1,
|
|
274
|
+
id: id,
|
|
275
|
+
scope,
|
|
276
|
+
entry,
|
|
277
|
+
input: toolJobJson(prepared, toolJobLimits.maxInputBytes),
|
|
278
|
+
recovery: request.recovery,
|
|
279
|
+
status: "queued",
|
|
280
|
+
attempt: 0,
|
|
281
|
+
createdAt: this.now(),
|
|
282
|
+
updatedAt: this.now(),
|
|
283
|
+
sequence: 0,
|
|
284
|
+
...(request.requestKey === undefined ? {} : { requestKey: request.requestKey }),
|
|
285
|
+
requestDigest,
|
|
286
|
+
};
|
|
287
|
+
await this.authorize(scope);
|
|
288
|
+
await this.exclusive(async () => {
|
|
289
|
+
if (this.stopping || controller.signal.aborted)
|
|
290
|
+
throw new Error("tool job input preparation was interrupted");
|
|
291
|
+
if (request.requestKey &&
|
|
292
|
+
[...this.jobs.values()].some((other) => sameScope(other.scope, scope) && other.requestKey === request.requestKey))
|
|
293
|
+
throw new Error("tool job request key is already being prepared");
|
|
294
|
+
await this.commit(job);
|
|
295
|
+
});
|
|
296
|
+
this.schedule();
|
|
297
|
+
return publicJob(job);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
const path = await this.storage.directory(id).catch(() => undefined);
|
|
301
|
+
if (path && !this.jobs.has(id))
|
|
302
|
+
await this.storage.remove(id);
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
this.preparing.delete(id);
|
|
307
|
+
done();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
schedule() {
|
|
311
|
+
if (this.pumping || this.stopping)
|
|
312
|
+
return;
|
|
313
|
+
this.pumping = true;
|
|
314
|
+
queueMicrotask(() => {
|
|
315
|
+
void this.pump().then(() => {
|
|
316
|
+
this.pumping = false;
|
|
317
|
+
if (!this.stopping &&
|
|
318
|
+
this.active.size < toolJobLimits.maxConcurrent &&
|
|
319
|
+
[...this.jobs.values()].some((job) => job.status === "queued"))
|
|
320
|
+
this.schedule();
|
|
321
|
+
}, () => {
|
|
322
|
+
this.pumping = false;
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
async pump() {
|
|
327
|
+
while (!this.stopping && this.active.size < toolJobLimits.maxConcurrent) {
|
|
328
|
+
const selected = await this.exclusive(async () => {
|
|
329
|
+
if (this.stopping)
|
|
330
|
+
return;
|
|
331
|
+
const current = [...this.jobs.values()].find((job) => job.status === "queued");
|
|
332
|
+
if (!current)
|
|
333
|
+
return;
|
|
334
|
+
const job = structuredClone(current);
|
|
335
|
+
job.status = "running";
|
|
336
|
+
job.attempt++;
|
|
337
|
+
job.startedAt = this.now();
|
|
338
|
+
delete job.completedAt;
|
|
339
|
+
delete job.error;
|
|
340
|
+
delete job.result;
|
|
341
|
+
delete job.progress;
|
|
342
|
+
const controller = new AbortController();
|
|
343
|
+
let finish;
|
|
344
|
+
const done = new Promise((resolve) => {
|
|
345
|
+
finish = resolve;
|
|
346
|
+
});
|
|
347
|
+
await this.commit(job);
|
|
348
|
+
this.active.set(job.id, { controller, done });
|
|
349
|
+
return { job, controller, finish };
|
|
350
|
+
});
|
|
351
|
+
if (!selected)
|
|
352
|
+
return;
|
|
353
|
+
void this.run(selected.job, selected.controller)
|
|
354
|
+
.finally(() => {
|
|
355
|
+
this.active.delete(selected.job.id);
|
|
356
|
+
selected.finish();
|
|
357
|
+
this.schedule();
|
|
358
|
+
})
|
|
359
|
+
.catch(() => { });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async run(job, controller) {
|
|
363
|
+
let result;
|
|
364
|
+
let failure;
|
|
365
|
+
try {
|
|
366
|
+
await this.authorize(job.scope);
|
|
367
|
+
if (this.stopping)
|
|
368
|
+
controller.abort();
|
|
369
|
+
if (controller.signal.aborted)
|
|
370
|
+
throw new Error("tool job cancelled before execution");
|
|
371
|
+
result = toolJobJson((await this.options.execute(publicJob(job), {
|
|
372
|
+
workDir: await this.storage.directory(job.id, true),
|
|
373
|
+
signal: controller.signal,
|
|
374
|
+
reportProgress: async (progress) => {
|
|
375
|
+
const value = progressValue(progress);
|
|
376
|
+
if (controller.signal.aborted)
|
|
377
|
+
return;
|
|
378
|
+
await this.exclusive(async () => {
|
|
379
|
+
const current = this.jobs.get(job.id);
|
|
380
|
+
if (current.status !== "running" || controller.signal.aborted)
|
|
381
|
+
return;
|
|
382
|
+
const next = structuredClone(current);
|
|
383
|
+
next.progress = { ...next.progress, ...value };
|
|
384
|
+
await this.commit(next);
|
|
385
|
+
});
|
|
386
|
+
},
|
|
387
|
+
})) ?? null, toolJobLimits.maxResultBytes);
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
failure = error;
|
|
391
|
+
}
|
|
392
|
+
await this.exclusive(async () => {
|
|
393
|
+
const next = structuredClone(this.jobs.get(job.id));
|
|
394
|
+
next.completedAt = this.now();
|
|
395
|
+
if (this.stopping || controller.signal.aborted) {
|
|
396
|
+
next.status = this.stopping ? "interrupted" : "cancelled";
|
|
397
|
+
if (next.error?.code !== "APP_REVOKED")
|
|
398
|
+
next.error = {
|
|
399
|
+
code: this.stopping ? "HOST_INTERRUPTED" : "CANCELLED",
|
|
400
|
+
message: this.stopping ? "Host stopped this task." : "Task was cancelled.",
|
|
401
|
+
retryable: next.recovery === "retry",
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
else if (failure !== undefined) {
|
|
405
|
+
const details = failure;
|
|
406
|
+
next.status = "failed";
|
|
407
|
+
next.error = {
|
|
408
|
+
code: typeof details?.code === "string" ? details.code.slice(0, 100) : "TOOL_FAILED",
|
|
409
|
+
message: String(failure instanceof Error ? failure.message : failure).slice(0, 2000),
|
|
410
|
+
retryable: next.recovery === "retry" && details?.retryable !== false,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
next.status = "succeeded";
|
|
415
|
+
next.result = result;
|
|
416
|
+
next.progress = { ...next.progress, fraction: 1 };
|
|
417
|
+
}
|
|
418
|
+
await this.commit(next);
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
async cancel(rawScope, id) {
|
|
422
|
+
const scope = scopeValue(rawScope);
|
|
423
|
+
await this.initialize();
|
|
424
|
+
await this.authorize(scope);
|
|
425
|
+
let active;
|
|
426
|
+
await this.exclusive(async () => {
|
|
427
|
+
const current = this.lookup(scope, id, true);
|
|
428
|
+
if (TERMINAL.has(current.status))
|
|
429
|
+
return;
|
|
430
|
+
const next = structuredClone(current);
|
|
431
|
+
active = this.active.get(id);
|
|
432
|
+
next.status = active ? "cancelling" : "cancelled";
|
|
433
|
+
if (!active) {
|
|
434
|
+
next.completedAt = this.now();
|
|
435
|
+
next.error = {
|
|
436
|
+
code: "CANCELLED",
|
|
437
|
+
message: "Task was cancelled before execution.",
|
|
438
|
+
retryable: next.recovery === "retry",
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
await this.commit(next);
|
|
442
|
+
active?.controller.abort();
|
|
443
|
+
});
|
|
444
|
+
await active?.done;
|
|
445
|
+
return publicJob(this.lookup(scope, id, true));
|
|
446
|
+
}
|
|
447
|
+
async retry(rawScope, id) {
|
|
448
|
+
const scope = scopeValue(rawScope);
|
|
449
|
+
await this.initialize();
|
|
450
|
+
await this.authorize(scope);
|
|
451
|
+
const job = await this.exclusive(async () => {
|
|
452
|
+
if (this.stopping)
|
|
453
|
+
throw new Error("tool job service is shutting down");
|
|
454
|
+
const current = this.lookup(scope, id, true);
|
|
455
|
+
if (this.active.has(id) || !["failed", "cancelled", "interrupted"].includes(current.status))
|
|
456
|
+
throw new Error("tool job must finish stopping before retry");
|
|
457
|
+
if (current.recovery !== "retry" || !current.error?.retryable)
|
|
458
|
+
throw new Error("tool job requires a new manually reviewed request");
|
|
459
|
+
const next = structuredClone(current);
|
|
460
|
+
next.status = "queued";
|
|
461
|
+
delete next.error;
|
|
462
|
+
delete next.completedAt;
|
|
463
|
+
delete next.result;
|
|
464
|
+
delete next.progress;
|
|
465
|
+
await this.commit(next);
|
|
466
|
+
return publicJob(next);
|
|
467
|
+
});
|
|
468
|
+
this.schedule();
|
|
469
|
+
return job;
|
|
470
|
+
}
|
|
471
|
+
async cancelApp(appId) {
|
|
472
|
+
await this.initialize();
|
|
473
|
+
for (const item of this.preparing.values())
|
|
474
|
+
if (item.scope.appId === appId)
|
|
475
|
+
item.controller.abort();
|
|
476
|
+
const matching = [...this.jobs.values()].filter((job) => job.scope.appId === appId && !TERMINAL.has(job.status));
|
|
477
|
+
// Revocation bypasses the now-revoked caller grant but still waits for real exits.
|
|
478
|
+
await this.exclusive(async () => {
|
|
479
|
+
for (const current of matching) {
|
|
480
|
+
const job = structuredClone(this.jobs.get(current.id));
|
|
481
|
+
if (TERMINAL.has(job.status))
|
|
482
|
+
continue;
|
|
483
|
+
const active = this.active.get(job.id);
|
|
484
|
+
job.status = active ? "cancelling" : "cancelled";
|
|
485
|
+
job.error = {
|
|
486
|
+
code: "APP_REVOKED",
|
|
487
|
+
message: "The app authorization was revoked.",
|
|
488
|
+
retryable: false,
|
|
489
|
+
};
|
|
490
|
+
if (!active) {
|
|
491
|
+
job.completedAt = this.now();
|
|
492
|
+
}
|
|
493
|
+
await this.commit(job);
|
|
494
|
+
active?.controller.abort();
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
await Promise.all(matching.flatMap((job) => this.active.get(job.id)?.done ?? []));
|
|
498
|
+
}
|
|
499
|
+
shutdown() {
|
|
500
|
+
return (this.shutdownPromise ??= this.stop());
|
|
501
|
+
}
|
|
502
|
+
async stop() {
|
|
503
|
+
this.stopping = true;
|
|
504
|
+
await this.initialize();
|
|
505
|
+
// Flush admissions that were already persisting when shutdown began.
|
|
506
|
+
await this.exclusive(async () => { });
|
|
507
|
+
for (const item of this.preparing.values())
|
|
508
|
+
item.controller.abort();
|
|
509
|
+
for (const item of this.active.values())
|
|
510
|
+
item.controller.abort();
|
|
511
|
+
await Promise.all([...this.preparing.values()].map((item) => item.done));
|
|
512
|
+
await Promise.all([...this.active.values()].map((item) => item.done));
|
|
513
|
+
await this.exclusive(async () => {
|
|
514
|
+
for (const current of this.jobs.values()) {
|
|
515
|
+
if (TERMINAL.has(current.status))
|
|
516
|
+
continue;
|
|
517
|
+
const job = structuredClone(current);
|
|
518
|
+
job.status = "interrupted";
|
|
519
|
+
job.completedAt = this.now();
|
|
520
|
+
job.error = {
|
|
521
|
+
code: "HOST_INTERRUPTED",
|
|
522
|
+
message: "Host stopped before this task completed.",
|
|
523
|
+
retryable: job.recovery === "retry",
|
|
524
|
+
};
|
|
525
|
+
await this.commit(job);
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
await this.storage.close();
|
|
529
|
+
}
|
|
530
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cjhyy/code-shell-server",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.11",
|
|
4
4
|
"description": "CodeShell server transport layer — mobile remote HTTP/WS host, pairing, passcode gate, tunnel, rooms, uploads, and session/attachment services. Electron-free; precursor of the auth gateway.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -51,13 +51,13 @@
|
|
|
51
51
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@cjhyy/code-shell-capability-coding": "0.9.
|
|
55
|
-
"@cjhyy/code-shell-core": "0.9.
|
|
56
|
-
"@cjhyy/code-shell-web": "0.9.
|
|
54
|
+
"@cjhyy/code-shell-capability-coding": "0.9.11",
|
|
55
|
+
"@cjhyy/code-shell-core": "0.9.11",
|
|
56
|
+
"@cjhyy/code-shell-web": "0.9.11",
|
|
57
57
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
58
58
|
"@types/ws": "^8.18.1",
|
|
59
59
|
"ws": "^8.21.0",
|
|
60
|
-
"@cjhyy/code-shell-link": "0.9.
|
|
60
|
+
"@cjhyy/code-shell-link": "0.9.11"
|
|
61
61
|
},
|
|
62
62
|
"engines": {
|
|
63
63
|
"node": ">=20.10"
|