@turns/dev 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -6
- package/dist/agent-anchor.js +7 -2
- package/dist/file-browsing.js +71 -90
- package/dist/harness-registry.js +11 -0
- package/dist/index.js +50 -14
- package/dist/local-session-sources.js +33 -1
- package/dist/path-resolution.js +2 -1
- package/dist/queue-worker.js +10 -3
- package/dist/upload-sessions.js +41 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,8 +14,9 @@ npm install --global @turns/dev
|
|
|
14
14
|
## Commands
|
|
15
15
|
|
|
16
16
|
```text
|
|
17
|
-
turns
|
|
18
|
-
turns upload [directory] watch one directory
|
|
17
|
+
turns watch and stream sessions for the current directory
|
|
18
|
+
turns upload [directory] watch and stream sessions for one directory
|
|
19
|
+
turns --once [directory] upload that directory's sessions once and exit
|
|
19
20
|
turns install install the all-directory user daemon
|
|
20
21
|
turns status report daemon state
|
|
21
22
|
turns uninstall remove the daemon
|
|
@@ -23,6 +24,20 @@ turns login replace saved credentials through the browser
|
|
|
23
24
|
turns logout remove saved credentials
|
|
24
25
|
```
|
|
25
26
|
|
|
27
|
+
`turns` and `turns upload DIRECTORY` are the daemon scoped to one directory
|
|
28
|
+
tree: they keep watching, stream live rows as the harness writes them, and
|
|
29
|
+
serve prompts queued from the web until interrupted. That directory is the
|
|
30
|
+
allowlist, so sessions in its subdirectories are followed and agent launches
|
|
31
|
+
cannot start outside it. Run `turns install` instead for an unattended
|
|
32
|
+
all-directory daemon; while that daemon is active a foreground run declines to
|
|
33
|
+
start a second uploader, since both would write the same upload-cursor file.
|
|
34
|
+
|
|
35
|
+
`--once` sweeps the same directory tree and exits as soon as its sessions are
|
|
36
|
+
current, for scripts and CI steps that need an upload to finish rather than a
|
|
37
|
+
process to stay resident. A run narrowed to a directory — watching or `--once` —
|
|
38
|
+
never prunes saved upload cursors, so it cannot make the sessions it filtered
|
|
39
|
+
out replay from their first byte the next time the daemon starts.
|
|
40
|
+
|
|
26
41
|
The server is selected with `--server URL` or `TURNS_ORIGIN`. Its normalized
|
|
27
42
|
origin is saved in `~/.config/turns/credentials.json` and reused by later
|
|
28
43
|
invocations and daemon starts. On Linux the other persistent files are:
|
|
@@ -42,7 +57,7 @@ Rows are scanned on their way out for substrings that look like file paths —
|
|
|
42
57
|
filesystem confirms is recorded on the session's own log as a resolution: the
|
|
43
58
|
substring, the absolute path, the size, and whether the contents were sent.
|
|
44
59
|
Paths resolve against the session's working directory and are canonicalised
|
|
45
|
-
before the `--
|
|
60
|
+
before the `--directory` prefixes are enforced, so a symlink out of an
|
|
46
61
|
allowed tree is not a way to read the disk.
|
|
47
62
|
|
|
48
63
|
Only images under 10 MB are uploaded, to `PUT /api/file/{machine}/{path}`. Everything else is recorded as a name, so
|
|
@@ -59,16 +74,16 @@ straight to R2, so neither process retains the complete file in memory.
|
|
|
59
74
|
The systemd unit uses `KillMode=process`: restarting the uploader leaves its
|
|
60
75
|
detached agent-anchor processes running so in-flight harness turns continue.
|
|
61
76
|
For a foreground daemon restricted to one or more directory trees, repeat
|
|
62
|
-
`--
|
|
77
|
+
`--directory`. Both transcript discovery and agent launches enforce the
|
|
63
78
|
canonical prefix, including protection against symlinks that escape it:
|
|
64
79
|
|
|
65
80
|
```sh
|
|
66
|
-
turns daemon run --server https://turns.example --
|
|
81
|
+
turns daemon run --server https://turns.example --directory /srv/agents
|
|
67
82
|
```
|
|
68
83
|
|
|
69
84
|
Against local Wrangler development, use the configured `DEV_USER` directly:
|
|
70
85
|
|
|
71
86
|
```sh
|
|
72
87
|
turns daemon run --server http://127.0.0.1:8787 \
|
|
73
|
-
--dev-user antimatter15 --
|
|
88
|
+
--dev-user antimatter15 --directory /tmp/turns-driver-workspaces
|
|
74
89
|
```
|
package/dist/agent-anchor.js
CHANGED
|
@@ -682,8 +682,13 @@ class AcpTransport extends LineTransport {
|
|
|
682
682
|
await this.request('session/set_mode', { sessionId: this.sessionId, modeId: this.profile.mode }, 30_000);
|
|
683
683
|
}
|
|
684
684
|
const model = this.anchor.model && this.anchor.model !== 'auto' ? this.anchor.model : this.profile.model;
|
|
685
|
-
if (model)
|
|
686
|
-
|
|
685
|
+
if (model) {
|
|
686
|
+
const method = this.profile.modelOption ? 'session/set_config_option' : 'session/set_model';
|
|
687
|
+
const params = this.profile.modelOption
|
|
688
|
+
? { sessionId: this.sessionId, configId: 'model', value: model }
|
|
689
|
+
: { sessionId: this.sessionId, modelId: model };
|
|
690
|
+
await this.request(method, params, 30_000).catch(() => { });
|
|
691
|
+
}
|
|
687
692
|
this.ready = true;
|
|
688
693
|
}
|
|
689
694
|
prompt(text, timeoutMs) {
|
package/dist/file-browsing.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
@@ -7,16 +8,14 @@ import { uploadFileBytes, uploadFileStream } from "./path-resolution.js";
|
|
|
7
8
|
* Answering "what is in that directory?" from the machine that knows.
|
|
8
9
|
*
|
|
9
10
|
* A reader's browser cannot reach this machine, and this machine will not open
|
|
10
|
-
* a port to let it try.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* upload finishes.
|
|
11
|
+
* a port to let it try. Directories are small structured answers, so the daemon
|
|
12
|
+
* sends their listing on the control socket immediately and stores the same
|
|
13
|
+
* bytes in the background for offline browsing. Regular files still stream to
|
|
14
|
+
* object storage; nothing stays open once the one browse upload finishes.
|
|
15
15
|
*
|
|
16
|
-
* A directory
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* tree is not a way to read the rest of the disk.
|
|
16
|
+
* A directory's fallback is stored at `_index.json` beneath it. Every path is
|
|
17
|
+
* canonicalised before the allowlist is consulted, so a symlink pointing out
|
|
18
|
+
* of an allowed tree is not a way to read the rest of the disk.
|
|
20
19
|
*/
|
|
21
20
|
/** Kept in step with `MAX_DIRECTORY_ENTRIES` in the Worker's shared listing. */
|
|
22
21
|
export const MAX_DIRECTORY_ENTRIES = 2_000;
|
|
@@ -26,62 +25,9 @@ export const DIRECTORY_INDEX_NAME = '_index.json';
|
|
|
26
25
|
export const MAX_BROWSE_FILE_BYTES = 199 * 1024 * 1024;
|
|
27
26
|
/** The derived label used in reader-facing errors. */
|
|
28
27
|
export const BROWSE_FILE_LIMIT_LABEL = `${Math.round(MAX_BROWSE_FILE_BYTES / (1024 * 1024))} MB`;
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
'.c',
|
|
33
|
-
'.cc',
|
|
34
|
-
'.cfg',
|
|
35
|
-
'.conf',
|
|
36
|
-
'.cpp',
|
|
37
|
-
'.cs',
|
|
38
|
-
'.css',
|
|
39
|
-
'.csv',
|
|
40
|
-
'.diff',
|
|
41
|
-
'.env',
|
|
42
|
-
'.go',
|
|
43
|
-
'.gradle',
|
|
44
|
-
'.h',
|
|
45
|
-
'.hpp',
|
|
46
|
-
'.htm',
|
|
47
|
-
'.html',
|
|
48
|
-
'.ini',
|
|
49
|
-
'.java',
|
|
50
|
-
'.jsonl',
|
|
51
|
-
'.jsx',
|
|
52
|
-
'.kt',
|
|
53
|
-
'.less',
|
|
54
|
-
'.lock',
|
|
55
|
-
'.log',
|
|
56
|
-
'.lua',
|
|
57
|
-
'.m',
|
|
58
|
-
'.md',
|
|
59
|
-
'.mjs',
|
|
60
|
-
'.cjs',
|
|
61
|
-
'.mts',
|
|
62
|
-
'.patch',
|
|
63
|
-
'.php',
|
|
64
|
-
'.pl',
|
|
65
|
-
'.properties',
|
|
66
|
-
'.py',
|
|
67
|
-
'.r',
|
|
68
|
-
'.rb',
|
|
69
|
-
'.rs',
|
|
70
|
-
'.scss',
|
|
71
|
-
'.sh',
|
|
72
|
-
'.sql',
|
|
73
|
-
'.swift',
|
|
74
|
-
'.tf',
|
|
75
|
-
'.toml',
|
|
76
|
-
'.ts',
|
|
77
|
-
'.tsx',
|
|
78
|
-
'.txt',
|
|
79
|
-
'.vue',
|
|
80
|
-
'.xml',
|
|
81
|
-
'.yaml',
|
|
82
|
-
'.yml',
|
|
83
|
-
'.zsh',
|
|
84
|
-
]);
|
|
28
|
+
/** Enough data to distinguish source and prose from arbitrary binary bytes
|
|
29
|
+
* without reading a large artifact into the daemon's memory. */
|
|
30
|
+
export const FILE_TYPE_SAMPLE_BYTES = 64 * 1024;
|
|
85
31
|
/** Extensions carrying their own type, where guessing text would be wrong. */
|
|
86
32
|
const BINARY_CONTENT_TYPES = new Map([
|
|
87
33
|
['.apng', 'image/apng'],
|
|
@@ -104,23 +50,55 @@ const BINARY_CONTENT_TYPES = new Map([
|
|
|
104
50
|
['.zip', 'application/zip'],
|
|
105
51
|
]);
|
|
106
52
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* is served as bytes: the Worker sends it with `nosniff` under a sandboxing
|
|
112
|
-
* policy, so an unhelpful type is only ever unhelpful.
|
|
53
|
+
* Whether a bounded sample can safely be decoded in the browser's text editor.
|
|
54
|
+
* NUL and uncommon ASCII control bytes are strong binary signals; strict UTF-8
|
|
55
|
+
* keeps arbitrary binary from being rendered as replacement-character noise.
|
|
56
|
+
* An incomplete final code point is allowed only when the sample is a prefix.
|
|
113
57
|
*/
|
|
114
|
-
export function
|
|
58
|
+
export function fileSampleIsText(sample, complete) {
|
|
59
|
+
let controls = 0;
|
|
60
|
+
for (const byte of sample) {
|
|
61
|
+
if (byte === 0)
|
|
62
|
+
return false;
|
|
63
|
+
if (byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0c && byte !== 0x0d && byte !== 0x1b) {
|
|
64
|
+
controls += 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (controls / Math.max(1, sample.byteLength) > 0.01)
|
|
68
|
+
return false;
|
|
69
|
+
try {
|
|
70
|
+
new TextDecoder('utf-8', { fatal: true }).decode(sample, { stream: !complete });
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** How one browsed file should be served back. Explicit media containers keep
|
|
78
|
+
* their MIME type; source, prose, and unknown extensions are decided by bytes.
|
|
79
|
+
* The extension only refines textual JSON after the sample has passed. */
|
|
80
|
+
export function browseContentType(file, sample, complete) {
|
|
115
81
|
const extension = path.extname(file).toLowerCase();
|
|
116
82
|
const known = BINARY_CONTENT_TYPES.get(extension);
|
|
117
83
|
if (known)
|
|
118
84
|
return known;
|
|
119
|
-
if (
|
|
120
|
-
return 'application/
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
85
|
+
if (!fileSampleIsText(sample, complete))
|
|
86
|
+
return 'application/octet-stream';
|
|
87
|
+
return extension === '.json' ? 'application/json' : 'text/plain; charset=utf-8';
|
|
88
|
+
}
|
|
89
|
+
function readFileTypeSample(file, bytes) {
|
|
90
|
+
const length = Math.min(bytes, FILE_TYPE_SAMPLE_BYTES);
|
|
91
|
+
if (!length)
|
|
92
|
+
return new Uint8Array();
|
|
93
|
+
const sample = Buffer.allocUnsafe(length);
|
|
94
|
+
const handle = fs.openSync(file, 'r');
|
|
95
|
+
try {
|
|
96
|
+
const read = fs.readSync(handle, sample, 0, length, 0);
|
|
97
|
+
return sample.subarray(0, read);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
fs.closeSync(handle);
|
|
101
|
+
}
|
|
124
102
|
}
|
|
125
103
|
function isoTime(value) {
|
|
126
104
|
return Number.isFinite(value) ? new Date(value).toISOString() : '';
|
|
@@ -193,39 +171,42 @@ export function readDirectory(directory) {
|
|
|
193
171
|
export function directoryIndexPath(directory) {
|
|
194
172
|
return `${String(directory || '').replace(/\/+$/u, '')}/${DIRECTORY_INDEX_NAME}`;
|
|
195
173
|
}
|
|
196
|
-
/**
|
|
197
|
-
* Store one path for a reader, and say what was stored.
|
|
198
|
-
*
|
|
199
|
-
* An empty file is a legitimate thing to open and has no bytes to upload, so it
|
|
200
|
-
* is reported rather than sent — the reader is told the file is empty instead of
|
|
201
|
-
* being handed a URL for an object that was never written. A file past the
|
|
202
|
-
* upload ceiling is refused here, where the size is known, rather than by the
|
|
203
|
-
* Worker after the bytes have crossed the network.
|
|
204
|
-
*/
|
|
205
174
|
export async function browseMachinePath({ requested, machine, baseUrl, options, workingDirectory = '', fetchImpl = fetch, }) {
|
|
206
175
|
const resolved = resolveBrowsePath(requested, {
|
|
207
176
|
workingDirectory,
|
|
208
177
|
allowedDirectories: options.allowedDirectories || [],
|
|
209
178
|
});
|
|
210
179
|
if (resolved.stat.isDirectory()) {
|
|
211
|
-
const
|
|
180
|
+
const listing = readDirectory(resolved.path);
|
|
181
|
+
const body = new TextEncoder().encode(JSON.stringify(listing));
|
|
212
182
|
if (body.byteLength > MAX_BROWSE_FILE_BYTES)
|
|
213
183
|
throw new Error(`Directory listing is too large: ${resolved.path}`);
|
|
214
|
-
|
|
184
|
+
const etag = crypto.createHash('sha256').update(body).digest('hex');
|
|
185
|
+
const cache = uploadFileBytes({
|
|
215
186
|
baseUrl,
|
|
216
187
|
options,
|
|
217
188
|
machine,
|
|
218
189
|
absolutePath: directoryIndexPath(resolved.path),
|
|
219
190
|
contentType: 'application/json',
|
|
191
|
+
contentEtag: etag,
|
|
220
192
|
body,
|
|
221
193
|
fetchImpl,
|
|
222
194
|
});
|
|
223
|
-
return {
|
|
195
|
+
return {
|
|
196
|
+
path: resolved.path,
|
|
197
|
+
kind: 'directory',
|
|
198
|
+
bytes: body.byteLength,
|
|
199
|
+
contentType: 'application/json',
|
|
200
|
+
listing,
|
|
201
|
+
etag,
|
|
202
|
+
cache,
|
|
203
|
+
};
|
|
224
204
|
}
|
|
225
|
-
const contentType = browseContentType(resolved.path);
|
|
226
205
|
if (resolved.stat.size > MAX_BROWSE_FILE_BYTES) {
|
|
227
206
|
throw new Error(`File is larger than the ${BROWSE_FILE_LIMIT_LABEL} browse limit.`);
|
|
228
207
|
}
|
|
208
|
+
const sample = readFileTypeSample(resolved.path, resolved.stat.size);
|
|
209
|
+
const contentType = browseContentType(resolved.path, sample, sample.byteLength === resolved.stat.size);
|
|
229
210
|
if (resolved.stat.size === 0)
|
|
230
211
|
return { path: resolved.path, kind: 'file', bytes: 0, contentType };
|
|
231
212
|
await uploadFileStream({
|
package/dist/harness-registry.js
CHANGED
|
@@ -144,6 +144,17 @@ export const ACP_PROFILES = {
|
|
|
144
144
|
mcp: true,
|
|
145
145
|
},
|
|
146
146
|
vibe: { command: 'vibe-acp', args: [], mcp: true },
|
|
147
|
+
fx: {
|
|
148
|
+
command: 'fx',
|
|
149
|
+
args: ['acp'],
|
|
150
|
+
// Fx reads the process-wide policy when the ACP server starts. Setting a
|
|
151
|
+
// session mode afterwards would replace `yolo` with code mode's `auto`, so
|
|
152
|
+
// bypass deliberately has no session/set_mode call.
|
|
153
|
+
env: (permissionMode) => ({ FX_PERMISSION_MODE: permissionMode === 'bypassPermissions' ? 'yolo' : 'ask' }),
|
|
154
|
+
mode: (permissionMode) => (permissionMode === 'bypassPermissions' ? undefined : 'ask'),
|
|
155
|
+
modelOption: true,
|
|
156
|
+
mcp: true,
|
|
157
|
+
},
|
|
147
158
|
};
|
|
148
159
|
// These harnesses keep one bidirectional process attached to the anchor. They
|
|
149
160
|
// are listed separately from process harnesses so queued turns cannot
|
package/dist/index.js
CHANGED
|
@@ -62,8 +62,9 @@ function help() {
|
|
|
62
62
|
return `Turns ${pkg.version}
|
|
63
63
|
|
|
64
64
|
Usage:
|
|
65
|
-
turns
|
|
66
|
-
turns upload [directory] Watch sessions
|
|
65
|
+
turns Watch and stream sessions for the current directory
|
|
66
|
+
turns upload [directory] Watch and stream sessions for one directory
|
|
67
|
+
turns --once [directory] Upload the directory's sessions once and exit
|
|
67
68
|
turns install Install and start the all-directory daemon
|
|
68
69
|
turns status Show daemon status
|
|
69
70
|
turns uninstall Stop and uninstall the background daemon
|
|
@@ -73,7 +74,8 @@ Usage:
|
|
|
73
74
|
|
|
74
75
|
Options:
|
|
75
76
|
--server URL Use a Turns server (saved with credentials)
|
|
76
|
-
--
|
|
77
|
+
--once Upload once and exit instead of watching
|
|
78
|
+
--directory PATH Restrict daemon uploads/launches to this prefix (repeatable)
|
|
77
79
|
--dev-user LOGIN Authenticate to a local Wrangler DEV_USER server
|
|
78
80
|
--no-browser Print the authentication URL without opening it
|
|
79
81
|
|
|
@@ -86,6 +88,7 @@ function parseGlobalArguments(argv) {
|
|
|
86
88
|
const remaining = [];
|
|
87
89
|
let origin = process.env.TURNS_ORIGIN || process.env.TURNS_SERVER || '';
|
|
88
90
|
let devUser = '';
|
|
91
|
+
let once = false;
|
|
89
92
|
const allowedDirectories = [];
|
|
90
93
|
for (let index = 0; index < argv.length; index += 1) {
|
|
91
94
|
const argument = argv[index];
|
|
@@ -95,7 +98,7 @@ function parseGlobalArguments(argv) {
|
|
|
95
98
|
throw new Error(`${argument} requires a URL.`);
|
|
96
99
|
origin = value;
|
|
97
100
|
}
|
|
98
|
-
else if (argument === '--
|
|
101
|
+
else if (argument === '--directory') {
|
|
99
102
|
const value = argv[++index];
|
|
100
103
|
if (!value)
|
|
101
104
|
throw new Error(`${argument} requires a path.`);
|
|
@@ -107,6 +110,9 @@ function parseGlobalArguments(argv) {
|
|
|
107
110
|
throw new Error(`${argument} requires a login.`);
|
|
108
111
|
devUser = value.trim().toLowerCase();
|
|
109
112
|
}
|
|
113
|
+
else if (argument === '--once') {
|
|
114
|
+
once = true;
|
|
115
|
+
}
|
|
110
116
|
else if (argument === '--no-browser') {
|
|
111
117
|
process.env.TURNS_NO_BROWSER = '1';
|
|
112
118
|
}
|
|
@@ -114,7 +120,24 @@ function parseGlobalArguments(argv) {
|
|
|
114
120
|
remaining.push(argument);
|
|
115
121
|
}
|
|
116
122
|
}
|
|
117
|
-
return { argv: remaining, origin: origin || undefined, devUser, allowedDirectories };
|
|
123
|
+
return { argv: remaining, origin: origin || undefined, devUser, once, allowedDirectories };
|
|
124
|
+
}
|
|
125
|
+
/** A foreground run is the daemon scoped to one directory: it keeps watching,
|
|
126
|
+
* streams live rows, and serves queued prompts. The directory becomes the
|
|
127
|
+
* allowlist rather than an upload filter, so sessions in its subdirectories are
|
|
128
|
+
* followed too and launches arriving from the server cannot escape it. */
|
|
129
|
+
export function scopedWatchOptions(directory, { once = false } = {}) {
|
|
130
|
+
const resolved = path.resolve(directory || process.cwd());
|
|
131
|
+
let allowedDirectories;
|
|
132
|
+
try {
|
|
133
|
+
allowedDirectories = normalizeAllowedDirectories([resolved]);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
throw new Error(`Not a directory: ${resolved}`);
|
|
137
|
+
}
|
|
138
|
+
// `--once` keeps the same tree, so a single sweep covers subdirectories the
|
|
139
|
+
// way the watch does; it only stops the process from staying resident.
|
|
140
|
+
return { directory: resolved, watch: !once, allowedDirectories };
|
|
118
141
|
}
|
|
119
142
|
function credentialProvider(initial, { paths, interactive }) {
|
|
120
143
|
let credentials = initial;
|
|
@@ -166,14 +189,18 @@ function credentialProvider(initial, { paths, interactive }) {
|
|
|
166
189
|
* @param {object} credentials
|
|
167
190
|
* @param {object} [options]
|
|
168
191
|
* @param {import('./auth.ts').AuthPaths} [options.paths]
|
|
169
|
-
* @param {string} [options.directory]
|
|
170
192
|
* @param {boolean} [options.watch]
|
|
171
193
|
* @param {boolean} [options.all]
|
|
172
194
|
* @param {boolean} [options.interactive]
|
|
173
195
|
* @param {string} [options.devUser]
|
|
174
196
|
* @param {string[]} [options.allowedDirectories]
|
|
175
197
|
*/
|
|
176
|
-
async function runUpload(credentials, { paths,
|
|
198
|
+
async function runUpload(credentials, { paths, watch = false, all = false, interactive = true, devUser = '', allowedDirectories = [], } = {}) {
|
|
199
|
+
if (!all && !allowedDirectories.length) {
|
|
200
|
+
// Every caller either sweeps all directories or names the prefixes it may
|
|
201
|
+
// touch. An unscoped run would also accept launches anywhere on the machine.
|
|
202
|
+
throw new Error('runUpload requires either all directories or an explicit allowlist.');
|
|
203
|
+
}
|
|
177
204
|
migrateLegacyUploaderState(paths);
|
|
178
205
|
const provider = devUser ? null : credentialProvider(credentials, { paths, interactive });
|
|
179
206
|
const { runUploader } = await import("./upload-sessions.js");
|
|
@@ -186,9 +213,9 @@ async function runUpload(credentials, { paths, directory = '', watch = false, al
|
|
|
186
213
|
'--machine-id-file',
|
|
187
214
|
paths.machineId,
|
|
188
215
|
...(devUser ? ['--dev-user', devUser] : []),
|
|
189
|
-
...allowedDirectories.flatMap((directory) => ['--
|
|
216
|
+
...allowedDirectories.flatMap((directory) => ['--directory', directory]),
|
|
190
217
|
...(watch ? ['--watch'] : []),
|
|
191
|
-
...(all ? ['--all'] :
|
|
218
|
+
...(all ? ['--all'] : []),
|
|
192
219
|
];
|
|
193
220
|
try {
|
|
194
221
|
await runUploader(arguments_, { credentialProvider: provider });
|
|
@@ -242,6 +269,8 @@ async function main(rawArguments = process.argv.slice(2)) {
|
|
|
242
269
|
if (argv[1] !== 'run')
|
|
243
270
|
throw new Error(`Unknown daemon command: ${argv[1] || ''}\n\n${help()}`);
|
|
244
271
|
const allowedDirectories = normalizeAllowedDirectories(parsed.allowedDirectories);
|
|
272
|
+
if (parsed.once)
|
|
273
|
+
throw new Error('--once cannot be combined with `turns daemon run`.');
|
|
245
274
|
if (parsed.devUser && !parsed.origin)
|
|
246
275
|
throw new Error('--dev-user requires an explicit --server URL.');
|
|
247
276
|
let credentials;
|
|
@@ -266,24 +295,31 @@ async function main(rawArguments = process.argv.slice(2)) {
|
|
|
266
295
|
return;
|
|
267
296
|
}
|
|
268
297
|
if (parsed.devUser || parsed.allowedDirectories.length) {
|
|
269
|
-
throw new Error('--dev-user and --
|
|
298
|
+
throw new Error('--dev-user and --directory are supported only by `turns daemon run`.');
|
|
270
299
|
}
|
|
271
300
|
if (command && command !== 'upload')
|
|
272
301
|
throw new Error(`Unknown command: ${command}\n\n${help()}`);
|
|
273
302
|
const runningDaemon = daemonStatus();
|
|
303
|
+
// Including `--once`: the daemon holds the same upload-cursor file, and a
|
|
304
|
+
// second writer would persist its own load-time copy over the daemon's
|
|
305
|
+
// concurrent progress. The daemon already keeps this directory current.
|
|
274
306
|
if (runningDaemon.active) {
|
|
275
307
|
process.stdout.write('The Turns daemon is already syncing this directory as part of the all-directory watch.\n');
|
|
276
308
|
return;
|
|
277
309
|
}
|
|
278
310
|
await migrateLegacyCredentials(paths, parsed.origin);
|
|
279
311
|
const credentials = await authenticate({ origin: parsed.origin, paths });
|
|
280
|
-
const
|
|
281
|
-
|
|
312
|
+
const selected = scopedWatchOptions(command === 'upload' ? argv[1] || process.cwd() : process.cwd(), {
|
|
313
|
+
once: parsed.once,
|
|
314
|
+
});
|
|
315
|
+
process.stdout.write(selected.watch
|
|
316
|
+
? `Watching sessions in ${selected.directory} as @${credentials.user.login}. Press Ctrl-C to stop.\n`
|
|
317
|
+
: `Uploading sessions in ${selected.directory} as @${credentials.user.login}.\n`);
|
|
282
318
|
await runUpload(credentials, {
|
|
283
319
|
paths,
|
|
284
|
-
|
|
285
|
-
watch: command === 'upload',
|
|
320
|
+
watch: selected.watch,
|
|
286
321
|
interactive: true,
|
|
322
|
+
allowedDirectories: selected.allowedDirectories,
|
|
287
323
|
});
|
|
288
324
|
}
|
|
289
325
|
export { credentialProvider, help, main, parseGlobalArguments, runUpload };
|
|
@@ -87,6 +87,10 @@ const EXTRA_FILE_STORES = {
|
|
|
87
87
|
root: path.join(HOME, '.local', 'state', 'poolside', 'trajectories'),
|
|
88
88
|
include: (file) => /^trajectory-[^/]+\.ndjson$/i.test(path.basename(file)),
|
|
89
89
|
},
|
|
90
|
+
fx: {
|
|
91
|
+
root: path.join(HOME, '.fx', 'sessions'),
|
|
92
|
+
include: (file) => path.basename(file) === 'events.jsonl',
|
|
93
|
+
},
|
|
90
94
|
};
|
|
91
95
|
function safeStat(file) {
|
|
92
96
|
try {
|
|
@@ -248,6 +252,7 @@ function nativeSessionIdFromPath(harness, sourcePath) {
|
|
|
248
252
|
qwen: /\/chats\/([^/]+)\.jsonl$/iu,
|
|
249
253
|
cline: /\/([^/]+)\.messages\.json$/iu,
|
|
250
254
|
poolside: /trajectory-(?:standalone_)?([0-9a-f-]{20,})\.ndjson$/iu,
|
|
255
|
+
fx: /\/sessions\/([^/]+)\/events\.jsonl$/iu,
|
|
251
256
|
};
|
|
252
257
|
return patterns[harness]?.exec(normalized)?.[1] || '';
|
|
253
258
|
}
|
|
@@ -269,6 +274,28 @@ function fileDescriptor(harness, file) {
|
|
|
269
274
|
createdAt: (stat.birthtimeMs > 0 ? stat.birthtime : stat.mtime).toISOString(),
|
|
270
275
|
};
|
|
271
276
|
}
|
|
277
|
+
/** Fx's ledger path identifies the session; its adjacent projections provide
|
|
278
|
+
* the workspace and display title without requiring a conversation scan. */
|
|
279
|
+
function fxDescriptor(file) {
|
|
280
|
+
const descriptor = fileDescriptor('fx', file);
|
|
281
|
+
if (!descriptor)
|
|
282
|
+
return null;
|
|
283
|
+
const directory = path.dirname(file);
|
|
284
|
+
const session = jsonFile(path.join(directory, 'session.json'));
|
|
285
|
+
const display = jsonFile(path.join(directory, 'display.json'));
|
|
286
|
+
const createdAtMs = Number(session?.created_at_ms);
|
|
287
|
+
const createdAt = Number.isFinite(createdAtMs) ? new Date(createdAtMs).toISOString() : descriptor.createdAt;
|
|
288
|
+
const title = typeof display?.title === 'string' ? display.title.trim() : '';
|
|
289
|
+
return {
|
|
290
|
+
...descriptor,
|
|
291
|
+
createdAt,
|
|
292
|
+
nativeSessionId: String(session?.id || path.basename(directory)).trim(),
|
|
293
|
+
...(absolutePathCandidate([session?.workspace_root, session?.origin_workspace_root])
|
|
294
|
+
? { workingDirectory: absolutePathCandidate([session?.workspace_root, session?.origin_workspace_root]) }
|
|
295
|
+
: {}),
|
|
296
|
+
...(title ? { nativeTitle: title } : {}),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
272
299
|
/** Discover top-level and subagent DeepSeek trajectories with header metadata. */
|
|
273
300
|
export function deepseekTrajectoryDescriptors(root = DEEPSEEK_SESSIONS_ROOT) {
|
|
274
301
|
const descriptors = [];
|
|
@@ -1070,6 +1097,10 @@ export function nativeDescriptorWorkingDirectory(descriptor) {
|
|
|
1070
1097
|
cursor: cursorMetadataWorkingDirectory,
|
|
1071
1098
|
gemini: geminiMetadataWorkingDirectory,
|
|
1072
1099
|
kimi: kimiMetadataWorkingDirectory,
|
|
1100
|
+
fx: (candidate) => absolutePathCandidate([
|
|
1101
|
+
sidecarJson(candidate, 'session.json')?.workspace_root,
|
|
1102
|
+
sidecarJson(candidate, 'session.json')?.origin_workspace_root,
|
|
1103
|
+
]),
|
|
1073
1104
|
};
|
|
1074
1105
|
const metadataCandidate = metadataResolvers[descriptor.harness]?.(descriptor) || '';
|
|
1075
1106
|
if (metadataCandidate)
|
|
@@ -1287,6 +1318,7 @@ const HARNESS_PROCESS_NAMES = {
|
|
|
1287
1318
|
prime: ['prime', 'prime-agent'],
|
|
1288
1319
|
qwen: ['qwen'],
|
|
1289
1320
|
vibe: ['vibe', 'vibe-acp'],
|
|
1321
|
+
fx: ['fx'],
|
|
1290
1322
|
};
|
|
1291
1323
|
const SCRIPT_RUNTIMES = new Set(['node', 'nodejs', 'bun', 'deno', 'python', 'python3']);
|
|
1292
1324
|
function executableStem(value) {
|
|
@@ -1392,7 +1424,7 @@ function discoverLocalSessionInventory(only = '') {
|
|
|
1392
1424
|
walk(store.root, (file) => {
|
|
1393
1425
|
if (!store.include(file))
|
|
1394
1426
|
return;
|
|
1395
|
-
const descriptor = fileDescriptor(harness, file);
|
|
1427
|
+
const descriptor = harness === 'fx' ? fxDescriptor(file) : fileDescriptor(harness, file);
|
|
1396
1428
|
if (descriptor)
|
|
1397
1429
|
descriptors.push(descriptor);
|
|
1398
1430
|
});
|
package/dist/path-resolution.js
CHANGED
|
@@ -173,7 +173,7 @@ function uploadHeaders(options) {
|
|
|
173
173
|
* addressed by machine and path alone — the session that happened to prompt the
|
|
174
174
|
* upload is not part of what was stored, and nothing records it.
|
|
175
175
|
*/
|
|
176
|
-
export async function uploadFileBytes({ baseUrl, options, machine, absolutePath, contentType, body, fetchImpl = fetch, }) {
|
|
176
|
+
export async function uploadFileBytes({ baseUrl, options, machine, absolutePath, contentType, contentEtag, body, fetchImpl = fetch, }) {
|
|
177
177
|
const url = new URL(`/api/file/${encodeURIComponent(machine)}/${encodeURIComponent(absolutePath)}`, String(baseUrl));
|
|
178
178
|
const response = await fetchImpl(url, {
|
|
179
179
|
method: 'PUT',
|
|
@@ -181,6 +181,7 @@ export async function uploadFileBytes({ baseUrl, options, machine, absolutePath,
|
|
|
181
181
|
...uploadHeaders(options),
|
|
182
182
|
'content-type': contentType,
|
|
183
183
|
'content-length': String(body.byteLength),
|
|
184
|
+
...(contentEtag ? { 'x-turns-content-etag': contentEtag } : {}),
|
|
184
185
|
},
|
|
185
186
|
body,
|
|
186
187
|
});
|
package/dist/queue-worker.js
CHANGED
|
@@ -952,22 +952,24 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
952
952
|
return recorded || path.dirname(descriptor.file);
|
|
953
953
|
};
|
|
954
954
|
/**
|
|
955
|
-
*
|
|
955
|
+
* Read one path this machine holds, so a reader can open it.
|
|
956
956
|
*
|
|
957
957
|
* Unlike the rest of these handlers this touches no harness: browsing is a
|
|
958
958
|
* property of the machine, so it answers for a stopped session as readily as
|
|
959
|
-
* for a running one.
|
|
959
|
+
* for a running one. Directory bytes answer directly; their offline copy is
|
|
960
|
+
* stored behind that reply.
|
|
960
961
|
*/
|
|
961
962
|
const handleFileBrowse = async (message, replyRpc) => {
|
|
962
963
|
const sessionId = String(message.sessionId || '');
|
|
963
964
|
try {
|
|
964
|
-
const
|
|
965
|
+
const browsed = await browseMachinePath({
|
|
965
966
|
requested: String(message.path || ''),
|
|
966
967
|
machine,
|
|
967
968
|
baseUrl,
|
|
968
969
|
options,
|
|
969
970
|
workingDirectory: await browseWorkingDirectory(sessionId),
|
|
970
971
|
});
|
|
972
|
+
const { cache, ...stored } = browsed.kind === 'directory' ? browsed : { ...browsed, cache: undefined };
|
|
971
973
|
replyRpc.notify({
|
|
972
974
|
type: 'session.file.browse.result',
|
|
973
975
|
requestId: message.requestId,
|
|
@@ -975,6 +977,11 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
975
977
|
ok: true,
|
|
976
978
|
...stored,
|
|
977
979
|
});
|
|
980
|
+
// The direct listing is already useful. Its R2 copy is only the fallback
|
|
981
|
+
// for a later offline browse, so storage latency must not hold this reply.
|
|
982
|
+
void cache?.catch((error) => {
|
|
983
|
+
console.warn(`Directory browse fallback upload failed: ${redactor.redactText(error?.message || String(error))}`);
|
|
984
|
+
});
|
|
978
985
|
}
|
|
979
986
|
catch (error) {
|
|
980
987
|
replyRpc.notify({
|
package/dist/upload-sessions.js
CHANGED
|
@@ -53,6 +53,7 @@ const TIME_FIELDS = [
|
|
|
53
53
|
'time_created',
|
|
54
54
|
'recorded_at',
|
|
55
55
|
'occurred_at_ms',
|
|
56
|
+
'timestamp_ms',
|
|
56
57
|
];
|
|
57
58
|
const TIME_CONTAINERS = ['data', 'payload', 'message', 'chat_message', 'node'];
|
|
58
59
|
function epochToIso(value) {
|
|
@@ -149,7 +150,7 @@ function argumentsFrom(argv, { allowMissingToken = false } = {}) {
|
|
|
149
150
|
rewriteSession: '',
|
|
150
151
|
nativeStateFile: process.env.TURNS_NATIVE_STATE_FILE || path.join(HERE, '.native-upload-state.json'),
|
|
151
152
|
machineIdFile: process.env.TURNS_MACHINE_ID_FILE || path.join(HERE, '.machine-id'),
|
|
152
|
-
|
|
153
|
+
onlyDirectory: '',
|
|
153
154
|
all: false,
|
|
154
155
|
devUser: '',
|
|
155
156
|
allowedDirectories: [],
|
|
@@ -180,13 +181,13 @@ function argumentsFrom(argv, { allowMissingToken = false } = {}) {
|
|
|
180
181
|
options.nativeStateFile = path.resolve(next());
|
|
181
182
|
else if (argument === '--machine-id-file')
|
|
182
183
|
options.machineIdFile = path.resolve(next());
|
|
183
|
-
else if (argument === '--directory')
|
|
184
|
-
options.
|
|
184
|
+
else if (argument === '--only-directory')
|
|
185
|
+
options.onlyDirectory = path.resolve(next());
|
|
185
186
|
else if (argument === '--all')
|
|
186
187
|
options.all = true;
|
|
187
188
|
else if (argument === '--dev-user')
|
|
188
189
|
options.devUser = next().trim().toLowerCase();
|
|
189
|
-
else if (argument === '--
|
|
190
|
+
else if (argument === '--directory')
|
|
190
191
|
options.allowedDirectories.push(next());
|
|
191
192
|
else if (argument === '--watch')
|
|
192
193
|
options.watch = true;
|
|
@@ -198,9 +199,9 @@ function argumentsFrom(argv, { allowMissingToken = false } = {}) {
|
|
|
198
199
|
` --dry-run inventory native sessions without uploading\n` +
|
|
199
200
|
` --runs PATH capture root (default: ${DEFAULT_RUNS})\n` +
|
|
200
201
|
` --only TEXT upload paths containing TEXT\n` +
|
|
201
|
-
` --directory PATH upload
|
|
202
|
+
` --directory PATH upload/launch only inside directory prefix PATH (repeatable)\n` +
|
|
203
|
+
` --only-directory PATH upload only sessions whose working directory is exactly PATH\n` +
|
|
202
204
|
` --all upload sessions across all working directories\n` +
|
|
203
|
-
` --allow-directory P upload/launch only inside directory prefix P (repeatable)\n` +
|
|
204
205
|
` --dev-user LOGIN authenticate as a local Wrangler DEV_USER uploader\n` +
|
|
205
206
|
` --url WS_URL websocket endpoint\n` +
|
|
206
207
|
` --token TOKEN internal/test bearer token override\n` +
|
|
@@ -214,10 +215,10 @@ function argumentsFrom(argv, { allowMissingToken = false } = {}) {
|
|
|
214
215
|
if (options.rewriteSession && !options.native) {
|
|
215
216
|
throw new Error('--rewrite-session requires --native.');
|
|
216
217
|
}
|
|
217
|
-
if (options.
|
|
218
|
-
throw new Error('--directory cannot be combined with --all.');
|
|
219
|
-
if (options.allowedDirectories.length && (options.
|
|
220
|
-
throw new Error('--
|
|
218
|
+
if (options.onlyDirectory && options.all)
|
|
219
|
+
throw new Error('--only-directory cannot be combined with --all.');
|
|
220
|
+
if (options.allowedDirectories.length && (options.onlyDirectory || options.all)) {
|
|
221
|
+
throw new Error('--directory cannot be combined with --only-directory or --all.');
|
|
221
222
|
}
|
|
222
223
|
options.allowedDirectories = normalizeAllowedDirectories(options.allowedDirectories);
|
|
223
224
|
if (!options.dryRun && !options.token && !options.devUser && !allowMissingToken) {
|
|
@@ -845,9 +846,11 @@ async function uploadFile(rpc, machine, root, capture, redactor = DEFAULT_REDACT
|
|
|
845
846
|
if (opened.type === 'session_tombstoned')
|
|
846
847
|
return { rows: 0, sent: 0, skipped: true, tombstoned: true };
|
|
847
848
|
let nextRow = Number(opened.nextRow) || 0;
|
|
849
|
+
let initialUpload = nextRow === 0;
|
|
848
850
|
if (Number(opened.redactionVersion || 0) !== REDACTION_VERSION) {
|
|
849
851
|
await rpc.request({ type: 'session.reset', sessionId, redactionVersion: REDACTION_VERSION });
|
|
850
852
|
nextRow = 0;
|
|
853
|
+
initialUpload = true;
|
|
851
854
|
}
|
|
852
855
|
if (nextRow > rows.length)
|
|
853
856
|
throw new Error(`${capture.relative}: remote has ${nextRow} rows, local file has ${rows.length}`);
|
|
@@ -857,7 +860,12 @@ async function uploadFile(rpc, machine, root, capture, redactor = DEFAULT_REDACT
|
|
|
857
860
|
for (const batch of batches(rows, raw, nextRow, redactor)) {
|
|
858
861
|
let acknowledgement;
|
|
859
862
|
try {
|
|
860
|
-
acknowledgement = await rpc.request({
|
|
863
|
+
acknowledgement = await rpc.request({
|
|
864
|
+
type: 'ingest',
|
|
865
|
+
sessionId,
|
|
866
|
+
...batch,
|
|
867
|
+
...(initialUpload ? { deferReduction: true } : {}),
|
|
868
|
+
});
|
|
861
869
|
}
|
|
862
870
|
catch (error) {
|
|
863
871
|
const offset = /^ROW_OFFSET:(\d+)$/u.exec(String(error.code || ''));
|
|
@@ -946,12 +954,16 @@ export async function uploadNativeSession(rpc, machine, descriptor, cursor = {},
|
|
|
946
954
|
}
|
|
947
955
|
const uploadSessionId = String(opened.sessionId || sessionId);
|
|
948
956
|
let nextRow = Number(opened.nextRow) || 0;
|
|
957
|
+
let initialUpload = nextRow === 0 || cursor.initialUpload === true;
|
|
949
958
|
if (Number(opened.redactionVersion || 0) !== REDACTION_VERSION) {
|
|
950
959
|
await rpc.request({ type: 'session.reset', sessionId: uploadSessionId, redactionVersion: REDACTION_VERSION });
|
|
951
960
|
nextRow = 0;
|
|
952
961
|
for (const key of Object.keys(cursor))
|
|
953
962
|
delete cursor[key];
|
|
963
|
+
initialUpload = true;
|
|
954
964
|
}
|
|
965
|
+
if (initialUpload)
|
|
966
|
+
cursor.initialUpload = true;
|
|
955
967
|
const resumable = canResumeNativeCursor(descriptor, cursor, nextRow);
|
|
956
968
|
const scanCursor = resumable
|
|
957
969
|
? { offset: Number(cursor.offset), rowCount: Number(cursor.rowCount), lineNumber: Number(cursor.lineNumber) || 0 }
|
|
@@ -981,6 +993,7 @@ export async function uploadNativeSession(rpc, machine, descriptor, cursor = {},
|
|
|
981
993
|
sessionId: uploadSessionId,
|
|
982
994
|
startRow: rows[0].index,
|
|
983
995
|
rows,
|
|
996
|
+
...(initialUpload ? { deferReduction: true } : {}),
|
|
984
997
|
});
|
|
985
998
|
nextRow = Number(acknowledgement.nextRow);
|
|
986
999
|
sent += rows.length;
|
|
@@ -1072,8 +1085,10 @@ export async function uploadNativeSession(rpc, machine, descriptor, cursor = {},
|
|
|
1072
1085
|
});
|
|
1073
1086
|
}
|
|
1074
1087
|
if (!sawRows && scanCursor.rowCount === 0) {
|
|
1088
|
+
delete cursor.initialUpload;
|
|
1075
1089
|
return { rows: 0, sent: 0, skipped: true, empty: true };
|
|
1076
1090
|
}
|
|
1091
|
+
delete cursor.initialUpload;
|
|
1077
1092
|
return { rows: scanCursor.rowCount, sent, skipped: sent === 0 };
|
|
1078
1093
|
}
|
|
1079
1094
|
export function nativeFailureRetryDelay(attempts) {
|
|
@@ -1245,7 +1260,7 @@ export function watchNativeSessionDirectories({ roots, shallowRoots = [], files
|
|
|
1245
1260
|
};
|
|
1246
1261
|
}
|
|
1247
1262
|
const nativeWorkingDirectoryCache = new Map();
|
|
1248
|
-
export async function
|
|
1263
|
+
export async function filterNativeSessionsByOnlyDirectory(sessions, directory, resolveWorkingDirectory = nativeDescriptorWorkingDirectory) {
|
|
1249
1264
|
if (!directory)
|
|
1250
1265
|
return sessions;
|
|
1251
1266
|
const selected = path.resolve(directory);
|
|
@@ -1263,7 +1278,7 @@ export async function filterNativeSessionsByDirectory(sessions, directory, resol
|
|
|
1263
1278
|
}
|
|
1264
1279
|
return scoped;
|
|
1265
1280
|
}
|
|
1266
|
-
export async function
|
|
1281
|
+
export async function filterNativeSessionsByDirectories(sessions, allowedDirectories, resolveWorkingDirectory = nativeDescriptorWorkingDirectory) {
|
|
1267
1282
|
if (!allowedDirectories?.length)
|
|
1268
1283
|
return sessions;
|
|
1269
1284
|
const scoped = [];
|
|
@@ -1280,10 +1295,18 @@ export async function filterNativeSessionsByAllowedDirectories(sessions, allowed
|
|
|
1280
1295
|
}
|
|
1281
1296
|
return scoped;
|
|
1282
1297
|
}
|
|
1298
|
+
/** Whether this run's inventory speaks for the whole machine. A run narrowed by
|
|
1299
|
+
* `--only`, `--rewrite-session`, `--only-directory`, or `--directory` sees a
|
|
1300
|
+
* subset, so it must not prune upload cursors for the sessions it filtered out:
|
|
1301
|
+
* the next unscoped run would find no cursor and replay those transcripts from
|
|
1302
|
+
* their first byte. */
|
|
1303
|
+
export function inventoryIsAuthoritative(options) {
|
|
1304
|
+
return !(options.only || options.rewriteSession || options.onlyDirectory || options.allowedDirectories?.length);
|
|
1305
|
+
}
|
|
1283
1306
|
async function nativeInventory(options, machine) {
|
|
1284
1307
|
let sessions = linkNativeSessionParents(machine, discoverLocalSessions(options.rewriteSession ? '' : options.only));
|
|
1285
|
-
sessions = await
|
|
1286
|
-
sessions = await
|
|
1308
|
+
sessions = await filterNativeSessionsByOnlyDirectory(sessions, options.onlyDirectory);
|
|
1309
|
+
sessions = await filterNativeSessionsByDirectories(sessions, options.allowedDirectories);
|
|
1287
1310
|
if (!options.rewriteSession)
|
|
1288
1311
|
return sessions;
|
|
1289
1312
|
const exact = sessions.filter((descriptor) => stableNativeSessionId(machine, descriptor.harness, descriptor.sourcePath) === options.rewriteSession);
|
|
@@ -1399,6 +1422,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
|
|
|
1399
1422
|
const livePriority = new Set();
|
|
1400
1423
|
const tombstoned = new Set();
|
|
1401
1424
|
let nativeMonitor = null;
|
|
1425
|
+
const authoritativeInventory = inventoryIsAuthoritative(options);
|
|
1402
1426
|
const rememberInventory = (sessions, retainState = false, forceMetadataDirectories = new Set()) => {
|
|
1403
1427
|
const previousFingerprints = new Map(fingerprints);
|
|
1404
1428
|
descriptorBySession.clear();
|
|
@@ -1459,7 +1483,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
|
|
|
1459
1483
|
redactionStarted.add(sessionId);
|
|
1460
1484
|
}
|
|
1461
1485
|
}
|
|
1462
|
-
if (retainState &&
|
|
1486
|
+
if (retainState && authoritativeInventory)
|
|
1463
1487
|
state.retain(descriptorBySession.keys());
|
|
1464
1488
|
descriptorRegistry.sessions = sessions;
|
|
1465
1489
|
nativeMonitor?.addFiles(sessions.map((descriptor) => descriptor.file));
|
|
@@ -1725,7 +1749,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
|
|
|
1725
1749
|
rememberInventory(sessions);
|
|
1726
1750
|
const unsettled = changedSessions();
|
|
1727
1751
|
if (unsettled.length) {
|
|
1728
|
-
process.stderr.write(`${unsettled.length} native sessions kept changing;
|
|
1752
|
+
process.stderr.write(`${unsettled.length} native sessions kept changing; run \`turns\` without --once to follow them continuously.\n`);
|
|
1729
1753
|
}
|
|
1730
1754
|
break;
|
|
1731
1755
|
}
|