@skrr-ai/cli 0.1.87 → 0.1.88

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 (92) hide show
  1. package/dist/commands/agents/chat.js +27 -1
  2. package/dist/commands/computer/index.d.ts +19 -0
  3. package/dist/commands/computer/index.js +37 -0
  4. package/dist/commands/labels/create.d.ts +1 -0
  5. package/dist/commands/labels/create.js +2 -0
  6. package/dist/commands/labels/restore.d.ts +1 -0
  7. package/dist/commands/labels/restore.js +6 -3
  8. package/dist/commands/labels/update.d.ts +1 -0
  9. package/dist/commands/labels/update.js +2 -0
  10. package/dist/commands/machines/computer-adoption.d.ts +15 -0
  11. package/dist/commands/machines/computer-adoption.js +33 -0
  12. package/dist/commands/machines/dedicated/archive.d.ts +26 -0
  13. package/dist/commands/machines/dedicated/archive.js +113 -0
  14. package/dist/commands/machines/dedicated/audit.d.ts +25 -0
  15. package/dist/commands/machines/dedicated/audit.js +113 -0
  16. package/dist/commands/machines/dedicated/computer.d.ts +23 -0
  17. package/dist/commands/machines/dedicated/computer.js +64 -0
  18. package/dist/commands/machines/dedicated/cp.d.ts +2 -0
  19. package/dist/commands/machines/dedicated/cp.js +74 -2
  20. package/dist/commands/machines/dedicated/git-status.d.ts +15 -0
  21. package/dist/commands/machines/dedicated/git-status.js +56 -0
  22. package/dist/commands/machines/dedicated/index.js +15 -1
  23. package/dist/commands/machines/dedicated/ls.d.ts +27 -0
  24. package/dist/commands/machines/dedicated/ls.js +100 -0
  25. package/dist/commands/machines/dedicated/mkdir.d.ts +18 -0
  26. package/dist/commands/machines/dedicated/mkdir.js +57 -0
  27. package/dist/commands/machines/dedicated/mv.d.ts +24 -0
  28. package/dist/commands/machines/dedicated/mv.js +83 -0
  29. package/dist/commands/machines/dedicated/rm.d.ts +26 -0
  30. package/dist/commands/machines/dedicated/rm.js +100 -0
  31. package/dist/commands/machines/dedicated/search.d.ts +24 -0
  32. package/dist/commands/machines/dedicated/search.js +78 -0
  33. package/dist/commands/machines/dedicated/stat.d.ts +22 -0
  34. package/dist/commands/machines/dedicated/stat.js +92 -0
  35. package/dist/commands/machines/hosted/index.js +1 -1
  36. package/dist/commands/machines/hosted/list.js +1 -1
  37. package/dist/commands/machines/hosted/start.js +1 -1
  38. package/dist/commands/machines/services/declare.d.ts +24 -0
  39. package/dist/commands/machines/services/declare.js +73 -0
  40. package/dist/commands/machines/services/index.d.ts +15 -0
  41. package/dist/commands/machines/services/index.js +31 -0
  42. package/dist/commands/machines/services/ls.d.ts +17 -0
  43. package/dist/commands/machines/services/ls.js +60 -0
  44. package/dist/commands/machines/services/withdraw.d.ts +19 -0
  45. package/dist/commands/machines/services/withdraw.js +47 -0
  46. package/dist/commands/machines/share.d.ts +29 -0
  47. package/dist/commands/machines/share.js +100 -0
  48. package/dist/commands/machines/shared.d.ts +19 -0
  49. package/dist/commands/machines/shared.js +62 -0
  50. package/dist/commands/machines/shares.d.ts +16 -0
  51. package/dist/commands/machines/shares.js +69 -0
  52. package/dist/commands/machines/unshare.d.ts +19 -0
  53. package/dist/commands/machines/unshare.js +61 -0
  54. package/dist/commands/tasks/labels/create.d.ts +1 -0
  55. package/dist/commands/tasks/labels/create.js +3 -0
  56. package/dist/commands/views/create.d.ts +1 -0
  57. package/dist/commands/views/create.js +5 -0
  58. package/dist/lib/agent-home-workspace.d.ts +7 -0
  59. package/dist/lib/agent-home-workspace.js +147 -0
  60. package/dist/lib/computer-adoption.d.ts +55 -0
  61. package/dist/lib/computer-adoption.js +67 -0
  62. package/dist/lib/computer-consent.d.ts +13 -0
  63. package/dist/lib/computer-consent.js +33 -0
  64. package/dist/lib/computer-files.d.ts +239 -0
  65. package/dist/lib/computer-files.js +707 -0
  66. package/dist/lib/dedicated-copy.d.ts +45 -9
  67. package/dist/lib/dedicated-copy.js +141 -44
  68. package/dist/lib/dedicated-machines.d.ts +49 -0
  69. package/dist/lib/dedicated-machines.js +97 -6
  70. package/dist/lib/label-scope.d.ts +12 -0
  71. package/dist/lib/label-scope.js +15 -1
  72. package/dist/lib/machine-audit.d.ts +34 -0
  73. package/dist/lib/machine-audit.js +50 -0
  74. package/dist/lib/machine-grants.d.ts +70 -0
  75. package/dist/lib/machine-grants.js +47 -0
  76. package/dist/lib/machine-services.d.ts +64 -0
  77. package/dist/lib/machine-services.js +60 -0
  78. package/dist/lib/task-view-render.d.ts +2 -0
  79. package/dist/lib/task-view-render.js +1 -1
  80. package/dist/lib/views/vocabulary.d.ts +1 -1
  81. package/dist/lib/views/vocabulary.js +2 -1
  82. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/computerWire.d.ts +955 -0
  83. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/computerWire.js +1070 -0
  84. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonToolApproval.js +7 -0
  85. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/computerWire.d.ts +955 -0
  86. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/computerWire.js +1051 -0
  87. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonToolApproval.js +7 -0
  88. package/dist/node_modules/@skrr-ai/auth-core/package.json +11 -1
  89. package/dist/node_modules/@skrr-ai/data-provider/index.js +22143 -20728
  90. package/dist/node_modules/@skrr-ai/data-provider/package.json +1 -1
  91. package/oclif.manifest.json +11550 -9963
  92. package/package.json +2 -2
