@turns/dev 0.0.2 → 0.0.4
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 +1 -0
- package/dist/agent-anchor.js +189 -3
- package/dist/file-browsing.js +71 -90
- package/dist/harness-registry.js +20 -1
- package/dist/letta-session-context.js +70 -0
- package/dist/local-session-sources.js +54 -1
- package/dist/path-resolution.js +2 -1
- package/dist/queue-worker.js +10 -3
- package/dist/upload-sessions.js +16 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@ invocations and daemon starts. On Linux the other persistent files are:
|
|
|
44
44
|
|
|
45
45
|
- `~/.config/turns/machine-id`
|
|
46
46
|
- `~/.local/state/turns/native-upload-state.json`
|
|
47
|
+
- `~/.local/state/turns/letta-sessions/*.json` (Letta conversation-to-project associations)
|
|
47
48
|
- `~/.config/systemd/user/turns.service`
|
|
48
49
|
|
|
49
50
|
The credential file contains a seven-day access token and a signed refresh
|
package/dist/agent-anchor.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import net from 'node:net';
|
|
5
|
+
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import readline from 'node:readline';
|
|
7
8
|
import { DatabaseSync } from 'node:sqlite';
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
9
10
|
import { WebSocket } from 'ws';
|
|
10
|
-
import { ACP_PROFILES, PROCESS_HARNESSES, processEventSessionId, processInvocation, RPC_PROFILES, resolveHarnessCommand, rpcFrameSessionIdentity, rpcUiRequestIsInformational, } from "./harness-registry.js";
|
|
11
|
+
import { ACP_PROFILES, lettaSessionIdentity, PROCESS_HARNESSES, parseLettaSessionIdentity, processEventSessionId, processInvocation, RPC_PROFILES, resolveHarnessCommand, rpcFrameSessionIdentity, rpcUiRequestIsInformational, } from "./harness-registry.js";
|
|
12
|
+
import { recordLettaSessionContext } from "./letta-session-context.js";
|
|
11
13
|
const socketIndex = process.argv.indexOf('--socket');
|
|
12
14
|
const socketPath = socketIndex >= 0 ? path.resolve(process.argv[socketIndex + 1] || '') : '';
|
|
13
15
|
if (!socketPath)
|
|
@@ -333,6 +335,165 @@ class ClaudeTransport extends LineTransport {
|
|
|
333
335
|
}
|
|
334
336
|
}
|
|
335
337
|
}
|
|
338
|
+
class LettaTransport extends LineTransport {
|
|
339
|
+
conversationId;
|
|
340
|
+
agentId;
|
|
341
|
+
permissionMode;
|
|
342
|
+
turnWaiter;
|
|
343
|
+
control;
|
|
344
|
+
initWaiter;
|
|
345
|
+
constructor(anchor, createSession) {
|
|
346
|
+
const existing = parseLettaSessionIdentity(anchor.agentSessionId);
|
|
347
|
+
const nativePermissionMode = anchor.permissionMode === 'bypassPermissions'
|
|
348
|
+
? 'unrestricted'
|
|
349
|
+
: anchor.permissionMode === 'acceptEdits'
|
|
350
|
+
? 'acceptEdits'
|
|
351
|
+
: 'standard';
|
|
352
|
+
const args = [
|
|
353
|
+
'-p',
|
|
354
|
+
'--input-format',
|
|
355
|
+
'stream-json',
|
|
356
|
+
'--output-format',
|
|
357
|
+
'stream-json',
|
|
358
|
+
'--include-partial-messages',
|
|
359
|
+
'--permission-mode',
|
|
360
|
+
nativePermissionMode,
|
|
361
|
+
'--reflection-trigger',
|
|
362
|
+
'off',
|
|
363
|
+
...(anchor.model && anchor.model !== 'auto' ? ['--model', anchor.model] : []),
|
|
364
|
+
...(createSession ? ['--new'] : ['--conversation', existing.conversationId]),
|
|
365
|
+
];
|
|
366
|
+
super(anchor, 'letta', resolveHarnessCommand('letta', { command: 'letta' }), args, anchor.cwd);
|
|
367
|
+
this.conversationId = createSession ? '' : existing.conversationId;
|
|
368
|
+
this.agentId = createSession ? '' : existing.agentId;
|
|
369
|
+
this.permissionMode = anchor.permissionMode;
|
|
370
|
+
this.turnWaiter = null;
|
|
371
|
+
this.control = new Map();
|
|
372
|
+
this.initWaiter = deferred();
|
|
373
|
+
}
|
|
374
|
+
onFrame(frame) {
|
|
375
|
+
if (!frame || typeof frame !== 'object')
|
|
376
|
+
return;
|
|
377
|
+
if (frame.type === 'system' && frame.subtype === 'init') {
|
|
378
|
+
this.agentId = String(frame.agent_id || frame.session_id || '');
|
|
379
|
+
this.conversationId = String(frame.conversation_id || '');
|
|
380
|
+
if (this.conversationId) {
|
|
381
|
+
const identity = this.agentId ? lettaSessionIdentity(this.agentId, this.conversationId) : this.conversationId;
|
|
382
|
+
const transcriptPath = this.agentId
|
|
383
|
+
? path.join(os.homedir(), '.letta', 'transcripts', this.agentId, this.conversationId, 'transcript.jsonl')
|
|
384
|
+
: '';
|
|
385
|
+
if (this.agentId && transcriptPath) {
|
|
386
|
+
recordLettaSessionContext({
|
|
387
|
+
agentId: this.agentId,
|
|
388
|
+
conversationId: this.conversationId,
|
|
389
|
+
workingDirectory: this.anchor.cwd,
|
|
390
|
+
transcriptPath,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
if (identity !== this.anchor.agentSessionId ||
|
|
394
|
+
(transcriptPath && path.resolve(transcriptPath) !== this.anchor.transcriptPath)) {
|
|
395
|
+
this.anchor.updateIdentity(identity, transcriptPath);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
this.initWaiter.resolve(frame);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (frame.type === 'result' && this.turnWaiter) {
|
|
402
|
+
const waiter = this.turnWaiter;
|
|
403
|
+
this.turnWaiter = null;
|
|
404
|
+
frame.subtype === 'error' || frame.stop_reason === 'error'
|
|
405
|
+
? waiter.reject(new Error(frame.result || 'Letta turn failed.'))
|
|
406
|
+
: waiter.resolve(frame);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (frame.type === 'control_response') {
|
|
410
|
+
const requestId = frame.response?.request_id;
|
|
411
|
+
const waiter = this.control.get(requestId);
|
|
412
|
+
if (!waiter)
|
|
413
|
+
return;
|
|
414
|
+
this.control.delete(requestId);
|
|
415
|
+
frame.response?.subtype === 'error'
|
|
416
|
+
? waiter.reject(new Error(frame.response.error || 'Letta control request failed.'))
|
|
417
|
+
: waiter.resolve(frame.response?.response ?? frame.response);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (frame.type === 'control_request')
|
|
421
|
+
void this.answerReverse(frame);
|
|
422
|
+
}
|
|
423
|
+
onClose(error) {
|
|
424
|
+
this.initWaiter.reject(error);
|
|
425
|
+
if (this.turnWaiter) {
|
|
426
|
+
this.turnWaiter.reject(error);
|
|
427
|
+
this.turnWaiter = null;
|
|
428
|
+
}
|
|
429
|
+
for (const waiter of this.control.values())
|
|
430
|
+
waiter.reject(error);
|
|
431
|
+
this.control.clear();
|
|
432
|
+
}
|
|
433
|
+
async open(timeoutMs = 90_000) {
|
|
434
|
+
const timer = setTimeout(() => this.initWaiter.reject(new Error('Letta init timed out.')), timeoutMs);
|
|
435
|
+
timer.unref?.();
|
|
436
|
+
try {
|
|
437
|
+
await this.initWaiter.promise;
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
clearTimeout(timer);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
controlRequest(subtype, extra = {}, timeoutMs = 30_000) {
|
|
444
|
+
const requestId = `turns_letta_${subtype}_${crypto.randomUUID()}`;
|
|
445
|
+
const waiter = deferred();
|
|
446
|
+
this.control.set(requestId, waiter);
|
|
447
|
+
this.emit({ type: 'control_request', request_id: requestId, request: { subtype, ...extra } });
|
|
448
|
+
const timer = setTimeout(() => {
|
|
449
|
+
if (this.control.delete(requestId))
|
|
450
|
+
waiter.reject(new Error(`Letta control request timed out: ${subtype}`));
|
|
451
|
+
}, timeoutMs);
|
|
452
|
+
timer.unref?.();
|
|
453
|
+
return waiter.promise.finally(() => clearTimeout(timer));
|
|
454
|
+
}
|
|
455
|
+
async answerReverse(frame) {
|
|
456
|
+
let response;
|
|
457
|
+
try {
|
|
458
|
+
response = await this.anchor.askInteraction(frame);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
this.emit({ type: 'control_response', response: { subtype: 'success', request_id: frame.request_id, response } });
|
|
464
|
+
}
|
|
465
|
+
prompt(text, timeoutMs) {
|
|
466
|
+
if (this.turnWaiter)
|
|
467
|
+
throw new Error('Letta already has an active turn.');
|
|
468
|
+
const waiter = deferred();
|
|
469
|
+
this.turnWaiter = waiter;
|
|
470
|
+
this.emit({ type: 'user', message: { role: 'user', content: text } });
|
|
471
|
+
const timer = setTimeout(() => {
|
|
472
|
+
if (this.turnWaiter === waiter)
|
|
473
|
+
this.turnWaiter = null;
|
|
474
|
+
waiter.reject(new Error('Letta turn timed out.'));
|
|
475
|
+
}, timeoutMs);
|
|
476
|
+
timer.unref?.();
|
|
477
|
+
return waiter.promise.finally(() => clearTimeout(timer));
|
|
478
|
+
}
|
|
479
|
+
async interrupt() {
|
|
480
|
+
try {
|
|
481
|
+
return await this.controlRequest('interrupt');
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
try {
|
|
485
|
+
process.kill(-this.child.pid, 'SIGINT');
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
try {
|
|
489
|
+
this.child.kill('SIGINT');
|
|
490
|
+
}
|
|
491
|
+
catch { }
|
|
492
|
+
}
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
336
497
|
class CodexAppServerTransport extends LineTransport {
|
|
337
498
|
threadId;
|
|
338
499
|
permissionMode;
|
|
@@ -682,8 +843,13 @@ class AcpTransport extends LineTransport {
|
|
|
682
843
|
await this.request('session/set_mode', { sessionId: this.sessionId, modeId: this.profile.mode }, 30_000);
|
|
683
844
|
}
|
|
684
845
|
const model = this.anchor.model && this.anchor.model !== 'auto' ? this.anchor.model : this.profile.model;
|
|
685
|
-
if (model)
|
|
686
|
-
|
|
846
|
+
if (model) {
|
|
847
|
+
const method = this.profile.modelOption ? 'session/set_config_option' : 'session/set_model';
|
|
848
|
+
const params = this.profile.modelOption
|
|
849
|
+
? { sessionId: this.sessionId, configId: 'model', value: model }
|
|
850
|
+
: { sessionId: this.sessionId, modelId: model };
|
|
851
|
+
await this.request(method, params, 30_000).catch(() => { });
|
|
852
|
+
}
|
|
687
853
|
this.ready = true;
|
|
688
854
|
}
|
|
689
855
|
prompt(text, timeoutMs) {
|
|
@@ -1375,6 +1541,26 @@ class Anchor {
|
|
|
1375
1541
|
stopReason: result?.method || 'turn/completed',
|
|
1376
1542
|
};
|
|
1377
1543
|
}
|
|
1544
|
+
if (this.source === 'letta') {
|
|
1545
|
+
if (!(this.transport instanceof LettaTransport) ||
|
|
1546
|
+
this.transport.child.exitCode !== null ||
|
|
1547
|
+
lettaSessionIdentity(this.transport.agentId, this.transport.conversationId) !== this.agentSessionId ||
|
|
1548
|
+
this.transport.permissionMode !== this.permissionMode) {
|
|
1549
|
+
this.transport?.stop?.();
|
|
1550
|
+
this.transport = new LettaTransport(this, createSession);
|
|
1551
|
+
await this.transport.open();
|
|
1552
|
+
}
|
|
1553
|
+
if (interruptedBeforePrompt())
|
|
1554
|
+
return interruptedBeforePrompt();
|
|
1555
|
+
const result = await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
|
|
1556
|
+
return {
|
|
1557
|
+
exitCode: 0,
|
|
1558
|
+
signal: null,
|
|
1559
|
+
timedOut: false,
|
|
1560
|
+
error: null,
|
|
1561
|
+
stopReason: result?.stop_reason || result?.subtype || 'completed',
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1378
1564
|
if (PROCESS_HARNESSES.has(this.source)) {
|
|
1379
1565
|
const invocation = processInvocation({
|
|
1380
1566
|
harness: this.source,
|
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,11 +144,30 @@ 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
|
|
150
161
|
// accidentally fall back to one-shot CLI invocations.
|
|
151
|
-
export const RESIDENT_HARNESSES = new Set(['claude', 'codex']);
|
|
162
|
+
export const RESIDENT_HARNESSES = new Set(['claude', 'codex', 'letta']);
|
|
163
|
+
/** Letta's agent + conversation pair identifies one exact transcript. */
|
|
164
|
+
export function lettaSessionIdentity(agentId, conversationId) {
|
|
165
|
+
return `letta:${String(agentId)}:${String(conversationId)}`;
|
|
166
|
+
}
|
|
167
|
+
export function parseLettaSessionIdentity(value) {
|
|
168
|
+
const match = /^letta:(agent-(?:local-)?[0-9a-f-]+):(.+)$/iu.exec(String(value || ''));
|
|
169
|
+
return match ? { agentId: match[1], conversationId: match[2] } : { agentId: '', conversationId: String(value || '') };
|
|
170
|
+
}
|
|
152
171
|
export const RPC_PROFILES = {
|
|
153
172
|
// Prime selects an existing session only at process launch. The transcript
|
|
154
173
|
// path is authoritative (its header id can differ from its filename id), so
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { applicationPaths } from "./auth.js";
|
|
5
|
+
import { lettaSessionIdentity } from "./harness-registry.js";
|
|
6
|
+
function contextDirectory(stateDirectory = applicationPaths().stateDirectory) {
|
|
7
|
+
return path.join(stateDirectory, 'letta-sessions');
|
|
8
|
+
}
|
|
9
|
+
function contextFile(identity, stateDirectory) {
|
|
10
|
+
const digest = crypto.createHash('sha256').update(identity).digest('hex');
|
|
11
|
+
return path.join(contextDirectory(stateDirectory), `${digest}.json`);
|
|
12
|
+
}
|
|
13
|
+
/** Letta transcripts identify an agent and conversation but do not persist the
|
|
14
|
+
* cwd. Keep Turns-owned launch context outside Letta's data directory so a
|
|
15
|
+
* later daemon scan can safely resume the conversation in the same project. */
|
|
16
|
+
export function recordLettaSessionContext(context, stateDirectory) {
|
|
17
|
+
if (!context.agentId ||
|
|
18
|
+
!context.conversationId ||
|
|
19
|
+
!path.isAbsolute(context.workingDirectory) ||
|
|
20
|
+
!path.isAbsolute(context.transcriptPath))
|
|
21
|
+
return;
|
|
22
|
+
const identity = lettaSessionIdentity(context.agentId, context.conversationId);
|
|
23
|
+
const directory = contextDirectory(stateDirectory);
|
|
24
|
+
const file = contextFile(identity, stateDirectory);
|
|
25
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
26
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
27
|
+
try {
|
|
28
|
+
fs.writeFileSync(temporary, `${JSON.stringify({ identity, ...context })}\n`, { mode: 0o600 });
|
|
29
|
+
try {
|
|
30
|
+
fs.renameSync(temporary, file);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (process.platform !== 'win32')
|
|
34
|
+
throw error;
|
|
35
|
+
try {
|
|
36
|
+
fs.unlinkSync(file);
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
39
|
+
fs.renameSync(temporary, file);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
try {
|
|
44
|
+
fs.unlinkSync(temporary);
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function readLettaSessionContext(identity, stateDirectory) {
|
|
50
|
+
try {
|
|
51
|
+
const value = JSON.parse(fs.readFileSync(contextFile(identity, stateDirectory), 'utf8'));
|
|
52
|
+
if (value?.identity !== identity ||
|
|
53
|
+
!value.agentId ||
|
|
54
|
+
!value.conversationId ||
|
|
55
|
+
!path.isAbsolute(value.workingDirectory || '') ||
|
|
56
|
+
!path.isAbsolute(value.transcriptPath || ''))
|
|
57
|
+
return null;
|
|
58
|
+
if (lettaSessionIdentity(value.agentId, value.conversationId) !== identity)
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
agentId: String(value.agentId),
|
|
62
|
+
conversationId: String(value.conversationId),
|
|
63
|
+
workingDirectory: String(value.workingDirectory),
|
|
64
|
+
transcriptPath: String(value.transcriptPath),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -4,6 +4,8 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { DatabaseSync } from 'node:sqlite';
|
|
6
6
|
import { readDeepseekTrajectoryHeader } from "./deepseek-trajectories.js";
|
|
7
|
+
import { lettaSessionIdentity } from "./harness-registry.js";
|
|
8
|
+
import { readLettaSessionContext } from "./letta-session-context.js";
|
|
7
9
|
const HOME = os.homedir();
|
|
8
10
|
const PROJECTION_ROOT = path.join(HOME, '.local', 'state', 'turns-dev', 'session-projections');
|
|
9
11
|
const DEEPSEEK_SESSIONS_ROOT = path.join(path.resolve(process.env.DSH_HOME || path.join(HOME, '.dsh')), 'sessions');
|
|
@@ -87,6 +89,14 @@ const EXTRA_FILE_STORES = {
|
|
|
87
89
|
root: path.join(HOME, '.local', 'state', 'poolside', 'trajectories'),
|
|
88
90
|
include: (file) => /^trajectory-[^/]+\.ndjson$/i.test(path.basename(file)),
|
|
89
91
|
},
|
|
92
|
+
fx: {
|
|
93
|
+
root: path.join(HOME, '.fx', 'sessions'),
|
|
94
|
+
include: (file) => path.basename(file) === 'events.jsonl',
|
|
95
|
+
},
|
|
96
|
+
letta: {
|
|
97
|
+
root: path.join(HOME, '.letta', 'transcripts'),
|
|
98
|
+
include: (file) => path.basename(file) === 'transcript.jsonl',
|
|
99
|
+
},
|
|
90
100
|
};
|
|
91
101
|
function safeStat(file) {
|
|
92
102
|
try {
|
|
@@ -248,6 +258,8 @@ function nativeSessionIdFromPath(harness, sourcePath) {
|
|
|
248
258
|
qwen: /\/chats\/([^/]+)\.jsonl$/iu,
|
|
249
259
|
cline: /\/([^/]+)\.messages\.json$/iu,
|
|
250
260
|
poolside: /trajectory-(?:standalone_)?([0-9a-f-]{20,})\.ndjson$/iu,
|
|
261
|
+
fx: /\/sessions\/([^/]+)\/events\.jsonl$/iu,
|
|
262
|
+
letta: /\/transcripts\/agent-(?:local-)?[0-9a-f-]+\/([^/]+)\/transcript\.jsonl$/iu,
|
|
251
263
|
};
|
|
252
264
|
return patterns[harness]?.exec(normalized)?.[1] || '';
|
|
253
265
|
}
|
|
@@ -269,6 +281,28 @@ function fileDescriptor(harness, file) {
|
|
|
269
281
|
createdAt: (stat.birthtimeMs > 0 ? stat.birthtime : stat.mtime).toISOString(),
|
|
270
282
|
};
|
|
271
283
|
}
|
|
284
|
+
/** Fx's ledger path identifies the session; its adjacent projections provide
|
|
285
|
+
* the workspace and display title without requiring a conversation scan. */
|
|
286
|
+
function fxDescriptor(file) {
|
|
287
|
+
const descriptor = fileDescriptor('fx', file);
|
|
288
|
+
if (!descriptor)
|
|
289
|
+
return null;
|
|
290
|
+
const directory = path.dirname(file);
|
|
291
|
+
const session = jsonFile(path.join(directory, 'session.json'));
|
|
292
|
+
const display = jsonFile(path.join(directory, 'display.json'));
|
|
293
|
+
const createdAtMs = Number(session?.created_at_ms);
|
|
294
|
+
const createdAt = Number.isFinite(createdAtMs) ? new Date(createdAtMs).toISOString() : descriptor.createdAt;
|
|
295
|
+
const title = typeof display?.title === 'string' ? display.title.trim() : '';
|
|
296
|
+
return {
|
|
297
|
+
...descriptor,
|
|
298
|
+
createdAt,
|
|
299
|
+
nativeSessionId: String(session?.id || path.basename(directory)).trim(),
|
|
300
|
+
...(absolutePathCandidate([session?.workspace_root, session?.origin_workspace_root])
|
|
301
|
+
? { workingDirectory: absolutePathCandidate([session?.workspace_root, session?.origin_workspace_root]) }
|
|
302
|
+
: {}),
|
|
303
|
+
...(title ? { nativeTitle: title } : {}),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
272
306
|
/** Discover top-level and subagent DeepSeek trajectories with header metadata. */
|
|
273
307
|
export function deepseekTrajectoryDescriptors(root = DEEPSEEK_SESSIONS_ROOT) {
|
|
274
308
|
const descriptors = [];
|
|
@@ -1070,6 +1104,17 @@ export function nativeDescriptorWorkingDirectory(descriptor) {
|
|
|
1070
1104
|
cursor: cursorMetadataWorkingDirectory,
|
|
1071
1105
|
gemini: geminiMetadataWorkingDirectory,
|
|
1072
1106
|
kimi: kimiMetadataWorkingDirectory,
|
|
1107
|
+
letta: (candidate) => {
|
|
1108
|
+
const normalized = String(candidate.sourcePath || candidate.file || '').replaceAll('\\', '/');
|
|
1109
|
+
const match = /\/transcripts\/(agent-(?:local-)?[0-9a-f-]+)\/([^/]+)\/transcript\.jsonl$/iu.exec(normalized);
|
|
1110
|
+
if (!match)
|
|
1111
|
+
return '';
|
|
1112
|
+
return readLettaSessionContext(lettaSessionIdentity(match[1], match[2]))?.workingDirectory || '';
|
|
1113
|
+
},
|
|
1114
|
+
fx: (candidate) => absolutePathCandidate([
|
|
1115
|
+
sidecarJson(candidate, 'session.json')?.workspace_root,
|
|
1116
|
+
sidecarJson(candidate, 'session.json')?.origin_workspace_root,
|
|
1117
|
+
]),
|
|
1073
1118
|
};
|
|
1074
1119
|
const metadataCandidate = metadataResolvers[descriptor.harness]?.(descriptor) || '';
|
|
1075
1120
|
if (metadataCandidate)
|
|
@@ -1145,6 +1190,12 @@ export async function nativeDescriptorAgentSessionId(descriptor) {
|
|
|
1145
1190
|
// into work whose lifecycle and ownership belong to its parent agent.
|
|
1146
1191
|
if (subagent)
|
|
1147
1192
|
return '';
|
|
1193
|
+
if (descriptor?.harness === 'letta') {
|
|
1194
|
+
const normalized = String(descriptor?.sourcePath || descriptor?.file || '').replaceAll('\\', '/');
|
|
1195
|
+
const match = /\/transcripts\/(agent-(?:local-)?[0-9a-f-]+)\/([^/]+)\/transcript\.jsonl$/iu.exec(normalized);
|
|
1196
|
+
if (match)
|
|
1197
|
+
return lettaSessionIdentity(match[1], match[2]);
|
|
1198
|
+
}
|
|
1148
1199
|
const structural = descriptorNativeSessionId(descriptor);
|
|
1149
1200
|
if (structural) {
|
|
1150
1201
|
// Kimi persists its ACP key as `session_<uuid>`. The path parser used to
|
|
@@ -1278,6 +1329,7 @@ const HARNESS_PROCESS_NAMES = {
|
|
|
1278
1329
|
hermes: ['hermes'],
|
|
1279
1330
|
kilo: ['kilo'],
|
|
1280
1331
|
kimi: ['kimi', 'kimi-code'],
|
|
1332
|
+
letta: ['letta'],
|
|
1281
1333
|
muse: ['muse'],
|
|
1282
1334
|
omp: ['omp'],
|
|
1283
1335
|
openclaw: ['openclaw'],
|
|
@@ -1287,6 +1339,7 @@ const HARNESS_PROCESS_NAMES = {
|
|
|
1287
1339
|
prime: ['prime', 'prime-agent'],
|
|
1288
1340
|
qwen: ['qwen'],
|
|
1289
1341
|
vibe: ['vibe', 'vibe-acp'],
|
|
1342
|
+
fx: ['fx'],
|
|
1290
1343
|
};
|
|
1291
1344
|
const SCRIPT_RUNTIMES = new Set(['node', 'nodejs', 'bun', 'deno', 'python', 'python3']);
|
|
1292
1345
|
function executableStem(value) {
|
|
@@ -1392,7 +1445,7 @@ function discoverLocalSessionInventory(only = '') {
|
|
|
1392
1445
|
walk(store.root, (file) => {
|
|
1393
1446
|
if (!store.include(file))
|
|
1394
1447
|
return;
|
|
1395
|
-
const descriptor = fileDescriptor(harness, file);
|
|
1448
|
+
const descriptor = harness === 'fx' ? fxDescriptor(file) : fileDescriptor(harness, file);
|
|
1396
1449
|
if (descriptor)
|
|
1397
1450
|
descriptors.push(descriptor);
|
|
1398
1451
|
});
|
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) {
|
|
@@ -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) {
|