@cjhyy/code-shell-server 0.9.21 → 0.9.23

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.
Files changed (104) hide show
  1. package/README.md +45 -1
  2. package/dist/bin/code-shell-settings-recovery.d.ts +2 -0
  3. package/dist/bin/code-shell-settings-recovery.js +4 -0
  4. package/dist/desktop-web/http-api.js +14 -0
  5. package/dist/environment-identity.d.ts +4 -0
  6. package/dist/environment-identity.js +67 -0
  7. package/dist/hub/auth-store.d.ts +2 -0
  8. package/dist/hub/auth-store.js +7 -0
  9. package/dist/index.auth.d.ts +3 -0
  10. package/dist/index.auth.js +2 -0
  11. package/dist/index.links.d.ts +1 -0
  12. package/dist/index.links.js +1 -0
  13. package/dist/index.panels.d.ts +7 -0
  14. package/dist/index.panels.js +7 -0
  15. package/dist/index.remote-relay.d.ts +5 -0
  16. package/dist/index.remote-relay.js +6 -0
  17. package/dist/index.serve.d.ts +2 -0
  18. package/dist/index.serve.js +2 -0
  19. package/dist/links/http.d.ts +3 -0
  20. package/dist/links/http.js +11 -2
  21. package/dist/links/remote-configuration.d.ts +4 -0
  22. package/dist/links/remote-configuration.js +32 -0
  23. package/dist/links/service.d.ts +6 -1
  24. package/dist/links/service.js +241 -19
  25. package/dist/mobile-remote/mobile-upload-service.d.ts +4 -0
  26. package/dist/mobile-remote/mobile-upload-service.js +69 -11
  27. package/dist/mobile-remote/remote-host-manager.d.ts +10 -5
  28. package/dist/mobile-remote/remote-host-manager.js +53 -5
  29. package/dist/panels/agent-task-service.js +9 -2
  30. package/dist/panels/automations.d.ts +49 -0
  31. package/dist/panels/automations.js +134 -0
  32. package/dist/panels/bridge-contract.d.ts +5 -3
  33. package/dist/panels/bridge-contract.js +11 -1
  34. package/dist/panels/directory-bookmarks.d.ts +9 -1
  35. package/dist/panels/directory-bookmarks.js +64 -5
  36. package/dist/panels/execution-gate.d.ts +26 -0
  37. package/dist/panels/execution-gate.js +70 -0
  38. package/dist/panels/http.d.ts +26 -2
  39. package/dist/panels/http.js +7 -1
  40. package/dist/panels/hub-automations.d.ts +31 -0
  41. package/dist/panels/hub-automations.js +352 -0
  42. package/dist/panels/management-http.d.ts +20 -2
  43. package/dist/panels/management-http.js +15 -2
  44. package/dist/panels/management.d.ts +26 -4
  45. package/dist/panels/management.js +382 -39
  46. package/dist/panels/package-mutation.d.ts +8 -0
  47. package/dist/panels/package-mutation.js +23 -0
  48. package/dist/panels/process-service.d.ts +3 -0
  49. package/dist/panels/process-service.js +77 -4
  50. package/dist/panels/resources/http.d.ts +11 -0
  51. package/dist/panels/resources/http.js +74 -0
  52. package/dist/panels/resources/references.d.ts +5 -0
  53. package/dist/panels/resources/references.js +26 -0
  54. package/dist/panels/runtime-services.js +16 -3
  55. package/dist/panels/runtime.d.ts +16 -4
  56. package/dist/panels/runtime.js +522 -64
  57. package/dist/panels/shared-tool-jobs.d.ts +40 -0
  58. package/dist/panels/shared-tool-jobs.js +37 -0
  59. package/dist/panels/storage-store.d.ts +21 -0
  60. package/dist/panels/storage-store.js +43 -0
  61. package/dist/panels/task-cookie-host.d.ts +26 -0
  62. package/dist/panels/task-cookie-host.js +145 -0
  63. package/dist/panels/task-cookies.d.ts +51 -0
  64. package/dist/panels/task-cookies.js +183 -0
  65. package/dist/panels/tool-executor.d.ts +21 -5
  66. package/dist/panels/tool-executor.js +171 -30
  67. package/dist/panels/tool-jobs-storage.d.ts +2 -0
  68. package/dist/panels/tool-jobs-storage.js +34 -0
  69. package/dist/panels/tool-jobs.d.ts +49 -3
  70. package/dist/panels/tool-jobs.js +270 -15
  71. package/dist/panels/types.d.ts +43 -0
  72. package/dist/project-runtime/backup.d.ts +26 -0
  73. package/dist/project-runtime/backup.js +500 -0
  74. package/dist/project-runtime/control-server.d.ts +4 -0
  75. package/dist/project-runtime/control-server.js +19 -1
  76. package/dist/project-runtime/docker-provider.d.ts +4 -0
  77. package/dist/project-runtime/docker-provider.js +16 -1
  78. package/dist/project-runtime/managed-entry.js +4 -0
  79. package/dist/project-runtime/registry.d.ts +2 -0
  80. package/dist/project-runtime/registry.js +15 -0
  81. package/dist/project-runtime/seccomp.d.ts +8 -0
  82. package/dist/project-runtime/seccomp.js +72 -0
  83. package/dist/project-runtime/types.d.ts +2 -0
  84. package/dist/remote-relay/byte-stream.d.ts +9 -0
  85. package/dist/remote-relay/byte-stream.js +71 -0
  86. package/dist/remote-relay/connector.d.ts +27 -0
  87. package/dist/remote-relay/connector.js +197 -0
  88. package/dist/remote-relay/local-stream.d.ts +10 -0
  89. package/dist/remote-relay/local-stream.js +120 -0
  90. package/dist/remote-relay/protocol.d.ts +39 -0
  91. package/dist/remote-relay/protocol.js +64 -0
  92. package/dist/serve/automation-worker.d.ts +16 -0
  93. package/dist/serve/automation-worker.js +126 -0
  94. package/dist/serve/cli.d.ts +3 -0
  95. package/dist/serve/cli.js +17 -0
  96. package/dist/serve/headless-server.d.ts +3 -0
  97. package/dist/serve/headless-server.js +140 -10
  98. package/dist/serve/settings-recovery-cli.d.ts +29 -0
  99. package/dist/serve/settings-recovery-cli.js +128 -0
  100. package/dist/session-http-responses.d.ts +7 -0
  101. package/dist/session-http-responses.js +40 -0
  102. package/dist/worker-bridge-core.d.ts +9 -1
  103. package/dist/worker-bridge-core.js +45 -0
  104. package/package.json +15 -6