@@ -1,3 +1,5 @@
1
+ import type { ComputerFileEntityTag } from '@skrr-ai/data-provider';
2
+ import { statComputerFile } from './computer-files';
1
3
  import { readDedicatedRuntimeFileChunk, writeDedicatedRuntimeFileChunk, type DedicatedRuntimeScope } from './dedicated-machines';
2
4
  /**
3
5
  * The two copy loops behind `skrr machines dedicated cp`.
@@ -8,6 +10,14 @@ import { readDedicatedRuntimeFileChunk, writeDedicatedRuntimeFileChunk, type Ded
8
10
  * stages beside its destination on the machine and the final chunk renames it;
9
11
  * a download writes a temporary file beside its local destination and renames.
10
12
  *
13
+ * Every chunk presents the ONE lease generation the command resolved before it
14
+ * started (review C-01), so a machine replaced mid-copy refuses the remaining
15
+ * chunks instead of receiving them. And an upload never replaces a destination
16
+ * it was not told to (review CW-01): it stats the destination first, creates a
17
+ * new file only if the name is still free at commit (`createOnly`), and
18
+ * replaces an existing one only when asked to — and then only the exact version
19
+ * it saw (`replaceTag`).
20
+ *
11
21
  * And neither leaves its partial behind when it is cancelled (OSK-8708).
12
22
  * `listenForCopyInterrupt` turns SIGINT, SIGTERM and SIGHUP into an
13
23
  * AbortSignal the loops honour; a download also removes its temporary file from
@@ -16,12 +26,25 @@ import { readDedicatedRuntimeFileChunk, writeDedicatedRuntimeFileChunk, type Ded
16
26
  export interface DedicatedCopyIo {
17
27
  readChunk: typeof readDedicatedRuntimeFileChunk;
18
28
  writeChunk: typeof writeDedicatedRuntimeFileChunk;
29
+ /** How an upload learns what its destination is before it sends a byte. */
30
+ statPath: typeof statComputerFile;
19
31
  chunkBytes: number;
20
32
  maxBytes: number;
21
33
  /** Run `fn` if the process exits; returns the unregister. Injectable for tests. */
22
34
  onProcessExit: (fn: () => void) => () => void;
23
35
  }
