@xfey/tutti 0.1.60 → 0.1.62

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 (90) hide show
  1. package/README.md +2 -2
  2. package/dist/collaboration-state/clarification-records.d.ts +0 -9
  3. package/dist/collaboration-state/clarification-records.js +0 -57
  4. package/dist/collaboration-state/index.d.ts +6 -5
  5. package/dist/collaboration-state/index.js +5 -4
  6. package/dist/collaboration-state/messages.d.ts +4 -6
  7. package/dist/collaboration-state/messages.js +22 -29
  8. package/dist/collaboration-state/scratchpad-source-state.d.ts +4 -12
  9. package/dist/collaboration-state/scratchpad-source-state.js +24 -58
  10. package/dist/collaboration-state/scratchpad.d.ts +1 -2
  11. package/dist/collaboration-state/scratchpad.js +0 -59
  12. package/dist/collaboration-state/storage-types.d.ts +25 -5
  13. package/dist/collaboration-state/task-compile-context.d.ts +40 -0
  14. package/dist/collaboration-state/task-compile-context.js +273 -0
  15. package/dist/collaboration-state/types.d.ts +39 -9
  16. package/dist/control-plane/clarification-commands.d.ts +0 -1
  17. package/dist/control-plane/clarification-commands.js +0 -1
  18. package/dist/control-plane/index.js +16 -3
  19. package/dist/control-plane/project-context-bootstrap.d.ts +4 -4
  20. package/dist/control-plane/project-context-bootstrap.js +7 -12
  21. package/dist/control-plane/scratchpad-auto-refresh.js +12 -1
  22. package/dist/control-plane/scratchpad-refresh-start.js +77 -94
  23. package/dist/control-plane/scratchpad-refresh.js +3 -1
  24. package/dist/control-plane/scratchpad-source-messages.d.ts +15 -7
  25. package/dist/control-plane/scratchpad-source-messages.js +53 -69
  26. package/dist/control-plane/task-compile-clarification-context.d.ts +9 -0
  27. package/dist/control-plane/task-compile-clarification-context.js +70 -0
  28. package/dist/control-plane/task-compile-continuation.js +28 -56
  29. package/dist/control-plane/task-compile-output.js +50 -14
  30. package/dist/control-plane/task-compile-start.js +119 -55
  31. package/dist/control-plane/workflows/openai.d.ts +2 -1
  32. package/dist/control-plane/workflows/openai.js +13 -27
  33. package/dist/control-plane/workflows/types.d.ts +27 -17
  34. package/dist/server-shell/cli/args.d.ts +4 -0
  35. package/dist/server-shell/cli/args.js +11 -0
  36. package/dist/server-shell/cli/cli.js +35 -11
  37. package/dist/server-shell/cli/host-server-runtime.d.ts +2 -0
  38. package/dist/server-shell/cli/host-server-runtime.js +12 -5
  39. package/dist/server-shell/cli/local-control-client.d.ts +5 -1
  40. package/dist/server-shell/cli/local-control-client.js +19 -0
  41. package/dist/server-shell/desktop-integration/manager.d.ts +2 -0
  42. package/dist/server-shell/desktop-integration/manager.js +19 -31
  43. package/dist/server-shell/http/routes/local-control.d.ts +2 -0
  44. package/dist/server-shell/http/routes/local-control.js +24 -0
  45. package/dist/server-shell/local-console/invocation-context.js +10 -1
  46. package/dist/server-shell/local-console/operation-coordinator.d.ts +16 -0
  47. package/dist/server-shell/local-console/operation-coordinator.js +50 -0
  48. package/dist/server-shell/local-console/package-update-completion.d.ts +25 -0
  49. package/dist/server-shell/local-console/package-update-completion.js +91 -0
  50. package/dist/server-shell/local-console/package-update-hosts.d.ts +17 -0
  51. package/dist/server-shell/local-console/package-update-hosts.js +92 -0
  52. package/dist/server-shell/local-console/package-update-lock.d.ts +46 -0
  53. package/dist/server-shell/local-console/package-update-lock.js +223 -0
  54. package/dist/server-shell/local-console/package-update-log.d.ts +20 -0
  55. package/dist/server-shell/local-console/package-update-log.js +75 -0
  56. package/dist/server-shell/local-console/package-update-process.d.ts +22 -0
  57. package/dist/server-shell/local-console/package-update-process.js +252 -0
  58. package/dist/server-shell/local-console/package-update-record.d.ts +53 -0
  59. package/dist/server-shell/local-console/package-update-record.js +180 -0
  60. package/dist/server-shell/local-console/package-update-recovery.d.ts +34 -0
  61. package/dist/server-shell/local-console/package-update-recovery.js +140 -0
  62. package/dist/server-shell/local-console/package-update-service.d.ts +72 -0
  63. package/dist/server-shell/local-console/package-update-service.js +400 -0
  64. package/dist/server-shell/local-console/package-update-worker.d.ts +55 -0
  65. package/dist/server-shell/local-console/package-update-worker.js +297 -0
  66. package/dist/server-shell/local-console/project-service.d.ts +2 -0
  67. package/dist/server-shell/local-console/project-service.js +16 -12
  68. package/dist/server-shell/local-console/server.d.ts +9 -0
  69. package/dist/server-shell/local-console/server.js +92 -4
  70. package/dist/server-shell/package-installation/command.d.ts +36 -0
  71. package/dist/server-shell/package-installation/command.js +162 -0
  72. package/dist/server-shell/package-installation/discovery.d.ts +51 -0
  73. package/dist/server-shell/package-installation/discovery.js +137 -0
  74. package/dist/server-shell/package-installation/index.d.ts +5 -0
  75. package/dist/server-shell/package-installation/index.js +5 -0
  76. package/dist/server-shell/package-installation/installation.d.ts +34 -0
  77. package/dist/server-shell/package-installation/installation.js +123 -0
  78. package/dist/server-shell/package-installation/semver.d.ts +9 -0
  79. package/dist/server-shell/package-installation/semver.js +84 -0
  80. package/migrations/0014_scratchpad_task_compile_context.sql +147 -0
  81. package/migrations/README.md +2 -1
  82. package/package.json +1 -1
  83. package/prompts/procedures/README.md +3 -3
  84. package/prompts/procedures/scratchpad-refresh.md +17 -9
  85. package/prompts/procedures/task-compile.md +6 -3
  86. package/prompts/prompt-flow-map.md +21 -14
  87. package/web/assets/{homepage-motion-scene-CY7o4hnR.js → homepage-motion-scene-CRmrkkEH.js} +1 -1
  88. package/web/assets/{index-B08r3x8o.js → index-C9JrplHV.js} +14 -14
  89. package/web/assets/{index-DpabiRgp.css → index-IZKcmU_g.css} +1 -1
  90. package/web/index.html +2 -2
