@turns/dev 0.0.1 → 0.0.2
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/index.js +50 -14
- package/dist/upload-sessions.js +25 -16
- 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/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 };
|
package/dist/upload-sessions.js
CHANGED
|
@@ -149,7 +149,7 @@ function argumentsFrom(argv, { allowMissingToken = false } = {}) {
|
|
|
149
149
|
rewriteSession: '',
|
|
150
150
|
nativeStateFile: process.env.TURNS_NATIVE_STATE_FILE || path.join(HERE, '.native-upload-state.json'),
|
|
151
151
|
machineIdFile: process.env.TURNS_MACHINE_ID_FILE || path.join(HERE, '.machine-id'),
|
|
152
|
-
|
|
152
|
+
onlyDirectory: '',
|
|
153
153
|
all: false,
|
|
154
154
|
devUser: '',
|
|
155
155
|
allowedDirectories: [],
|
|
@@ -180,13 +180,13 @@ function argumentsFrom(argv, { allowMissingToken = false } = {}) {
|
|
|
180
180
|
options.nativeStateFile = path.resolve(next());
|
|
181
181
|
else if (argument === '--machine-id-file')
|
|
182
182
|
options.machineIdFile = path.resolve(next());
|
|
183
|
-
else if (argument === '--directory')
|
|
184
|
-
options.
|
|
183
|
+
else if (argument === '--only-directory')
|
|
184
|
+
options.onlyDirectory = path.resolve(next());
|
|
185
185
|
else if (argument === '--all')
|
|
186
186
|
options.all = true;
|
|
187
187
|
else if (argument === '--dev-user')
|
|
188
188
|
options.devUser = next().trim().toLowerCase();
|
|
189
|
-
else if (argument === '--
|
|
189
|
+
else if (argument === '--directory')
|
|
190
190
|
options.allowedDirectories.push(next());
|
|
191
191
|
else if (argument === '--watch')
|
|
192
192
|
options.watch = true;
|
|
@@ -198,9 +198,9 @@ function argumentsFrom(argv, { allowMissingToken = false } = {}) {
|
|
|
198
198
|
` --dry-run inventory native sessions without uploading\n` +
|
|
199
199
|
` --runs PATH capture root (default: ${DEFAULT_RUNS})\n` +
|
|
200
200
|
` --only TEXT upload paths containing TEXT\n` +
|
|
201
|
-
` --directory PATH upload
|
|
201
|
+
` --directory PATH upload/launch only inside directory prefix PATH (repeatable)\n` +
|
|
202
|
+
` --only-directory PATH upload only sessions whose working directory is exactly PATH\n` +
|
|
202
203
|
` --all upload sessions across all working directories\n` +
|
|
203
|
-
` --allow-directory P upload/launch only inside directory prefix P (repeatable)\n` +
|
|
204
204
|
` --dev-user LOGIN authenticate as a local Wrangler DEV_USER uploader\n` +
|
|
205
205
|
` --url WS_URL websocket endpoint\n` +
|
|
206
206
|
` --token TOKEN internal/test bearer token override\n` +
|
|
@@ -214,10 +214,10 @@ function argumentsFrom(argv, { allowMissingToken = false } = {}) {
|
|
|
214
214
|
if (options.rewriteSession && !options.native) {
|
|
215
215
|
throw new Error('--rewrite-session requires --native.');
|
|
216
216
|
}
|
|
217
|
-
if (options.
|
|
218
|
-
throw new Error('--directory cannot be combined with --all.');
|
|
219
|
-
if (options.allowedDirectories.length && (options.
|
|
220
|
-
throw new Error('--
|
|
217
|
+
if (options.onlyDirectory && options.all)
|
|
218
|
+
throw new Error('--only-directory cannot be combined with --all.');
|
|
219
|
+
if (options.allowedDirectories.length && (options.onlyDirectory || options.all)) {
|
|
220
|
+
throw new Error('--directory cannot be combined with --only-directory or --all.');
|
|
221
221
|
}
|
|
222
222
|
options.allowedDirectories = normalizeAllowedDirectories(options.allowedDirectories);
|
|
223
223
|
if (!options.dryRun && !options.token && !options.devUser && !allowMissingToken) {
|
|
@@ -1245,7 +1245,7 @@ export function watchNativeSessionDirectories({ roots, shallowRoots = [], files
|
|
|
1245
1245
|
};
|
|
1246
1246
|
}
|
|
1247
1247
|
const nativeWorkingDirectoryCache = new Map();
|
|
1248
|
-
export async function
|
|
1248
|
+
export async function filterNativeSessionsByOnlyDirectory(sessions, directory, resolveWorkingDirectory = nativeDescriptorWorkingDirectory) {
|
|
1249
1249
|
if (!directory)
|
|
1250
1250
|
return sessions;
|
|
1251
1251
|
const selected = path.resolve(directory);
|
|
@@ -1263,7 +1263,7 @@ export async function filterNativeSessionsByDirectory(sessions, directory, resol
|
|
|
1263
1263
|
}
|
|
1264
1264
|
return scoped;
|
|
1265
1265
|
}
|
|
1266
|
-
export async function
|
|
1266
|
+
export async function filterNativeSessionsByDirectories(sessions, allowedDirectories, resolveWorkingDirectory = nativeDescriptorWorkingDirectory) {
|
|
1267
1267
|
if (!allowedDirectories?.length)
|
|
1268
1268
|
return sessions;
|
|
1269
1269
|
const scoped = [];
|
|
@@ -1280,10 +1280,18 @@ export async function filterNativeSessionsByAllowedDirectories(sessions, allowed
|
|
|
1280
1280
|
}
|
|
1281
1281
|
return scoped;
|
|
1282
1282
|
}
|
|
1283
|
+
/** Whether this run's inventory speaks for the whole machine. A run narrowed by
|
|
1284
|
+
* `--only`, `--rewrite-session`, `--only-directory`, or `--directory` sees a
|
|
1285
|
+
* subset, so it must not prune upload cursors for the sessions it filtered out:
|
|
1286
|
+
* the next unscoped run would find no cursor and replay those transcripts from
|
|
1287
|
+
* their first byte. */
|
|
1288
|
+
export function inventoryIsAuthoritative(options) {
|
|
1289
|
+
return !(options.only || options.rewriteSession || options.onlyDirectory || options.allowedDirectories?.length);
|
|
1290
|
+
}
|
|
1283
1291
|
async function nativeInventory(options, machine) {
|
|
1284
1292
|
let sessions = linkNativeSessionParents(machine, discoverLocalSessions(options.rewriteSession ? '' : options.only));
|
|
1285
|
-
sessions = await
|
|
1286
|
-
sessions = await
|
|
1293
|
+
sessions = await filterNativeSessionsByOnlyDirectory(sessions, options.onlyDirectory);
|
|
1294
|
+
sessions = await filterNativeSessionsByDirectories(sessions, options.allowedDirectories);
|
|
1287
1295
|
if (!options.rewriteSession)
|
|
1288
1296
|
return sessions;
|
|
1289
1297
|
const exact = sessions.filter((descriptor) => stableNativeSessionId(machine, descriptor.harness, descriptor.sourcePath) === options.rewriteSession);
|
|
@@ -1399,6 +1407,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
|
|
|
1399
1407
|
const livePriority = new Set();
|
|
1400
1408
|
const tombstoned = new Set();
|
|
1401
1409
|
let nativeMonitor = null;
|
|
1410
|
+
const authoritativeInventory = inventoryIsAuthoritative(options);
|
|
1402
1411
|
const rememberInventory = (sessions, retainState = false, forceMetadataDirectories = new Set()) => {
|
|
1403
1412
|
const previousFingerprints = new Map(fingerprints);
|
|
1404
1413
|
descriptorBySession.clear();
|
|
@@ -1459,7 +1468,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
|
|
|
1459
1468
|
redactionStarted.add(sessionId);
|
|
1460
1469
|
}
|
|
1461
1470
|
}
|
|
1462
|
-
if (retainState &&
|
|
1471
|
+
if (retainState && authoritativeInventory)
|
|
1463
1472
|
state.retain(descriptorBySession.keys());
|
|
1464
1473
|
descriptorRegistry.sessions = sessions;
|
|
1465
1474
|
nativeMonitor?.addFiles(sessions.map((descriptor) => descriptor.file));
|
|
@@ -1725,7 +1734,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
|
|
|
1725
1734
|
rememberInventory(sessions);
|
|
1726
1735
|
const unsettled = changedSessions();
|
|
1727
1736
|
if (unsettled.length) {
|
|
1728
|
-
process.stderr.write(`${unsettled.length} native sessions kept changing;
|
|
1737
|
+
process.stderr.write(`${unsettled.length} native sessions kept changing; run \`turns\` without --once to follow them continuously.\n`);
|
|
1729
1738
|
}
|
|
1730
1739
|
break;
|
|
1731
1740
|
}
|