24
36
  export interface DedicatedCopyOptions extends Partial<DedicatedCopyIo> {
37
+ /**
38
+ * The lease generation the command resolved at its start
39
+ * (`resolveDedicatedFileGeneration`), presented on every call of the copy.
40
+ */
41
+ generation: number;
42
+ /** Upload: replace an existing destination (the version the stat found). */
43
+ overwrite?: boolean;
44
+ /** Upload: replace exactly this version of the destination, and no other. */
45
+ ifTag?: ComputerFileEntityTag;
46
+ /** Upload: told which version is about to be replaced, before any byte is sent. */
47
+ onReplace?: (destination: string, tag: ComputerFileEntityTag) => void;
25
48
  /** Cancels the copy, which then removes its partial file before it rejects. */
26
49
  signal?: AbortSignal;
27
50
  /** Called after every chunk with the bytes copied so far and the file's size. */
@@ -35,8 +58,8 @@ export interface DedicatedCopyOptions extends Partial<DedicatedCopyIo> {
35
58
  * (OSK-8714). Before, these local refusals had no code at all.
36
59
  */
37
60
  export declare class DedicatedCopyError extends Error {
38
- readonly code: 'DEDICATED_RUNTIME_FILE_TOO_LARGE' | 'DEDICATED_RUNTIME_FILE_CHANGED' | 'DEDICATED_RUNTIME_FILE_NOT_FOUND' | 'DEDICATED_RUNTIME_PATH_NOT_A_FILE' | 'DEDICATED_RUNTIME_FILE_TRANSFER_FAILED';
39
- constructor(code: 'DEDICATED_RUNTIME_FILE_TOO_LARGE' | 'DEDICATED_RUNTIME_FILE_CHANGED' | 'DEDICATED_RUNTIME_FILE_NOT_FOUND' | 'DEDICATED_RUNTIME_PATH_NOT_A_FILE' | 'DEDICATED_RUNTIME_FILE_TRANSFER_FAILED', message: string);
61
+ readonly code: 'DEDICATED_RUNTIME_FILE_TOO_LARGE' | 'DEDICATED_RUNTIME_FILE_CHANGED' | 'DEDICATED_RUNTIME_FILE_NOT_FOUND' | 'DEDICATED_RUNTIME_PATH_NOT_A_FILE' | 'DEDICATED_RUNTIME_PATH_IS_DIRECTORY' | 'DEDICATED_RUNTIME_FILE_EXISTS' | 'DEDICATED_RUNTIME_FILE_TRANSFER_FAILED';
62
+ constructor(code: 'DEDICATED_RUNTIME_FILE_TOO_LARGE' | 'DEDICATED_RUNTIME_FILE_CHANGED' | 'DEDICATED_RUNTIME_FILE_NOT_FOUND' | 'DEDICATED_RUNTIME_PATH_NOT_A_FILE' | 'DEDICATED_RUNTIME_PATH_IS_DIRECTORY' | 'DEDICATED_RUNTIME_FILE_EXISTS' | 'DEDICATED_RUNTIME_FILE_TRANSFER_FAILED', message: string);
40
63
  }
41
64
  declare const SIGNAL_NUMBERS: {
42
65
  readonly SIGHUP: 1;
@@ -76,6 +99,14 @@ export declare function listenForCopyInterrupt({ proc, notice, }?: {
76
99
  export interface DedicatedCopyResult {
77
100
  bytes: number;
78
101
  destination: string;
102
+ /** Upload: true when the copy created the destination, false when it replaced one. */
103
+ created?: boolean;
104
+ /** Upload: the tag of the version that was replaced, as `stat` prints it. */
105
+ replacedTag?: string;
106
+ /** Upload: the committed file's tag, as `stat` prints it. */
107
+ tag?: string;
108
+ /** Upload: the machine answered from the receipt of an earlier, committed attempt. */
109
+ alreadyCommitted?: boolean;
79
110
  }
80
111
  /**
81
112
  * One line of progress for a terminal, redrawn in place: a 60 MiB copy takes
@@ -100,13 +131,18 @@ export declare function intoRemoteDirectory(directory: string, name: string): st
100
131
  * Upload one file. A destination that is a directory — spelled `dir/`, or an
101
132
  * existing directory named without the slash — receives the file under its own
102
133
  * name, as `cp` and `scp` do and as a download into a local directory already
103
- * did. Before, `cp tiny.txt <lease>:repos/scout-a/` sent the whole file and then
104
- * failed the machine's final rename with EISDIR (OSK-8713).
134
+ * did (OSK-8713).
135
+ *
136
+ * The destination is stat'ed before any byte is sent, and what the copy may do
137
+ * to it is decided then and enforced at commit by the machine (review CW-01):
105
138
  *
106
- * `dir/` is resolved here, without asking. A directory named without the slash
107
- * is found out by the machine, which refuses it on the first chunk before
108
- * anything is staged; the copy then goes into it, once.
139
+ * - nothing there → the copy creates it, and commits only if the name is still
140
+ * free (`createOnly`); something that appears meanwhile is never replaced;
141
+ * - a file there → refused before sending anything, unless `overwrite` (replace
142
+ * the version the stat found) or `ifTag` (replace exactly that version) was
143
+ * given; either way the commit replaces only that version (`replaceTag`), and
144
+ * a file changed during the copy is refused `file_changed`, nothing replaced.
109
145
  */
110
- export declare function uploadToDedicatedRuntime(localPath: string, leaseId: string, remotePath: string, scope?: DedicatedRuntimeScope, io?: DedicatedCopyOptions): Promise<DedicatedCopyResult>;
111
- export declare function downloadFromDedicatedRuntime(leaseId: string, remotePath: string, localPath: string, scope?: DedicatedRuntimeScope, io?: DedicatedCopyOptions): Promise<DedicatedCopyResult>;
146
+ export declare function uploadToDedicatedRuntime(localPath: string, leaseId: string, remotePath: string, scope: DedicatedRuntimeScope, io: DedicatedCopyOptions): Promise<DedicatedCopyResult>;
147
+ export declare function downloadFromDedicatedRuntime(leaseId: string, remotePath: string, localPath: string, scope: DedicatedRuntimeScope, io: DedicatedCopyOptions): Promise<DedicatedCopyResult>;
112
148
  export {};
@@ -12,6 +12,7 @@ const node_crypto_1 = require("node:crypto");
12
12
  const node_fs_1 = require("node:fs");
13
13
  const promises_1 = require("node:fs/promises");
14
14
  const node_path_1 = require("node:path");
15
+ const computer_files_1 = require("./computer-files");
15
16
  const dedicated_file_retry_1 = require("./dedicated-file-retry");
16
17
  const dedicated_machines_1 = require("./dedicated-machines");
17
18
  /**
@@ -34,6 +35,7 @@ const changedWhileCopying = (path, where) => new DedicatedCopyError('DEDICATED_R
34
35
  const defaultIo = {
35
36
  readChunk: dedicated_machines_1.readDedicatedRuntimeFileChunk,
36
37
  writeChunk: dedicated_machines_1.writeDedicatedRuntimeFileChunk,
38
+ statPath: computer_files_1.statComputerFile,
37
39
  chunkBytes: dedicated_machines_1.DEDICATED_FILE_CHUNK_BYTES,
38
40
  maxBytes: dedicated_machines_1.DEDICATED_FILE_MAX_BYTES,
39
41
  onProcessExit: (fn) => {
@@ -138,51 +140,107 @@ function intoRemoteDirectory(directory, name) {
138
140
  const parent = directory.replace(/\/\.?$/, '').replace(/^\.$/, '');
139
141
  return parent ? `${parent}/${name}` : name;
140
142
  }
141
- const PATH_IS_DIRECTORY = 'DEDICATED_RUNTIME_PATH_IS_DIRECTORY';
142
- /** The machine refused the destination as a directory before anything was staged. */
143
- class DestinationIsDirectory extends Error {
144
- refusal;
145
- constructor(refusal) {
146
- super('The destination on the machine is a directory');
147
- this.refusal = refusal;
143
+ /**
144
+ * Stat one machine path for an upload. A path that does not exist is `absent`
145
+ * — the copy creates it, and any missing parent with it. Every other refusal
146
+ * (withheld, stale generation, the machine unreachable) is the copy's refusal.
147
+ */
148
+ async function statDestination(statPath, leaseId, remotePath, generation, scope, signal) {
149
+ let result;
150
+ try {
151
+ result = await (0, dedicated_file_retry_1.withFileAdmissionRetry)(() => statPath(leaseId, { path: remotePath, generation }, scope), signal);
152
+ }
153
+ catch (err) {
154
+ const refusal = (0, computer_files_1.computerFileRefusalInfo)(err);
155
+ // `details.path` (or a path-shaped message) marks a missing PATH; without
156
+ // it the machine itself was not found, which is not a destination to create.
157
+ if (refusal?.code === 'not_found' &&
158
+ (refusal.details?.path !== undefined || /path|file|folder|directory/i.test(refusal.message))) {
159
+ return { kind: 'absent' };
160
+ }
161
+ throw err;
162
+ }
163
+ const type = result.entry.type;
164
+ if (type === 'directory')
165
+ return { kind: 'directory' };
166
+ if (type === 'file' && result.tag) {
167
+ return {
168
+ kind: 'file',
169
+ tag: { inode: result.tag.inode, size: result.tag.size, mtimeNs: result.tag.mtimeNs },
170
+ };
148
171
  }
172
+ return { kind: 'other', type };
149
173
  }
174
+ const sameTag = (a, b) => a.inode === b.inode && a.size === b.size && a.mtimeNs === b.mtimeNs;
150
175
  /**
151
176
  * Upload one file. A destination that is a directory — spelled `dir/`, or an
152
177
  * existing directory named without the slash — receives the file under its own
153
178
  * name, as `cp` and `scp` do and as a download into a local directory already
154
- * did. Before, `cp tiny.txt <lease>:repos/scout-a/` sent the whole file and then
155
- * failed the machine's final rename with EISDIR (OSK-8713).
179
+ * did (OSK-8713).
156
180
  *
157
- * `dir/` is resolved here, without asking. A directory named without the slash
158
- * is found out by the machine, which refuses it on the first chunk before
159
- * anything is staged; the copy then goes into it, once.
181
+ * The destination is stat'ed before any byte is sent, and what the copy may do
182
+ * to it is decided then and enforced at commit by the machine (review CW-01):
183
+ *
184
+ * - nothing there → the copy creates it, and commits only if the name is still
185
+ * free (`createOnly`); something that appears meanwhile is never replaced;
186
+ * - a file there → refused before sending anything, unless `overwrite` (replace
187
+ * the version the stat found) or `ifTag` (replace exactly that version) was
188
+ * given; either way the commit replaces only that version (`replaceTag`), and
189
+ * a file changed during the copy is refused `file_changed`, nothing replaced.
160
190
  */
161
- async function uploadToDedicatedRuntime(localPath, leaseId, remotePath, scope = {}, io = {}) {
162
- const intoDirectory = (directory) => intoRemoteDirectory(directory, (0, node_path_1.basename)(localPath));
163
- const attempt = async (target) => {
164
- try {
165
- return await uploadFile(localPath, leaseId, target, scope, io);
191
+ async function uploadToDedicatedRuntime(localPath, leaseId, remotePath, scope, io) {
192
+ const { statPath, signal, generation } = { ...defaultIo, ...io };
193
+ throwIfCancelled(signal);
194
+ const info = await localSourceInfo(localPath, { ...defaultIo, ...io }.maxBytes);
195
+ const shown = (target) => `${leaseId}:${target}`;
196
+ let target = namesRemoteDirectory(remotePath)
197
+ ? intoRemoteDirectory(remotePath, (0, node_path_1.basename)(localPath))
198
+ : remotePath;
199
+ let destination = await statDestination(statPath, leaseId, target, generation, scope, signal);
200
+ if (destination.kind === 'directory' && target === remotePath) {
201
+ // An existing directory named without the slash: into it, once.
202
+ target = intoRemoteDirectory(remotePath, (0, node_path_1.basename)(localPath));
203
+ destination = await statDestination(statPath, leaseId, target, generation, scope, signal);
204
+ }
205
+ if (destination.kind === 'directory') {
206
+ throw new DedicatedCopyError('DEDICATED_RUNTIME_PATH_IS_DIRECTORY', `${shown(target)} is a directory on the machine; nothing was sent.`);
207
+ }
208
+ if (destination.kind === 'other') {
209
+ throw new DedicatedCopyError('DEDICATED_RUNTIME_PATH_NOT_A_FILE', `${shown(target)} is a ${destination.type} on the machine, not a regular file; cp does not replace it. Nothing was sent.`);
210
+ }
211
+ let condition;
212
+ let replacedTag;
213
+ if (destination.kind === 'absent') {
214
+ if (io.ifTag) {
215
+ throw new DedicatedCopyError('DEDICATED_RUNTIME_FILE_CHANGED', `${shown(target)} is no longer there, so the version --if-tag ${(0, computer_files_1.computerFileTagString)(io.ifTag)} names cannot be replaced. Nothing was sent; copy again without --if-tag to create it.`);
166
216
  }
167
- catch (err) {
168
- throw err instanceof DestinationIsDirectory ? err.refusal : err;
217
+ condition = { createOnly: true };
218
+ }
219
+ else if (io.ifTag) {
220
+ if (!sameTag(io.ifTag, destination.tag)) {
221
+ throw new DedicatedCopyError('DEDICATED_RUNTIME_FILE_CHANGED', `${shown(target)} changed since --if-tag ${(0, computer_files_1.computerFileTagString)(io.ifTag)} was read; its current tag is ${(0, computer_files_1.computerFileTagString)(destination.tag)}. Nothing was sent or replaced.`);
169
222
  }
170
- };
171
- if (namesRemoteDirectory(remotePath)) {
172
- return attempt(intoDirectory(remotePath));
223
+ replacedTag = destination.tag;
224
+ condition = { replaceTag: destination.tag };
173
225
  }
174
- try {
175
- return await uploadFile(localPath, leaseId, remotePath, scope, io);
226
+ else if (io.overwrite) {
227
+ replacedTag = destination.tag;
228
+ condition = { replaceTag: destination.tag };
176
229
  }
177
- catch (err) {
178
- if (err instanceof DestinationIsDirectory)
179
- return attempt(intoDirectory(remotePath));
180
- throw err;
230
+ else {
231
+ throw new DedicatedCopyError('DEDICATED_RUNTIME_FILE_EXISTS', `${shown(target)} already exists (tag ${(0, computer_files_1.computerFileTagString)(destination.tag)}). Nothing was sent. Pass --overwrite to replace it, or --if-tag ${(0, computer_files_1.computerFileTagString)(destination.tag)} to replace exactly this version.`);
181
232
  }
233
+ if (replacedTag)
234
+ io.onReplace?.(shown(target), replacedTag);
235
+ const result = await uploadFile(localPath, info, leaseId, target, scope, io, condition);
236
+ return {
237
+ ...result,
238
+ created: replacedTag === undefined,
239
+ ...(replacedTag ? { replacedTag: (0, computer_files_1.computerFileTagString)(replacedTag) } : {}),
240
+ };
182
241
  }
183
- async function uploadFile(localPath, leaseId, remotePath, scope, io) {
184
- const { writeChunk, chunkBytes, maxBytes, signal, onProgress } = { ...defaultIo, ...io };
185
- throwIfCancelled(signal);
242
+ /** The local source of an upload: a regular file within the size limit. */
243
+ async function localSourceInfo(localPath, maxBytes) {
186
244
  const info = await (0, promises_1.stat)(localPath).catch((err) => {
187
245
  // The raw `ENOENT: no such file or directory, stat './x'` said the same
188
246
  // thing in the platform's words rather than ours.
@@ -197,6 +255,34 @@ async function uploadFile(localPath, leaseId, remotePath, scope, io) {
197
255
  if (info.size > maxBytes) {
198
256
  throw new DedicatedCopyError('DEDICATED_RUNTIME_FILE_TOO_LARGE', `${localPath} is ${mib(info.size)}; copy is limited to ${mib(maxBytes)}. Push it through Git or object storage instead.`);
199
257
  }
258
+ return info;
259
+ }
260
+ /**
261
+ * The commit's own refusal of the condition the upload carried, in the copy's
262
+ * words: the destination changed (or appeared) DURING the copy and nothing was
263
+ * replaced. Anything else passes through unchanged.
264
+ */
265
+ function commitConditionRefusal(err, shownTarget) {
266
+ const refusal = (0, computer_files_1.computerFileRefusalInfo)(err);
267
+ if (!refusal)
268
+ return err;
269
+ if (refusal.code === 'file_changed') {
270
+ let now = '';
271
+ if (refusal.currentTag !== undefined) {
272
+ now = ` Its current tag is ${(0, computer_files_1.computerFileTagString)(refusal.currentTag)}.`;
273
+ }
274
+ else if (refusal.body.currentTag === null) {
275
+ now = ' It is no longer there.';
276
+ }
277
+ return new DedicatedCopyError('DEDICATED_RUNTIME_FILE_CHANGED', `${shownTarget} changed on the machine during the copy, so nothing was replaced.${now} Look at it with \`stat\`, then copy again.`);
278
+ }
279
+ if (refusal.code === 'invalid_request' && refusal.details?.reason === 'exists') {
280
+ return new DedicatedCopyError('DEDICATED_RUNTIME_FILE_EXISTS', `${shownTarget} appeared on the machine during the copy, so nothing was replaced. Copy again with --overwrite if you mean to replace it.`);
281
+ }
282
+ return err;
283
+ }
284
+ async function uploadFile(localPath, info, leaseId, remotePath, scope, io, condition) {
285
+ const { writeChunk, chunkBytes, signal, onProgress, generation } = { ...defaultIo, ...io };
200
286
  const transferId = `cp-${Date.now().toString(36)}-${(0, node_crypto_1.randomBytes)(6).toString('hex')}`;
201
287
  const handle = await (0, promises_1.open)(localPath, 'r');
202
288
  let offset = 0;
@@ -217,36 +303,45 @@ async function uploadFile(localPath, leaseId, remotePath, scope, io) {
217
303
  path: remotePath,
218
304
  transferId,
219
305
  offset,
306
+ generation,
220
307
  dataBase64: buffer.subarray(0, bytesRead).toString('base64'),
308
+ // On every chunk, so a machine whose image cannot honour the
309
+ // condition refuses the FIRST chunk, before anything is staged.
310
+ ...condition,
221
311
  // A file that GREW is caught here: the machine refuses to commit
222
312
  // when what it staged is not the size declared at the start.
223
- ...(final ? { final: true, totalSize: info.size, executable: (info.mode & 0o111) !== 0 } : {}),
313
+ ...(final
314
+ ? { final: true, totalSize: info.size, executable: (info.mode & 0o111) !== 0 }
315
+ : {}),
224
316
  }, scope), signal);
225
317
  offset += bytesRead;
226
318
  onProgress?.(offset, info.size);
227
- if (final) {
228
- return { bytes: offset, destination: `${leaseId}:${result.path || remotePath}` };
319
+ // A receipt (`alreadyCommitted`) is the same success: this transfer
320
+ // committed earlier and only its acknowledgement was lost.
321
+ if (final || result.alreadyCommitted === true) {
322
+ return {
323
+ bytes: info.size,
324
+ destination: `${leaseId}:${result.path || remotePath}`,
325
+ ...(result.tag ? { tag: (0, computer_files_1.computerFileTagString)(result.tag) } : {}),
326
+ ...(result.alreadyCommitted === true ? { alreadyCommitted: true } : {}),
327
+ };
229
328
  }
230
329
  }
231
330
  }
232
331
  catch (err) {
233
- if (offset === 0 && err?.code === PATH_IS_DIRECTORY) {
234
- // Refused before the first chunk was staged: there is nothing to abort.
235
- throw new DestinationIsDirectory(err);
236
- }
237
332
  // Best effort: remove the staged partial so it does not sit beside the
238
333
  // destination. The destination itself was never touched. An abort that
239
334
  // never arrives — the process killed, the network gone — is covered by the
240
335
  // machine, which removes a stale staging file on the next upload into the
241
336
  // same directory (daemon/src/tools/file-transfer.ts).
242
- await writeChunk(leaseId, { path: remotePath, transferId, offset: 0, abort: true }, scope).catch(() => undefined);
243
- throw err;
337
+ await writeChunk(leaseId, { path: remotePath, transferId, offset: 0, abort: true, generation }, scope).catch(() => undefined);
338
+ throw commitConditionRefusal(err, `${leaseId}:${remotePath}`);
244
339
  }
245
340
  finally {
246
341
  await handle.close();
247
342
  }
248
343
  }
249
- async function downloadFromDedicatedRuntime(leaseId, remotePath, localPath, scope = {}, io = {}) {
344
+ async function downloadFromDedicatedRuntime(leaseId, remotePath, localPath, scope, io) {
250
345
  const { readChunk, chunkBytes, onProcessExit, signal, onProgress } = { ...defaultIo, ...io };
251
346
  throwIfCancelled(signal);
252
347
  let target = localPath;
@@ -282,14 +377,16 @@ async function downloadFromDedicatedRuntime(leaseId, remotePath, localPath, scop
282
377
  throwIfCancelled(signal);
283
378
  // Nothing on the machine needs undoing, so an in-flight read is abandoned
284
379
  // at once rather than waited out.
285
- const chunk = await (0, dedicated_file_retry_1.withFileAdmissionRetry)(() => readChunk(leaseId, { path: remotePath, offset, length: chunkBytes }, scope, signal), signal);
380
+ const chunk = await (0, dedicated_file_retry_1.withFileAdmissionRetry)(() => readChunk(leaseId, { path: remotePath, offset, length: chunkBytes, generation: io.generation }, scope, signal), signal);
286
381
  if (size === -1) {
287
382
  size = chunk.size;
288
383
  mtimeMs = chunk.mtimeMs;
289
384
  executable = chunk.executable;
290
385
  }
291
- else if (chunk.size !== size || chunk.mtimeMs !== mtimeMs ||
292
- (typeof chunk.executable === 'boolean' && typeof executable === 'boolean' &&
386
+ else if (chunk.size !== size ||
387
+ chunk.mtimeMs !== mtimeMs ||
388
+ (typeof chunk.executable === 'boolean' &&
389
+ typeof executable === 'boolean' &&
293
390
  chunk.executable !== executable)) {
294
391
  // Chunks from two versions of a file splice into one that never existed.
295
392
  throw changedWhileCopying(remotePath, 'on the machine');
@@ -285,6 +285,7 @@ export declare function dedicatedRuntimeCreateInput(input: {
285
285
  * billable request is sent.
286
286
  */
287
287
  export declare function isDedicatedRepositoryRef(value: string): boolean;
288
+ export declare function withDedicatedScope(path: string, scope: DedicatedRuntimeScope, query?: Record<string, string>): string;
288
289
  export declare function createDedicatedRuntime(input: CreateDedicatedRuntimeInput): Promise<DedicatedRuntimeResponse>;
289
290
  /** One offered tier as the operator price book checks it. */
290
291
  export interface DedicatedPriceBookPresetCheck {
@@ -478,21 +479,69 @@ export interface DedicatedFileWriteResult {
478
479
  staged: number;
479
480
  committed: boolean;
480
481
  aborted?: boolean;
482
+ /** On commit: the committed file's entity tag. */
483
+ tag?: {
484
+ inode: string;
485
+ size: number;
486
+ mtimeNs: string;
487
+ };
488
+ /**
489
+ * This transfer had ALREADY committed (review CW-02): the answer is its
490
+ * receipt, and nothing was written again — a retried final chunk whose first
491
+ * acknowledgement was lost. A success, never a reason to upload again.
492
+ */
493
+ alreadyCommitted?: boolean;
481
494
  }
495
+ /**
496
+ * `lease:<leaseId>` is the machine id the computer file routes address a
497
+ * Dedicated Runtime by (`/api/machines/:machineId/files/*`, authorised by
498
+ * ComputerAccess), the same id the machine inventory uses.
499
+ */
500
+ export declare const computerMachineId: (leaseId: string) => string;
501
+ /**
502
+ * The computer's page for a Dedicated Runtime — `/machines/<machineId>/computer`
503
+ * (the route `paneRouteConfig` serves the computer shell at, design §11).
504
+ * Composed on the `machine` web route rather than concatenated against baseURL,
505
+ * so the `api.`-subdomain → apex origin mapping stays the one in web-url.ts.
506
+ */
507
+ export declare function dedicatedComputerUrl(baseURL: string, leaseId: string): string | null;
508
+ /**
509
+ * One chunk of a download. `generation` is the lease generation the `cp`
510
+ * command resolved at its start, presented on every chunk (review C-01): a
511
+ * machine replaced mid-copy refuses the rest `stale_generation`.
512
+ */
482
513
  export declare function readDedicatedRuntimeFileChunk(leaseId: string, input: {
483
514
  path: string;
484
515
  offset: number;
485
516
  length: number;
517
+ generation: number;
486
518
  }, scope?: DedicatedRuntimeScope, signal?: AbortSignal): Promise<DedicatedFileChunk>;
519
+ /**
520
+ * One call of a chunked upload. `generation` fences every chunk to the machine
521
+ * the command resolved (review C-01). How the commit treats a destination is
522
+ * the caller's explicit choice (review CW-01), never an unconditional replace:
523
+ * `createOnly` commits only if nothing has the name (else 409
524
+ * `invalid_request`/`exists`); `replaceTag` commits only over the file with
525
+ * exactly that tag (else 409 `file_changed` with `currentTag`, null when it is
526
+ * gone). Never both. Each needs `computer_files_fenced_v1` on the machine.
527
+ */
487
528
  export declare function writeDedicatedRuntimeFileChunk(leaseId: string, input: {
488
529
  path: string;
489
530
  transferId: string;
490
531
  offset: number;
532
+ generation: number;
491
533
  dataBase64?: string;
492
534
  final?: boolean;
493
535
  abort?: boolean;
536
+ query?: boolean;
494
537
  totalSize?: number;
495
538
  executable?: boolean;
539
+ createOnly?: boolean;
540
+ replaceTag?: {
541
+ inode: string;
542
+ size: number;
543
+ mtimeNs: string;
544
+ };
496
545
  }, scope?: DedicatedRuntimeScope): Promise<DedicatedFileWriteResult>;
497
546
  export type DedicatedCopyPlan = {
498
547
  direction: 'upload';
@@ -1,12 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEDICATED_FILE_MAX_BYTES = exports.DEDICATED_FILE_CHUNK_BYTES = exports.DEDICATED_EXEC_DEFAULT_TIMEOUT_MS = exports.DEDICATED_EXEC_MAX_TIMEOUT_MS = exports.DEDICATED_MACHINE_PRODUCT = exports.DEDICATED_LEASE_ACTIONS = void 0;
3
+ exports.computerMachineId = exports.DEDICATED_FILE_MAX_BYTES = exports.DEDICATED_FILE_CHUNK_BYTES = exports.DEDICATED_EXEC_DEFAULT_TIMEOUT_MS = exports.DEDICATED_EXEC_MAX_TIMEOUT_MS = exports.DEDICATED_MACHINE_PRODUCT = exports.DEDICATED_LEASE_ACTIONS = void 0;
4
4
  exports.describeDedicatedCheckpoint = describeDedicatedCheckpoint;
5
5
  exports.compareDaemonVersions = compareDaemonVersions;
6
6
  exports.describeDedicatedDaemonRelease = describeDedicatedDaemonRelease;
7
7
  exports.describeDedicatedImageStanding = describeDedicatedImageStanding;
8
8
  exports.dedicatedRuntimeCreateInput = dedicatedRuntimeCreateInput;
9
9
  exports.isDedicatedRepositoryRef = isDedicatedRepositoryRef;
10
+ exports.withDedicatedScope = withDedicatedScope;
10
11
  exports.createDedicatedRuntime = createDedicatedRuntime;
11
12
  exports.getDedicatedOperatorPriceBook = getDedicatedOperatorPriceBook;
12
13
  exports.dedicatedOperatorPriceBookLines = dedicatedOperatorPriceBookLines;
@@ -22,6 +23,7 @@ exports.execDedicatedRuntime = execDedicatedRuntime;
22
23
  exports.getDedicatedRuntimeSignInStatus = getDedicatedRuntimeSignInStatus;
23
24
  exports.startDedicatedRuntimeExecution = startDedicatedRuntimeExecution;
24
25
  exports.observeDedicatedRuntimeExecution = observeDedicatedRuntimeExecution;
26
+ exports.dedicatedComputerUrl = dedicatedComputerUrl;
25
27
  exports.readDedicatedRuntimeFileChunk = readDedicatedRuntimeFileChunk;
26
28
  exports.writeDedicatedRuntimeFileChunk = writeDedicatedRuntimeFileChunk;
27
29
  exports.parseDedicatedCopyEndpoints = parseDedicatedCopyEndpoints;
@@ -53,6 +55,7 @@ const auth_core_1 = require("@skrr-ai/auth-core");
53
55
  const data_provider_1 = require("@skrr-ai/data-provider");
54
56
  const api_fetch_1 = require("./api-fetch");
55
57
  const machine_spend_cap_1 = require("./machine-spend-cap");
58
+ const web_url_1 = require("./web-url");
56
59
  const edge_error_page_1 = require("./edge-error-page");
57
60
  const harnesses_1 = require("./harnesses");
58
61
  exports.DEDICATED_LEASE_ACTIONS = [
@@ -445,11 +448,89 @@ async function observeDedicatedRuntimeExecution(leaseId, executionId, options) {
445
448
  /** Mirrors the daemon's bounds (daemon/src/tools/file-transfer.ts). */
446
449
  exports.DEDICATED_FILE_CHUNK_BYTES = 512 * 1024;
447
450
  exports.DEDICATED_FILE_MAX_BYTES = 64 * 1024 * 1024;
451
+ /**
452
+ * `lease:<leaseId>` is the machine id the computer file routes address a
453
+ * Dedicated Runtime by (`/api/machines/:machineId/files/*`, authorised by
454
+ * ComputerAccess), the same id the machine inventory uses.
455
+ */
456
+ const computerMachineId = (leaseId) => `lease:${leaseId}`;
457
+ exports.computerMachineId = computerMachineId;
458
+ /**
459
+ * The computer's page for a Dedicated Runtime — `/machines/<machineId>/computer`
460
+ * (the route `paneRouteConfig` serves the computer shell at, design §11).
461
+ * Composed on the `machine` web route rather than concatenated against baseURL,
462
+ * so the `api.`-subdomain → apex origin mapping stays the one in web-url.ts.
463
+ */
464
+ function dedicatedComputerUrl(baseURL, leaseId) {
465
+ const page = (0, web_url_1.webUrl)(baseURL, { kind: 'machine', machineId: (0, exports.computerMachineId)(leaseId) });
466
+ return page ? `${page}/computer` : null;
467
+ }
468
+ const LEGACY_FILE_CODES = {
469
+ file_changed: 'DEDICATED_RUNTIME_FILE_CHANGED',
470
+ too_large: 'DEDICATED_RUNTIME_FILE_TOO_LARGE',
471
+ not_found: 'DEDICATED_RUNTIME_FILE_NOT_FOUND',
472
+ };
473
+ /**
474
+ * The copy loop branches on the `DEDICATED_RUNTIME_*` code a refused chunk
475
+ * carried (a destination that is a directory, a file that changed). The file
476
+ * routes answer in the computer's wire vocabulary and keep the specific cause in
477
+ * `details.reason`, so a refusal is re-coded here, at the one place that knows
478
+ * about the copy loop, rather than the loop learning two vocabularies.
479
+ */
480
+ function withCopyLoopCode(err) {
481
+ if (!(err instanceof api_fetch_1.ApiFetchError))
482
+ return err;
483
+ let reason;
484
+ try {
485
+ reason = JSON.parse(err.body ?? '{}').details?.reason;
486
+ }
487
+ catch {
488
+ reason = undefined;
489
+ }
490
+ const code = typeof reason === 'string' && reason.startsWith('DEDICATED_RUNTIME_')
491
+ ? reason
492
+ : err.code
493
+ ? LEGACY_FILE_CODES[err.code]
494
+ : undefined;
495
+ if (code)
496
+ err.code = code;
497
+ return err;
498
+ }
499
+ /**
500
+ * One chunk of a download. `generation` is the lease generation the `cp`
501
+ * command resolved at its start, presented on every chunk (review C-01): a
502
+ * machine replaced mid-copy refuses the rest `stale_generation`.
503
+ */
448
504
  async function readDedicatedRuntimeFileChunk(leaseId, input, scope = {}, signal) {
449
- return (0, api_fetch_1.apiFetch)(withDedicatedScope(`/api/machines/dedicated/${encodeURIComponent(leaseId)}/files/read`, scope), { method: 'POST', body: input, ...(signal ? { signal } : {}) });
505
+ const wire = await (0, api_fetch_1.apiFetch)(withDedicatedScope(`/api/machines/${encodeURIComponent((0, exports.computerMachineId)(leaseId))}/files/read`, scope), { method: 'POST', body: input, ...(signal ? { signal } : {}) }).catch((err) => {
506
+ throw withCopyLoopCode(err);
507
+ });
508
+ return {
509
+ path: wire.path,
510
+ size: wire.size,
511
+ // The tag's nanosecond mtime, as the whole-millisecond value the copy loop
512
+ // compares between chunks to notice a file that changed under it.
513
+ mtimeMs: Number(BigInt(wire.tag.mtimeNs) / 1000000n),
514
+ ...(typeof wire.executable === 'boolean' ? { executable: wire.executable } : {}),
515
+ offset: wire.offset,
516
+ bytesRead: Buffer.from(wire.data, 'base64').byteLength,
517
+ dataBase64: wire.data,
518
+ eof: wire.eof,
519
+ };
450
520
  }
521
+ /**
522
+ * One call of a chunked upload. `generation` fences every chunk to the machine
523
+ * the command resolved (review C-01). How the commit treats a destination is
524
+ * the caller's explicit choice (review CW-01), never an unconditional replace:
525
+ * `createOnly` commits only if nothing has the name (else 409
526
+ * `invalid_request`/`exists`); `replaceTag` commits only over the file with
527
+ * exactly that tag (else 409 `file_changed` with `currentTag`, null when it is
528
+ * gone). Never both. Each needs `computer_files_fenced_v1` on the machine.
529
+ */
451
530
  async function writeDedicatedRuntimeFileChunk(leaseId, input, scope = {}) {
452
- return (0, api_fetch_1.apiFetch)(withDedicatedScope(`/api/machines/dedicated/${encodeURIComponent(leaseId)}/files/write`, scope), { method: 'POST', body: input });
531
+ return (0, api_fetch_1.apiFetch)(withDedicatedScope(`/api/machines/${encodeURIComponent((0, exports.computerMachineId)(leaseId))}/files/write`, scope), { method: 'POST', body: input }).catch((err) => {
532
+ throw withCopyLoopCode(err);
533
+ });
453
534
  }
454
535
  const REMOTE_ENDPOINT = /^(rtl_[A-Za-z0-9_-]{1,80}):(.+)$/;
455
536
  /**
@@ -570,7 +651,8 @@ function dedicatedSpendSummary(lease) {
570
651
  if (!billing)
571
652
  return '-';
572
653
  const cap = billing.monthlySpendLimitCents !== undefined
573
- ? billing.monthlySpendLimitCents : billing.spendingLimitCents;
654
+ ? billing.monthlySpendLimitCents
655
+ : billing.spendingLimitCents;
574
656
  if (billing.spendStatusUnavailable) {
575
657
  return `spend unavailable right now · ${cap === null ? 'no monthly cap' : `cap ${formatUsdCents(cap)}`}`;
576
658
  }
@@ -647,6 +729,12 @@ function describeDedicatedLease(lease, harnesses = [], opts = {}) {
647
729
  row('state', lease.stateReason
648
730
  ? `${dedicatedLeaseState(lease)} (${lease.stateReason})`
649
731
  : dedicatedLeaseState(lease));
732
+ // A reason the platform wrote for its own stop gets a sentence, not just a code: the
733
+ // code names an internal rule and leaves the owner asking where their machine went
734
+ // (OSK-13732). A reason with no copy keeps printing as the code above.
735
+ const stateReasonCopy = (0, data_provider_1.describeMachineStateReason)(lease.stateReason);
736
+ if (stateReasonCopy)
737
+ row('why', stateReasonCopy);
650
738
  const resources = lease.resources || {};
651
739
  const size = [
652
740
  resources.sizePreset,
@@ -1119,10 +1207,13 @@ function dedicatedCreateSummary(catalog, choices) {
1119
1207
  : 'server default';
1120
1208
  const retains = retentionClass !== undefined && retentionClass !== 'none';
1121
1209
  const capCents = choices.spendingLimitCents !== undefined
1122
- ? choices.spendingLimitCents : catalog?.defaultMonthlySpendingLimitCents;
1210
+ ? choices.spendingLimitCents
1211
+ : catalog?.defaultMonthlySpendingLimitCents;
1123
1212
  const cap = capCents !== undefined && capCents !== null
1124
1213
  ? `${formatUsdCents(capCents)} per month${choices.spendingLimitCents === undefined ? ' (catalog default)' : ''}`
1125
- : capCents === null ? 'no cap — compute and retained storage still bill' : 'server default';
1214
+ : capCents === null
1215
+ ? 'no cap — compute and retained storage still bill'
1216
+ : 'server default';
1126
1217
  return [
1127
1218
  `Create Dedicated Runtime "${choices.name}" — it bills while it runs:`,
1128
1219
  ` size ${choices.sizePreset} — ${spec}`,
@@ -39,6 +39,18 @@ export declare const labelScopeFlags: {
39
39
  scope: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
40
40
  'scope-id': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
41
41
  };
42
+ /**
43
+ * `--allow-duplicate-name`, shared by every command that can put a live name
44
+ * into a scope (create, rename, restore).
45
+ *
46
+ * The SERVER owns the rule (`LabelService.assertNameFreeInOtherScope`): a name
47
+ * another label you can use already carries is refused with
48
+ * `409 duplicate_name_other_scope`, and the refusal names that label. The CLI
49
+ * only relays the override, so every surface — web, MCP, CLI — gets one answer.
50
+ */
51
+ export declare const allowDuplicateNameFlags: {
52
+ 'allow-duplicate-name': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
53
+ };
42
54
  export interface ResolvedLabelScope {
43
55
  scope: LabelScope;
44
56
  scopeId?: string;