@@ -0,0 +1,297 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ import { ID_PREFIXES, isPrefixedId } from "@tutti/shared/ids";
5
+ import { readCliPackageIdentity } from "../cli/version.js";
6
+ import { buildGlobalInstallPlan, createPackageCommandPlan, PUBLISHED_TUTTI_PACKAGE_NAME, runPackageCommand, compareExactSemver, parseExactSemver, } from "../package-installation/index.js";
7
+ import { releasePackageUpdateLockForCurrentProcess } from "./package-update-lock.js";
8
+ import { PackageUpdateLogger } from "./package-update-log.js";
9
+ import { updatePackageUpdateRuntimeRecord, writePackageUpdateRuntimeRecord, } from "./package-update-record.js";
10
+ import { restorePackageUpdateHosts, stopPackageUpdateHost, } from "./package-update-hosts.js";
11
+ const WORKER_INPUT_KEYS = [
12
+ "cli_entrypoint",
13
+ "completion_environment",
14
+ "installation",
15
+ "restore_project_ids",
16
+ "schema_version",
17
+ "target",
18
+ "tutti_home",
19
+ ];
20
+ const INSTALLATION_KEYS = [
21
+ "currentVersion",
22
+ "globalRoot",
23
+ "nodeExecutable",
24
+ "npmEnvironment",
25
+ "npmExecutable",
26
+ "packageName",
27
+ "packageRoot",
28
+ ];
29
+ const NPM_ENV_KEYS = new Set([
30
+ "HOME",
31
+ "LANG",
32
+ "LC_ALL",
33
+ "LC_CTYPE",
34
+ "LOGNAME",
35
+ "NO_PROXY",
36
+ "NPM_CONFIG_UPDATE_NOTIFIER",
37
+ "PATH",
38
+ "TMPDIR",
39
+ "USER",
40
+ ]);
41
+ const COMPLETION_ENV_KEYS = new Set([
42
+ "HOME",
43
+ "LANG",
44
+ "LC_ALL",
45
+ "LC_CTYPE",
46
+ "LOGNAME",
47
+ "NO_PROXY",
48
+ "PATH",
49
+ "TMPDIR",
50
+ "TUTTI_HOME",
51
+ "USER",
52
+ ]);
53
+ function strictObject(value, keys) {
54
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
55
+ return null;
56
+ }
57
+ const record = value;
58
+ return Object.keys(record).every((key) => keys.includes(key)) ? record : null;
59
+ }
60
+ function strictEnvironment(value, allowedKeys) {
61
+ const record = strictObject(value, [...allowedKeys]);
62
+ if (record === null ||
63
+ Object.entries(record).some(([key, nested]) => !allowedKeys.has(key) || typeof nested !== "string" || nested === "")) {
64
+ return null;
65
+ }
66
+ return record;
67
+ }
68
+ export function parsePackageUpdateWorkerInput(value, options = {}) {
69
+ const input = strictObject(value, WORKER_INPUT_KEYS);
70
+ const installation = strictObject(input?.installation, INSTALLATION_KEYS);
71
+ const target = strictObject(input?.target, ["packageName", "version"]);
72
+ const sourceVersion = parseExactSemver(installation?.currentVersion);
73
+ const targetVersion = parseExactSemver(target?.version);
74
+ const npmEnvironment = strictEnvironment(installation?.npmEnvironment, NPM_ENV_KEYS);
75
+ const completionEnvironment = strictEnvironment(input?.completion_environment, COMPLETION_ENV_KEYS);
76
+ const tuttiHome = input?.tutti_home;
77
+ const packageRoot = installation?.packageRoot;
78
+ const globalRoot = installation?.globalRoot;
79
+ if (input?.schema_version !== 1 ||
80
+ typeof tuttiHome !== "string" ||
81
+ !isAbsolute(tuttiHome) ||
82
+ resolve(tuttiHome) !== resolve(options.defaultTuttiHome ?? join(homedir(), ".tutti")) ||
83
+ typeof input.cli_entrypoint !== "string" ||
84
+ !isAbsolute(input.cli_entrypoint) ||
85
+ installation?.packageName !== PUBLISHED_TUTTI_PACKAGE_NAME ||
86
+ sourceVersion === null ||
87
+ target?.packageName !== PUBLISHED_TUTTI_PACKAGE_NAME ||
88
+ targetVersion === null ||
89
+ compareExactSemver(targetVersion, sourceVersion) <= 0 ||
90
+ typeof packageRoot !== "string" ||
91
+ !isAbsolute(packageRoot) ||
92
+ typeof globalRoot !== "string" ||
93
+ !isAbsolute(globalRoot) ||
94
+ typeof installation.npmExecutable !== "string" ||
95
+ !isAbsolute(installation.npmExecutable) ||
96
+ typeof installation.nodeExecutable !== "string" ||
97
+ !isAbsolute(installation.nodeExecutable) ||
98
+ npmEnvironment === null ||
99
+ typeof npmEnvironment.PATH !== "string" ||
100
+ completionEnvironment === null ||
101
+ completionEnvironment.TUTTI_HOME !== tuttiHome ||
102
+ typeof completionEnvironment.PATH !== "string" ||
103
+ !Array.isArray(input.restore_project_ids) ||
104
+ !input.restore_project_ids.every((projectId) => typeof projectId === "string" && isPrefixedId(projectId, ID_PREFIXES.project)) ||
105
+ new Set(input.restore_project_ids).size !== input.restore_project_ids.length) {
106
+ return null;
107
+ }
108
+ try {
109
+ if (realpathSync(join(globalRoot, ...PUBLISHED_TUTTI_PACKAGE_NAME.split("/"))) !==
110
+ realpathSync(packageRoot)) {
111
+ return null;
112
+ }
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ return {
118
+ schema_version: 1,
119
+ tutti_home: resolve(tuttiHome),
120
+ cli_entrypoint: resolve(input.cli_entrypoint),
121
+ installation: {
122
+ packageName: PUBLISHED_TUTTI_PACKAGE_NAME,
123
+ currentVersion: sourceVersion,
124
+ packageRoot: resolve(packageRoot),
125
+ globalRoot: resolve(globalRoot),
126
+ npmExecutable: resolve(installation.npmExecutable),
127
+ nodeExecutable: resolve(installation.nodeExecutable),
128
+ npmEnvironment,
129
+ },
130
+ target: { packageName: PUBLISHED_TUTTI_PACKAGE_NAME, version: targetVersion },
131
+ restore_project_ids: [...input.restore_project_ids].sort(),
132
+ completion_environment: completionEnvironment,
133
+ };
134
+ }
135
+ export function createPackageUpdateWorkerInput(options) {
136
+ if (!isAbsolute(options.tuttiHome) || !isAbsolute(options.cliEntrypoint)) {
137
+ throw new Error("Package update worker paths must be absolute.");
138
+ }
139
+ return {
140
+ schema_version: 1,
141
+ tutti_home: resolve(options.tuttiHome),
142
+ cli_entrypoint: resolve(options.cliEntrypoint),
143
+ installation: options.installation,
144
+ target: options.target,
145
+ restore_project_ids: [...new Set(options.restoreProjectIds)].sort(),
146
+ completion_environment: { ...options.completionEnvironment },
147
+ };
148
+ }
149
+ export async function verifyPackageUpdatePostInstall(input, runner = runPackageCommand, readIdentity = readCliPackageIdentity) {
150
+ try {
151
+ const identity = readIdentity();
152
+ if (identity.name !== PUBLISHED_TUTTI_PACKAGE_NAME ||
153
+ identity.version !== input.target.version ||
154
+ realpathSync(identity.root) !== realpathSync(input.installation.packageRoot) ||
155
+ !existsSync(input.cli_entrypoint)) {
156
+ return false;
157
+ }
158
+ const result = await runner(createPackageCommandPlan({
159
+ executable: input.installation.nodeExecutable,
160
+ args: [input.cli_entrypoint, "--version"],
161
+ env: input.completion_environment,
162
+ }));
163
+ return result.status === 0 && result.stdout.trim() === input.target.version;
164
+ }
165
+ catch {
166
+ return false;
167
+ }
168
+ }
169
+ export async function runPackageUpdateWorker(input, dependencies) {
170
+ const now = dependencies.now ?? (() => new Date());
171
+ const logger = dependencies.logger ??
172
+ new PackageUpdateLogger({
173
+ tuttiHome: input.tutti_home,
174
+ privatePaths: [
175
+ input.installation.packageRoot,
176
+ input.installation.globalRoot,
177
+ input.tutti_home,
178
+ ],
179
+ now,
180
+ });
181
+ const writeRecord = dependencies.writeRecord ??
182
+ ((record) => writePackageUpdateRuntimeRecord(input.tutti_home, record));
183
+ const updateRecord = dependencies.updateRecord ??
184
+ ((record, options) => updatePackageUpdateRuntimeRecord({
185
+ tuttiHome: input.tutti_home,
186
+ current: record,
187
+ phase: options.phase,
188
+ now,
189
+ ...(options.failureReasonCode === undefined
190
+ ? {}
191
+ : { failureReasonCode: options.failureReasonCode }),
192
+ ...(options.restartFailureCount === undefined
193
+ ? {}
194
+ : { restartFailureCount: options.restartFailureCount }),
195
+ }));
196
+ const releaseLock = dependencies.releaseLock ?? (() => releasePackageUpdateLockForCurrentProcess(input.tutti_home));
197
+ const restoreHosts = dependencies.restoreHosts ??
198
+ ((projectIds) => restorePackageUpdateHosts({
199
+ tuttiHome: input.tutti_home,
200
+ projectIds,
201
+ env: input.completion_environment,
202
+ }));
203
+ const stopHost = dependencies.stopHost ??
204
+ ((projectId) => stopPackageUpdateHost({ tuttiHome: input.tutti_home, projectId }));
205
+ const startedAt = now().toISOString();
206
+ let record = {
207
+ schema_version: 1,
208
+ source_version: input.installation.currentVersion,
209
+ target_version: input.target.version,
210
+ phase: "preparing",
211
+ restore_project_ids: [...input.restore_project_ids],
212
+ updater_pid: process.pid,
213
+ started_at: startedAt,
214
+ updated_at: startedAt,
215
+ };
216
+ writeRecord(record);
217
+ const log = (entry) => {
218
+ try {
219
+ logger.append(entry);
220
+ }
221
+ catch {
222
+ // Update correctness must not depend on the diagnostic log remaining writable.
223
+ }
224
+ };
225
+ log({ level: "info", message: "Package update worker accepted.", phase: record.phase });
226
+ const fail = async (reasonCode, stoppedProjectIds) => {
227
+ let restartFailureCount = stoppedProjectIds.length;
228
+ try {
229
+ const restored = await restoreHosts(stoppedProjectIds);
230
+ restartFailureCount = restored.failed_project_ids.length;
231
+ }
232
+ catch {
233
+ // Every Host in the stopped set remains conservatively classified as not restored.
234
+ }
235
+ try {
236
+ record = updateRecord(record, {
237
+ phase: "failed",
238
+ failureReasonCode: reasonCode,
239
+ restartFailureCount,
240
+ });
241
+ }
242
+ catch {
243
+ // A prior valid phase record is left for startup recovery to classify.
244
+ }
245
+ log({
246
+ level: "error",
247
+ message: "Package update stopped before completion.",
248
+ phase: record.phase,
249
+ reasonCode,
250
+ });
251
+ releaseLock();
252
+ return {
253
+ status: "failed",
254
+ reason_code: reasonCode,
255
+ restart_failure_count: restartFailureCount,
256
+ };
257
+ };
258
+ const stoppedProjectIds = [];
259
+ try {
260
+ record = updateRecord(record, { phase: "stopping_hosts" });
261
+ for (const projectId of input.restore_project_ids) {
262
+ const result = await stopHost(projectId);
263
+ if (result !== "stopped") {
264
+ return await fail(result, stoppedProjectIds);
265
+ }
266
+ stoppedProjectIds.push(projectId);
267
+ }
268
+ record = updateRecord(record, { phase: "installing" });
269
+ const install = await (dependencies.commandRunner ?? runPackageCommand)(buildGlobalInstallPlan({ installation: input.installation, target: input.target }));
270
+ log({
271
+ level: install.status === 0 ? "info" : "error",
272
+ message: install.status === 0 ? "npm install completed." : "npm install failed.",
273
+ phase: record.phase,
274
+ ...(install.stdout === "" ? {} : { stdout: install.stdout }),
275
+ ...(install.stderr === "" ? {} : { stderr: install.stderr }),
276
+ });
277
+ if (install.status !== 0) {
278
+ return await fail("npm_install_failed", stoppedProjectIds);
279
+ }
280
+ const verified = await (dependencies.verifyPostInstall ?? verifyPackageUpdatePostInstall)(input);
281
+ if (!verified) {
282
+ return await fail("post_install_validation_failed", stoppedProjectIds);
283
+ }
284
+ record = updateRecord(record, { phase: "restarting" });
285
+ await dependencies.startCompletion(input, record);
286
+ log({
287
+ level: "info",
288
+ message: "Package update handed off to the installed CLI.",
289
+ phase: record.phase,
290
+ });
291
+ return { status: "handed_off" };
292
+ }
293
+ catch {
294
+ return await fail(record.phase === "restarting" ? "completion_start_failed" : "worker_crashed", stoppedProjectIds);
295
+ }
296
+ }
297
+ //# sourceMappingURL=package-update-worker.js.map
@@ -3,6 +3,7 @@ import { type ConfigureProjectOpenAiProviderResult, type OpenAiProviderCredentia
3
3
  import { type FetchLike } from "../cli/host-runtime-endpoint.js";
4
4
  import { type RuntimeProjectRow } from "../cli/runtime-commands.js";
5
5
  import { type LocalConsoleInvocationContext } from "./invocation-context.js";
6
+ import { LocalConsoleOperationCoordinator } from "./operation-coordinator.js";
6
7
  export type LocalConsoleProject = {
7
8
  project_id: ProjectId;
8
9
  display_name: string;
@@ -55,6 +56,7 @@ export declare class LocalConsoleProjectService {
55
56
  serviceEnvironment?: NodeJS.ProcessEnv;
56
57
  fetchImpl?: FetchLike;
57
58
  validateProviderCredential?: OpenAiProviderCredentialValidator;
59
+ operations?: LocalConsoleOperationCoordinator;
58
60
  });
59
61
  listProjects(context: LocalConsoleInvocationContext): Promise<LocalConsoleProject[]>;
60
62
  discoverModels(input: {
@@ -12,6 +12,7 @@ import { resolveManagedProjectContext } from "../cli/project-resolver.js";
12
12
  import { listRuntimeProjects, runStopCommand, } from "../cli/runtime-commands.js";
13
13
  import { readCliVersion } from "../cli/version.js";
14
14
  import { createLocalConsoleOperationEnvironment, } from "./invocation-context.js";
15
+ import { LocalConsoleOperationConflictError, LocalConsoleOperationCoordinator, } from "./operation-coordinator.js";
15
16
  const OPEN_UPDATE_SHUTDOWN_WAIT_MS = 5_000;
16
17
  export class LocalConsoleProjectError extends Error {
17
18
  code;
@@ -153,12 +154,13 @@ export class LocalConsoleProjectService {
153
154
  #serviceEnvironment;
154
155
  #fetchImpl;
155
156
  #validateProviderCredential;
157
+ #operations;
156
158
  #launches = new Map();
157
- #mutationTail = Promise.resolve();
158
159
  constructor(options) {
159
160
  this.#tuttiHome = resolve(options.tuttiHome);
160
161
  this.#serviceEnvironment = options.serviceEnvironment ?? process.env;
161
162
  this.#fetchImpl = options.fetchImpl ?? fetch;
163
+ this.#operations = options.operations ?? new LocalConsoleOperationCoordinator();
162
164
  this.#validateProviderCredential =
163
165
  options.validateProviderCredential ?? validateOpenAiCredential;
164
166
  }
@@ -172,11 +174,6 @@ export class LocalConsoleProjectService {
172
174
  }),
173
175
  };
174
176
  }
175
- #runMutation(operation) {
176
- const result = this.#mutationTail.then(operation, operation);
177
- this.#mutationTail = result.then(() => undefined, () => undefined);
178
- return result;
179
- }
180
177
  async listProjects(context) {
181
178
  const operation = this.#operationOptions(context);
182
179
  const rows = await listRuntimeProjects({
@@ -293,13 +290,17 @@ export class LocalConsoleProjectService {
293
290
  if (active !== undefined) {
294
291
  return await active;
295
292
  }
296
- const launch = this.#runMutation(async () => await this.#launchProject({
293
+ const launch = this.#operations
294
+ .runProjectOperation(async () => await this.#launchProject({
297
295
  workspacePath,
298
296
  ...(input.project === undefined
299
297
  ? {}
300
298
  : { project: validateProjectMetadata(input.project) }),
301
- ...(input.provider === undefined ? {} : { provider: validateProvider(input.provider) }),
302
- }, context)).finally(() => {
299
+ ...(input.provider === undefined
300
+ ? {}
301
+ : { provider: validateProvider(input.provider) }),
302
+ }, context))
303
+ .finally(() => {
303
304
  this.#launches.delete(workspacePath);
304
305
  });
305
306
  this.#launches.set(workspacePath, launch);
@@ -380,7 +381,7 @@ export class LocalConsoleProjectService {
380
381
  }
381
382
  }
382
383
  async openProject(projectId, context) {
383
- return await this.#runMutation(async () => {
384
+ return await this.#operations.runProjectOperation(async () => {
384
385
  const current = await this.#readProject(projectId, context);
385
386
  if (current.open_url === undefined) {
386
387
  throw new LocalConsoleProjectError("project_open_unavailable", "This project does not have a Relay destination yet.");
@@ -461,7 +462,7 @@ export class LocalConsoleProjectService {
461
462
  });
462
463
  }
463
464
  async refreshInvite(projectId, context) {
464
- return await this.#runMutation(async () => {
465
+ return await this.#operations.runProjectOperation(async () => {
465
466
  const project = resolveManagedProjectContext({
466
467
  target: projectId,
467
468
  ...this.#operationOptions(context),
@@ -491,7 +492,7 @@ export class LocalConsoleProjectService {
491
492
  });
492
493
  }
493
494
  async stopProject(projectId, context) {
494
- return await this.#runMutation(async () => {
495
+ return await this.#operations.runProjectOperation(async () => {
495
496
  try {
496
497
  return {
497
498
  message: await runStopCommand(projectId, this.#operationOptions(context)),
@@ -506,6 +507,9 @@ export class LocalConsoleProjectService {
506
507
  if (error instanceof LocalConsoleProjectError) {
507
508
  return error;
508
509
  }
510
+ if (error instanceof LocalConsoleOperationConflictError) {
511
+ return new LocalConsoleProjectError(error.code, error.message, error.statusCode);
512
+ }
509
513
  if (error instanceof LaunchError) {
510
514
  return new LocalConsoleProjectError(error.code, error.message);
511
515
  }
@@ -1,4 +1,7 @@
1
1
  import { type FastifyInstance } from "fastify";
2
+ import { type PackageUpdateCompletionResult } from "./package-update-record.js";
3
+ import { LocalConsolePackageUpdateService, type LocalConsolePackageUpdatePreparation } from "./package-update-service.js";
4
+ import { LocalConsoleOperationCoordinator } from "./operation-coordinator.js";
2
5
  export declare const LOCAL_CONSOLE_API_BASE = "/local-console/v1";
3
6
  type LocalConsoleRuntimeMetadata = {
4
7
  instanceId: string;
@@ -17,6 +20,12 @@ type LocalConsoleServerOptions = {
17
20
  runtime?: LocalConsoleRuntimeMetadata;
18
21
  onActivity?: () => void;
19
22
  onShutdown?: () => void;
23
+ operationCoordinator?: LocalConsoleOperationCoordinator;
24
+ packageUpdateService?: Pick<LocalConsolePackageUpdateService, "getStatus" | "beginUpdate">;
25
+ startPackageUpdate?: (preparation: LocalConsolePackageUpdatePreparation) => Promise<{
26
+ pid: number;
27
+ }>;
28
+ getPackageUpdateCompletion?: () => PackageUpdateCompletionResult | undefined;
20
29
  };
21
30
  export declare function createLocalConsoleServer(options: LocalConsoleServerOptions): FastifyInstance;
22
31
  export declare function runLocalConsoleProcess(options?: {
@@ -7,7 +7,12 @@ import { readCliVersion } from "../cli/version.js";
7
7
  import { registerHostWebStaticRoutes, resolvePackagedWebStaticRoot } from "../http/static-web.js";
8
8
  import { detectLocalFolderPicker, pickLocalFolder } from "./folder-picker.js";
9
9
  import { LOCAL_CONSOLE_INVOCATION_ENV_KEYS, createLocalConsoleServiceEnvironment, } from "./invocation-context.js";
10
+ import { spawnPackageUpdateWorkerProcess } from "./package-update-process.js";
11
+ import { consumePackageUpdateCompletion, } from "./package-update-record.js";
12
+ import { LocalConsolePackageUpdateService, } from "./package-update-service.js";
13
+ import { createPackageUpdateWorkerInput } from "./package-update-worker.js";
10
14
  import { LocalConsoleProjectError, LocalConsoleProjectService, } from "./project-service.js";
15
+ import { LocalConsoleOperationConflictError, LocalConsoleOperationCoordinator, } from "./operation-coordinator.js";
11
16
  import { LOCAL_CONSOLE_SESSION_COOKIE, LocalConsoleSessionRegistry, localConsoleSessionCookie, parseCookieHeader, } from "./session.js";
12
17
  import { createLocalConsoleSecret, deleteLocalConsoleRuntimeEndpoint, writeLocalConsoleRuntimeEndpoint, } from "./runtime-endpoint.js";
13
18
  export const LOCAL_CONSOLE_API_BASE = "/local-console/v1";
@@ -66,10 +71,51 @@ export function createLocalConsoleServer(options) {
66
71
  const sessions = new LocalConsoleSessionRegistry({
67
72
  ...(options.now === undefined ? {} : { now: options.now }),
68
73
  });
74
+ const operations = options.operationCoordinator ?? new LocalConsoleOperationCoordinator();
69
75
  const projects = new LocalConsoleProjectService({
70
76
  tuttiHome,
77
+ operations,
71
78
  ...(options.env === undefined ? {} : { serviceEnvironment: options.env }),
72
79
  });
80
+ const packageUpdates = options.packageUpdateService ??
81
+ new LocalConsolePackageUpdateService({
82
+ tuttiHome,
83
+ operations,
84
+ ...(options.env === undefined ? {} : { env: options.env }),
85
+ });
86
+ const startPackageUpdate = options.startPackageUpdate ??
87
+ (async (preparation) => {
88
+ try {
89
+ const entrypoint = options.runtime?.entrypoint;
90
+ if (entrypoint === undefined) {
91
+ throw new Error("Local Console package update entrypoint is unavailable.");
92
+ }
93
+ const completionEnvironment = createLocalConsoleServiceEnvironment({
94
+ env: options.env ?? process.env,
95
+ tuttiHome,
96
+ });
97
+ completionEnvironment.PATH = preparation.installation.npmEnvironment.PATH;
98
+ const noProxy = preparation.installation.npmEnvironment.NO_PROXY;
99
+ if (noProxy !== undefined && noProxy !== "") {
100
+ completionEnvironment.NO_PROXY = noProxy;
101
+ }
102
+ return await spawnPackageUpdateWorkerProcess({
103
+ input: createPackageUpdateWorkerInput({
104
+ tuttiHome,
105
+ cliEntrypoint: entrypoint,
106
+ installation: preparation.installation,
107
+ target: preparation.target,
108
+ restoreProjectIds: preparation.restore_project_ids,
109
+ completionEnvironment,
110
+ }),
111
+ lease: preparation.lease,
112
+ });
113
+ }
114
+ catch (error) {
115
+ preparation.lease.release();
116
+ throw error;
117
+ }
118
+ });
73
119
  app.addHook("onRequest", (request, reply, done) => {
74
120
  try {
75
121
  requireLoopbackHost(request);
@@ -88,7 +134,9 @@ export function createLocalConsoleServer(options) {
88
134
  }
89
135
  });
90
136
  app.setErrorHandler((error, _request, reply) => {
91
- const normalized = error instanceof LocalConsoleHttpError || error instanceof LocalConsoleProjectError
137
+ const normalized = error instanceof LocalConsoleHttpError ||
138
+ error instanceof LocalConsoleProjectError ||
139
+ error instanceof LocalConsoleOperationConflictError
92
140
  ? error
93
141
  : isValidationError(error)
94
142
  ? new LocalConsoleHttpError(400, "bad_request", "Local Console request is invalid.")
@@ -222,6 +270,7 @@ export function createLocalConsoleServer(options) {
222
270
  });
223
271
  app.get(`${LOCAL_CONSOLE_API_BASE}/bootstrap`, (request) => {
224
272
  const session = requireSession(request);
273
+ const packageUpdateCompletion = options.getPackageUpdateCompletion?.();
225
274
  return {
226
275
  csrf_token: session.csrfToken,
227
276
  invocation: session.context.source === "terminal"
@@ -232,8 +281,42 @@ export function createLocalConsoleServer(options) {
232
281
  : { source: "desktop" },
233
282
  platform: process.platform,
234
283
  folder_picker: detectLocalFolderPicker(process.platform, session.context.environment).kind,
284
+ ...(packageUpdateCompletion === undefined
285
+ ? {}
286
+ : { package_update_completion: packageUpdateCompletion }),
235
287
  };
236
288
  });
289
+ app.get(`${LOCAL_CONSOLE_API_BASE}/package-update`, {
290
+ schema: {
291
+ querystring: {
292
+ type: "object",
293
+ additionalProperties: false,
294
+ properties: { refresh: { type: "string", enum: ["true"] } },
295
+ },
296
+ },
297
+ }, async (request) => {
298
+ requireSession(request);
299
+ return await packageUpdates.getStatus({ refresh: request.query.refresh === "true" });
300
+ });
301
+ app.post(`${LOCAL_CONSOLE_API_BASE}/package-update`, async (request, reply) => {
302
+ requireSession(request, true);
303
+ if (request.body !== undefined) {
304
+ throw new LocalConsoleHttpError(400, "bad_request", "Package update does not accept browser-controlled input.");
305
+ }
306
+ const begin = await packageUpdates.beginUpdate();
307
+ if (begin.status === "blocked") {
308
+ throw new LocalConsoleHttpError(409, begin.update.reason_code ?? "package_update_blocked", "Package update is currently unavailable.");
309
+ }
310
+ await startPackageUpdate(begin.preparation);
311
+ setImmediate(() => options.onShutdown?.());
312
+ return await reply.status(202).send({
313
+ current_version: begin.preparation.installation.currentVersion,
314
+ latest_version: begin.preparation.target.version,
315
+ status: "updating",
316
+ can_update: false,
317
+ phase: "preparing",
318
+ });
319
+ });
237
320
  app.get(`${LOCAL_CONSOLE_API_BASE}/projects`, async (request) => {
238
321
  const session = requireSession(request);
239
322
  return { projects: await projects.listProjects(session.context) };
@@ -244,7 +327,7 @@ export function createLocalConsoleServer(options) {
244
327
  });
245
328
  app.post(`${LOCAL_CONSOLE_API_BASE}/folders/pick`, async (request) => {
246
329
  const session = requireSession(request, true);
247
- return await pickLocalFolder({ env: session.context.environment });
330
+ return await operations.runProjectOperation(async () => await pickLocalFolder({ env: session.context.environment }));
248
331
  });
249
332
  app.post(`${LOCAL_CONSOLE_API_BASE}/providers/models`, {
250
333
  schema: {
@@ -260,10 +343,10 @@ export function createLocalConsoleServer(options) {
260
343
  },
261
344
  }, async (request) => {
262
345
  requireSession(request, true);
263
- return await projects.discoverModels({
346
+ return await operations.runProjectOperation(async () => await projects.discoverModels({
264
347
  baseUrl: request.body.base_url,
265
348
  apiKey: request.body.api_key,
266
- });
349
+ }));
267
350
  });
268
351
  app.post(`${LOCAL_CONSOLE_API_BASE}/projects/launch`, {
269
352
  schema: {
@@ -359,6 +442,10 @@ export async function runLocalConsoleProcess(options = {}) {
359
442
  throw new Error("The Tutti CLI entrypoint is unavailable.");
360
443
  }
361
444
  let lastActivityAt = now().getTime();
445
+ const packageUpdateCompletion = consumePackageUpdateCompletion({
446
+ tuttiHome,
447
+ currentVersion: version,
448
+ });
362
449
  let requestClose = () => undefined;
363
450
  const app = createLocalConsoleServer({
364
451
  controlToken,
@@ -376,6 +463,7 @@ export async function runLocalConsoleProcess(options = {}) {
376
463
  lastActivityAt = now().getTime();
377
464
  },
378
465
  onShutdown: () => requestClose(),
466
+ getPackageUpdateCompletion: () => packageUpdateCompletion,
379
467
  ...(options.now === undefined ? {} : { now: options.now }),
380
468
  });
381
469
  await app.listen({ host: "127.0.0.1", port: 0 });
@@ -0,0 +1,36 @@
1
+ export type PackageCommandPlan = {
2
+ executable: string;
3
+ args: readonly string[];
4
+ env: NodeJS.ProcessEnv;
5
+ timeoutMs: number;
6
+ maxOutputBytes: number;
7
+ shell: false;
8
+ };
9
+ export type PackageCommandResult = {
10
+ status: number | null;
11
+ stdout: string;
12
+ stderr: string;
13
+ errorCode?: "spawn_failed" | "timeout" | "output_limit";
14
+ };
15
+ export type SyncPackageCommandRunner = (plan: PackageCommandPlan) => PackageCommandResult;
16
+ export type PackageCommandRunner = (plan: PackageCommandPlan) => Promise<PackageCommandResult>;
17
+ export declare const runPackageCommandSync: SyncPackageCommandRunner;
18
+ export declare const runPackageCommand: PackageCommandRunner;
19
+ export declare function collectNpmExecutableCandidates(options: {
20
+ env: NodeJS.ProcessEnv;
21
+ nodeExecutable: string;
22
+ packageRoot: string;
23
+ packageName: string;
24
+ }): string[];
25
+ export declare function createNpmCommandEnvironment(options: {
26
+ env: NodeJS.ProcessEnv;
27
+ nodeExecutable: string;
28
+ }): NodeJS.ProcessEnv;
29
+ export declare function createPackageCommandPlan(options: {
30
+ executable: string;
31
+ args: readonly string[];
32
+ env: NodeJS.ProcessEnv;
33
+ timeoutMs?: number;
34
+ maxOutputBytes?: number;
35
+ }): PackageCommandPlan;
36
+ //# sourceMappingURL=command.d.ts.map