@@ -1,10 +1,13 @@
1
+ import { panelExecutionGate } from "./execution-gate.js";
1
2
  import { createHash, randomUUID } from "node:crypto";
2
3
  import { opendir } from "node:fs/promises";
3
4
  import { isAbsolute, join, resolve } from "node:path";
4
5
  import { ToolJobStorage, toolJobJson } from "./tool-jobs-storage.js";
5
6
  export const toolJobLimits = Object.freeze({
6
7
  maxJobs: 2000,
7
- maxQueuedPerScope: 32,
8
+ maxQueuedPerScope: 128,
9
+ maxQueues: 512,
10
+ maxQueueCatalogBytes: 4 * 1024 * 1024,
8
11
  maxConcurrent: 2,
9
12
  maxInputBytes: 2 * 1024 * 1024 + 8 * 1024,
10
13
  maxResultBytes: 2 * 1024 * 1024,
@@ -12,6 +15,43 @@ export const toolJobLimits = Object.freeze({
12
15
  maxProgressBytes: 4 * 1024,
13
16
  maxExaminedEntries: 4096,
14
17
  });
18
+ const queueKey = (scope) => JSON.stringify(scopeValue(scope));
19
+ function queueState(value) {
20
+ if (!value ||
21
+ typeof value.paused !== "boolean" ||
22
+ !Number.isSafeInteger(value.revision) ||
23
+ value.revision < 0 ||
24
+ !Number.isSafeInteger(value.maxConcurrent) ||
25
+ value.maxConcurrent < 1 ||
26
+ value.maxConcurrent > toolJobLimits.maxConcurrent)
27
+ throw new Error("Invalid tool queue state");
28
+ return { revision: value.revision, paused: value.paused, maxConcurrent: value.maxConcurrent };
29
+ }
30
+ async function waitPreparation(result, signal) {
31
+ signal?.throwIfAborted();
32
+ let abort;
33
+ try {
34
+ const outcome = signal
35
+ ? await Promise.race([
36
+ result,
37
+ new Promise((_resolve, reject) => {
38
+ abort = () => reject(signal.reason ?? new Error("Tool request cancelled"));
39
+ signal.addEventListener("abort", abort, { once: true });
40
+ if (signal.aborted)
41
+ abort();
42
+ }),
43
+ ])
44
+ : await result;
45
+ signal?.throwIfAborted();
46
+ if ("error" in outcome)
47
+ throw outcome.error;
48
+ return outcome.job;
49
+ }
50
+ finally {
51
+ if (abort)
52
+ signal?.removeEventListener("abort", abort);
53
+ }
54
+ }
15
55
  const TERMINAL = new Set(["succeeded", "failed", "cancelled", "interrupted"]);
16
56
  const STATUSES = new Set([...TERMINAL, "queued", "running", "cancelling"]);
17
57
  function scopeValue(scope) {
@@ -63,6 +103,20 @@ function publicJob(job) {
63
103
  const { version: _version, requestKey: _requestKey, requestDigest: _requestDigest, ...value } = job;
64
104
  return toolJobJson(value, toolJobLimits.maxRecordBytes);
65
105
  }
106
+ function packageValue(value) {
107
+ if (!value ||
108
+ typeof value.version !== "string" ||
109
+ !value.version ||
110
+ value.version.length > 128 ||
111
+ /[\u0000-\u001f\u007f]/.test(value.version) ||
112
+ typeof value.packageDigest !== "string" ||
113
+ !/^[a-f0-9]{64}$/.test(value.packageDigest))
114
+ throw new Error("Invalid tool job package identity");
115
+ return { version: value.version, packageDigest: value.packageDigest };
116
+ }
117
+ function packageError(code, message, retryable = true) {
118
+ return Object.assign(new Error(message), { code, retryable });
119
+ }
66
120
  function storedValue(raw) {
67
121
  const job = toolJobJson(raw, toolJobLimits.maxRecordBytes);
68
122
  if (!job ||
@@ -80,6 +134,8 @@ function storedValue(raw) {
80
134
  throw new Error("invalid tool job record");
81
135
  job.scope = scopeValue(job.scope);
82
136
  job.entry = entryValue(job.entry);
137
+ if (job.package !== undefined)
138
+ job.package = packageValue(job.package);
83
139
  job.input = toolJobJson(job.input, toolJobLimits.maxInputBytes);
84
140
  if (job.result !== undefined)
85
141
  job.result = toolJobJson(job.result, toolJobLimits.maxResultBytes);
@@ -99,6 +155,7 @@ export class PanelToolJobService {
99
155
  options;
100
156
  storage;
101
157
  jobs = new Map();
158
+ queues = new Map();
102
159
  active = new Map();
103
160
  preparing = new Map();
104
161
  ready;
@@ -106,13 +163,31 @@ export class PanelToolJobService {
106
163
  stopping = false;
107
164
  pumping = false;
108
165
  shutdownPromise;
166
+ unregisterExecution;
109
167
  constructor(options) {
110
168
  this.options = options;
111
169
  this.storage = new ToolJobStorage(options.rootDir);
170
+ this.unregisterExecution = panelExecutionGate.register(() => this.executionScopes());
112
171
  }
113
- activeCount() {
114
- return (this.preparing.size +
115
- [...this.jobs.values()].filter((job) => !TERMINAL.has(job.status)).length);
172
+ listeners = new Set();
173
+ /** Trusted Host subscription. Transports must recheck their viewer's authorization. */
174
+ subscribe(rawScope, send) {
175
+ const listener = { scope: scopeValue(rawScope), send };
176
+ this.listeners.add(listener);
177
+ return () => {
178
+ this.listeners.delete(listener);
179
+ };
180
+ }
181
+ executionScopes() {
182
+ return [
183
+ ...[...this.preparing.values()].map((item) => item.scope),
184
+ ...[...this.jobs.values()]
185
+ .filter((job) => !TERMINAL.has(job.status) || this.active.has(job.id))
186
+ .map((job) => job.scope),
187
+ ];
188
+ }
189
+ activeCount(projectPath) {
190
+ return this.executionScopes().filter((scope) => !projectPath || scope.projectPath === resolve(projectPath)).length;
116
191
  }
117
192
  now() {
118
193
  return this.options.now?.() ?? Date.now();
@@ -126,12 +201,46 @@ export class PanelToolJobService {
126
201
  if (this.options.isAuthorized && !(await this.options.isAuthorized(scope)))
127
202
  throw new Error("tool job owner is no longer authorized");
128
203
  }
204
+ async describePackage(scope) {
205
+ if (!this.options.describePackage)
206
+ return undefined;
207
+ try {
208
+ return packageValue(await this.options.describePackage(scope));
209
+ }
210
+ catch {
211
+ throw packageError("PACKAGE_UNAVAILABLE", "任务的项目安装包无法校验,请恢复原包后重试。");
212
+ }
213
+ }
214
+ async verifyPackage(job) {
215
+ if (!job.package && !this.options.describePackage)
216
+ return; // Legacy standalone embedders.
217
+ if (!job.package)
218
+ throw packageError("PACKAGE_UNKNOWN", "旧任务未记录安装包版本,请检查输入并重新提交任务。", false);
219
+ const current = await this.describePackage(job.scope);
220
+ if (!current)
221
+ throw packageError("PACKAGE_UNAVAILABLE", "当前 Host 无法校验此任务的原安装包。");
222
+ if (current.version !== job.package.version ||
223
+ current.packageDigest !== job.package.packageDigest)
224
+ throw packageError("PACKAGE_CHANGED", "项目安装包与任务启动时不一致,请恢复原版本,或检查输入并创建新任务。");
225
+ }
226
+ readOnly(job, scope) {
227
+ return (job.scope.revision !== scope.revision || (!!this.options.describePackage && !job.package));
228
+ }
129
229
  initialize() {
130
230
  return (this.ready ??= this.load());
131
231
  }
132
232
  async load() {
133
233
  await this.storage.initialize();
134
234
  try {
235
+ const queues = await this.storage.readQueueCatalog(toolJobLimits.maxQueueCatalogBytes);
236
+ if (!Array.isArray(queues) || queues.length > toolJobLimits.maxQueues)
237
+ throw new Error("Invalid tool queue catalog");
238
+ for (const raw of queues) {
239
+ const scope = scopeValue(raw?.scope), key = queueKey(scope);
240
+ if (this.queues.has(key))
241
+ throw new Error("Duplicate tool queue scope");
242
+ this.queues.set(key, { scope, ...queueState(raw) });
243
+ }
135
244
  const listing = await opendir(await this.storage.directory());
136
245
  let examined = 0;
137
246
  for await (const item of listing) {
@@ -181,6 +290,17 @@ export class PanelToolJobService {
181
290
  catch {
182
291
  /* A caller may reconnect via list/get. */
183
292
  }
293
+ for (const listener of this.listeners) {
294
+ if (!sameScope(listener.scope, job.scope))
295
+ continue;
296
+ try {
297
+ const { input: _input, result: _result, ...event } = publicJob(job);
298
+ listener.send(event);
299
+ }
300
+ catch {
301
+ /* One viewer must not interrupt persistence. */
302
+ }
303
+ }
184
304
  }
185
305
  lookup(scope, id, mutate = false) {
186
306
  if (typeof id !== "string")
@@ -192,6 +312,16 @@ export class PanelToolJobService {
192
312
  throw new Error("old-revision tool jobs are read-only; create a new compatible task");
193
313
  return job;
194
314
  }
315
+ /** Scoped existence check for transports merging read-only legacy history. */
316
+ async has(rawScope, id) {
317
+ const scope = scopeValue(rawScope);
318
+ await this.initialize();
319
+ await this.authorize(scope);
320
+ if (typeof id !== "string")
321
+ throw new Error("tool job ID is required");
322
+ const job = this.jobs.get(id);
323
+ return !!job && sameScope(job.scope, scope, false);
324
+ }
195
325
  async list(rawScope) {
196
326
  const scope = scopeValue(rawScope);
197
327
  await this.initialize();
@@ -199,18 +329,91 @@ export class PanelToolJobService {
199
329
  return [...this.jobs.values()]
200
330
  .filter((job) => sameScope(job.scope, scope, false))
201
331
  .sort((a, b) => b.createdAt - a.createdAt)
202
- .map((job) => ({ ...publicJob(job), readOnly: job.scope.revision !== scope.revision }));
332
+ .map((job) => ({ ...publicJob(job), readOnly: this.readOnly(job, scope) }));
203
333
  }
204
334
  async get(rawScope, id) {
205
335
  const scope = scopeValue(rawScope);
206
336
  await this.initialize();
207
337
  await this.authorize(scope);
208
338
  const job = this.lookup(scope, id);
209
- return { ...publicJob(job), readOnly: job.scope.revision !== scope.revision };
339
+ return { ...publicJob(job), readOnly: this.readOnly(job, scope) };
340
+ }
341
+ async find(rawScope, requestKey) {
342
+ const scope = scopeValue(rawScope);
343
+ if (typeof requestKey !== "string" || !requestKey || requestKey.length > 160)
344
+ throw new Error("invalid tool job request key");
345
+ await this.initialize();
346
+ await this.authorize(scope);
347
+ const job = [...this.jobs.values()].find((item) => sameScope(item.scope, scope) && item.requestKey === requestKey);
348
+ return job ? publicJob(job) : null;
349
+ }
350
+ queueFor(scope) {
351
+ return queueState(this.queues.get(queueKey(scope)) ?? {
352
+ revision: 0,
353
+ paused: false,
354
+ maxConcurrent: toolJobLimits.maxConcurrent,
355
+ });
356
+ }
357
+ async getQueue(rawScope) {
358
+ const scope = scopeValue(rawScope);
359
+ await this.initialize();
360
+ await this.authorize(scope);
361
+ return this.queueFor(scope);
362
+ }
363
+ async setQueue(rawScope, update) {
364
+ const scope = scopeValue(rawScope);
365
+ const desired = queueState({
366
+ revision: update?.expectedRevision,
367
+ paused: update?.paused,
368
+ maxConcurrent: update?.maxConcurrent,
369
+ });
370
+ if (desired.revision >= Number.MAX_SAFE_INTEGER)
371
+ throw new Error("Tool queue revision exhausted");
372
+ await this.initialize();
373
+ await this.authorize(scope);
374
+ const result = await this.exclusive(async () => {
375
+ if (this.stopping)
376
+ throw new Error("tool job service is shutting down");
377
+ await this.authorize(scope);
378
+ const current = this.queueFor(scope);
379
+ if (current.revision !== desired.revision)
380
+ return { saved: false, queue: current };
381
+ const key = queueKey(scope);
382
+ if (!this.queues.has(key) && this.queues.size >= toolJobLimits.maxQueues)
383
+ throw new Error("Tool queue catalog is full");
384
+ const next = { scope, ...desired, revision: current.revision + 1 };
385
+ const catalog = new Map(this.queues);
386
+ catalog.set(key, next);
387
+ await this.storage.writeQueueCatalog([...catalog.values()], toolJobLimits.maxQueueCatalogBytes);
388
+ this.queues.set(key, next);
389
+ return { saved: true, queue: queueState(next) };
390
+ });
391
+ this.schedule();
392
+ return result;
393
+ }
394
+ runnable(job) {
395
+ if (job.status !== "queued")
396
+ return false;
397
+ const queue = this.queueFor(job.scope);
398
+ if (queue.paused)
399
+ return false;
400
+ let active = 0;
401
+ for (const id of this.active.keys()) {
402
+ const running = this.jobs.get(id);
403
+ if (running && sameScope(running.scope, job.scope))
404
+ active++;
405
+ }
406
+ return active < queue.maxConcurrent;
407
+ }
408
+ pendingCount(scope) {
409
+ return ([...this.jobs.values()].filter((job) => sameScope(job.scope, scope) && !TERMINAL.has(job.status)).length + [...this.preparing.values()].filter((job) => sameScope(job.scope, scope)).length);
210
410
  }
211
411
  async start(rawScope, request, signal) {
212
- signal?.throwIfAborted();
213
412
  const scope = scopeValue(rawScope);
413
+ return panelExecutionGate.run(scope, () => this.startAdmitted(scope, request, signal));
414
+ }
415
+ async startAdmitted(scope, request, signal) {
416
+ signal?.throwIfAborted();
214
417
  const entry = entryValue(request?.entry);
215
418
  if (!["manual", "retry"].includes(request.recovery))
216
419
  throw new Error("tool job recovery must be manual or retry");
@@ -232,6 +435,8 @@ export class PanelToolJobService {
232
435
  await this.initialize();
233
436
  await this.authorize(scope);
234
437
  let done;
438
+ let settle;
439
+ let outcome;
235
440
  const reservation = await this.exclusive(async () => {
236
441
  signal?.throwIfAborted();
237
442
  if (this.stopping)
@@ -243,6 +448,12 @@ export class PanelToolJobService {
243
448
  throw new Error("tool job request key was already used for different input");
244
449
  return { existing };
245
450
  }
451
+ const pending = [...this.preparing.values()].find((item) => sameScope(item.scope, scope) && item.requestKey === request.requestKey);
452
+ if (pending) {
453
+ if (pending.requestDigest !== requestDigest)
454
+ throw new Error("tool job request key was already used for different input");
455
+ return { following: pending.result };
456
+ }
246
457
  }
247
458
  while (this.jobs.size + this.preparing.size >= toolJobLimits.maxJobs) {
248
459
  const oldest = [...this.jobs.values()]
@@ -253,14 +464,18 @@ export class PanelToolJobService {
253
464
  await this.storage.remove(oldest.id);
254
465
  this.jobs.delete(oldest.id);
255
466
  }
256
- 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;
257
- if (pending >= toolJobLimits.maxQueuedPerScope)
467
+ if (this.pendingCount(scope) >= toolJobLimits.maxQueuedPerScope)
258
468
  throw new Error("too many queued tool jobs for this workspace");
259
469
  const id = randomUUID();
260
470
  const controller = new AbortController();
261
471
  this.preparing.set(id, {
262
472
  scope,
263
473
  controller,
474
+ requestKey: request.requestKey,
475
+ requestDigest,
476
+ result: new Promise((resolveResult) => {
477
+ settle = resolveResult;
478
+ }),
264
479
  done: new Promise((resolve) => {
265
480
  done = resolve;
266
481
  }),
@@ -269,6 +484,12 @@ export class PanelToolJobService {
269
484
  });
270
485
  if (reservation.existing)
271
486
  return publicJob(reservation.existing);
487
+ if (reservation.following) {
488
+ const job = await waitPreparation(reservation.following, signal);
489
+ await this.authorize(scope);
490
+ signal?.throwIfAborted();
491
+ return toolJobJson(job, toolJobLimits.maxRecordBytes);
492
+ }
272
493
  const { id, controller } = reservation;
273
494
  const abort = () => controller.abort(signal?.reason);
274
495
  signal?.addEventListener("abort", abort, { once: true });
@@ -276,6 +497,7 @@ export class PanelToolJobService {
276
497
  abort();
277
498
  try {
278
499
  controller.signal.throwIfAborted();
500
+ const selectedPackage = await this.describePackage(scope);
279
501
  const workDir = await this.storage.directory(id, true, true);
280
502
  controller.signal.throwIfAborted();
281
503
  const prepared = this.options.prepareInput
@@ -286,6 +508,7 @@ export class PanelToolJobService {
286
508
  id: id,
287
509
  scope,
288
510
  entry,
511
+ ...(selectedPackage ? { package: selectedPackage } : {}),
289
512
  input: toolJobJson(prepared, toolJobLimits.maxInputBytes),
290
513
  recovery: request.recovery,
291
514
  status: "queued",
@@ -297,6 +520,7 @@ export class PanelToolJobService {
297
520
  requestDigest,
298
521
  };
299
522
  await this.authorize(scope);
523
+ await this.verifyPackage(job);
300
524
  await this.exclusive(async () => {
301
525
  if (this.stopping || controller.signal.aborted)
302
526
  throw new Error("tool job input preparation was interrupted");
@@ -319,9 +543,12 @@ export class PanelToolJobService {
319
543
  }
320
544
  });
321
545
  this.schedule();
322
- return publicJob(job);
546
+ const result = publicJob(job);
547
+ outcome = { job: publicJob(job) };
548
+ return result;
323
549
  }
324
550
  catch (error) {
551
+ outcome = { error };
325
552
  const path = await this.storage.directory(id).catch(() => undefined);
326
553
  if (path && !this.jobs.has(id))
327
554
  await this.storage.remove(id);
@@ -330,6 +557,7 @@ export class PanelToolJobService {
330
557
  finally {
331
558
  signal?.removeEventListener("abort", abort);
332
559
  this.preparing.delete(id);
560
+ settle(outcome);
333
561
  done();
334
562
  }
335
563
  }
@@ -342,7 +570,7 @@ export class PanelToolJobService {
342
570
  this.pumping = false;
343
571
  if (!this.stopping &&
344
572
  this.active.size < toolJobLimits.maxConcurrent &&
345
- [...this.jobs.values()].some((job) => job.status === "queued"))
573
+ [...this.jobs.values()].some((job) => this.runnable(job)))
346
574
  this.schedule();
347
575
  }, () => {
348
576
  this.pumping = false;
@@ -354,7 +582,7 @@ export class PanelToolJobService {
354
582
  const selected = await this.exclusive(async () => {
355
583
  if (this.stopping)
356
584
  return;
357
- const current = [...this.jobs.values()].find((job) => job.status === "queued");
585
+ const current = [...this.jobs.values()].find((job) => this.runnable(job));
358
586
  if (!current)
359
587
  return;
360
588
  const job = structuredClone(current);
@@ -390,6 +618,7 @@ export class PanelToolJobService {
390
618
  let failure;
391
619
  try {
392
620
  await this.authorize(job.scope);
621
+ await this.verifyPackage(job);
393
622
  if (this.stopping)
394
623
  controller.abort();
395
624
  if (controller.signal.aborted)
@@ -472,6 +701,9 @@ export class PanelToolJobService {
472
701
  }
473
702
  async retry(rawScope, id) {
474
703
  const scope = scopeValue(rawScope);
704
+ return panelExecutionGate.run(scope, () => this.retryAdmitted(scope, id));
705
+ }
706
+ async retryAdmitted(scope, id) {
475
707
  await this.initialize();
476
708
  await this.authorize(scope);
477
709
  const job = await this.exclusive(async () => {
@@ -482,6 +714,9 @@ export class PanelToolJobService {
482
714
  throw new Error("tool job must finish stopping before retry");
483
715
  if (current.recovery !== "retry" || !current.error?.retryable)
484
716
  throw new Error("tool job requires a new manually reviewed request");
717
+ if (this.pendingCount(scope) >= toolJobLimits.maxQueuedPerScope)
718
+ throw new Error("too many queued tool jobs for this workspace");
719
+ await this.verifyPackage(current);
485
720
  const next = structuredClone(current);
486
721
  next.status = "queued";
487
722
  delete next.error;
@@ -494,12 +729,30 @@ export class PanelToolJobService {
494
729
  this.schedule();
495
730
  return job;
496
731
  }
497
- async cancelApp(appId) {
732
+ async cancelProject(projectPath, appId) {
733
+ await this.initialize();
734
+ const project = resolve(projectPath);
735
+ const ids = appId
736
+ ? [appId]
737
+ : [
738
+ ...new Set([
739
+ ...[...this.preparing.values()]
740
+ .filter((item) => item.scope.projectPath === project)
741
+ .map((item) => item.scope.appId),
742
+ ...[...this.jobs.values()]
743
+ .filter((job) => job.scope.projectPath === project)
744
+ .map((job) => job.scope.appId),
745
+ ]),
746
+ ];
747
+ await Promise.all(ids.map((id) => this.cancelApp(id, project)));
748
+ }
749
+ async cancelApp(appId, projectPath) {
498
750
  await this.initialize();
751
+ const matches = (scope) => scope.appId === appId && (!projectPath || scope.projectPath === resolve(projectPath));
499
752
  for (const item of this.preparing.values())
500
- if (item.scope.appId === appId)
753
+ if (matches(item.scope))
501
754
  item.controller.abort();
502
- const matching = [...this.jobs.values()].filter((job) => job.scope.appId === appId && !TERMINAL.has(job.status));
755
+ const matching = [...this.jobs.values()].filter((job) => matches(job.scope) && !TERMINAL.has(job.status));
503
756
  // Revocation bypasses the now-revoked caller grant but still waits for real exits.
504
757
  await this.exclusive(async () => {
505
758
  for (const current of matching) {
@@ -551,6 +804,8 @@ export class PanelToolJobService {
551
804
  await this.commit(job);
552
805
  }
553
806
  });
807
+ this.listeners.clear();
554
808
  await this.storage.close();
809
+ this.unregisterExecution();
555
810
  }
556
811
  }
@@ -12,6 +12,7 @@ export interface PanelGitSource {
12
12
  commit: string;
13
13
  }
14
14
  export interface ManagedPanel extends Omit<PanelAppPreview, "reviewToken" | "alreadyInstalled" | "warnings" | "source"> {
15
+ packageDigest?: string;
15
16
  revision: string;
16
17
  bound: boolean;
17
18
  enabled: boolean;
@@ -27,10 +28,21 @@ export interface ManagedPanel extends Omit<PanelAppPreview, "reviewToken" | "alr
27
28
  };
28
29
  compatibility: PanelCompatibility;
29
30
  }
31
+ export interface PanelPackageIssue {
32
+ id: string;
33
+ revision: string;
34
+ code: "package_unavailable";
35
+ version?: string;
36
+ packageDigest?: string;
37
+ bound: boolean;
38
+ globalDisabled: boolean;
39
+ }
30
40
  export interface PanelSnapshot {
31
41
  panels: ManagedPanel[];
42
+ issues?: PanelPackageIssue[];
32
43
  workspace: string;
33
44
  hasProject: boolean;
45
+ canRestorePackages?: boolean;
34
46
  }
35
47
  export interface PanelDiscovery {
36
48
  panels: GitPanelAppDiscoveryCandidate[];
@@ -46,7 +58,38 @@ export interface PanelReview {
46
58
  expectedRevision: string | null;
47
59
  compatibility: PanelCompatibility;
48
60
  }
61
+ /** Trusted native hosts may review local sources without exposing paths to Web routes. */
62
+ export interface PanelProjectReview extends Omit<PanelReview, "source"> {
63
+ installedVersion?: string;
64
+ }
49
65
  export interface PanelOperationContext {
50
66
  ownerId: string;
51
67
  authorize: () => boolean | Promise<boolean>;
52
68
  }
69
+ export interface PanelPackageVersion {
70
+ version: string;
71
+ packageDigest: string;
72
+ permissions: PanelAppPreview["permissions"];
73
+ compatibility: PanelCompatibility;
74
+ }
75
+ export interface PanelPackageHistory {
76
+ appId: string;
77
+ title: PanelAppPreview["title"];
78
+ expectedRevision: string;
79
+ current: {
80
+ version: string;
81
+ packageDigest?: string;
82
+ unavailable?: boolean;
83
+ };
84
+ versions: PanelPackageVersion[];
85
+ unavailablePackages: number;
86
+ }
87
+ export interface PanelPackageRestoreReview extends PanelPackageVersion {
88
+ appId: string;
89
+ title: PanelAppPreview["title"];
90
+ current: PanelPackageHistory["current"];
91
+ addedPermissions: PanelAppPreview["permissions"];
92
+ expectedRevision: string;
93
+ reviewToken: string;
94
+ expiresAt: number;
95
+ }
@@ -0,0 +1,26 @@
1
+ export interface ProjectBackupOptions {
2
+ dataDir: string;
3
+ destination: string;
4
+ /** Locally available, administrator-reviewed Linux image containing tar. No pull occurs. */
5
+ helperImage: string;
6
+ }
7
+ export interface ProjectRestoreOptions {
8
+ source: string;
9
+ destination: string;
10
+ helperImage: string;
11
+ }
12
+ /** Offline, owner-only backup. It never stops projects or modifies their volumes. */
13
+ export declare function backupProjectInstallation(options: ProjectBackupOptions): Promise<{
14
+ destination: string;
15
+ projects: number;
16
+ volumes: number;
17
+ installationId: string;
18
+ }>;
19
+ /** Restore into a new directory and new Docker volumes. Original installation is untouched. */
20
+ export declare function restoreProjectInstallation(options: ProjectRestoreOptions): Promise<{
21
+ destination: string;
22
+ installationId: string;
23
+ previousInstallationId: string;
24
+ projects: number;
25
+ volumes: number;
26
+ }>;