@diffin/cli 0.0.0-stage → 0.1.1
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/LICENSE +22 -0
- package/README.md +82 -2
- package/bin/diffin.mjs +69 -0
- package/lib/agents.mjs +56 -0
- package/lib/backfill.mjs +334 -0
- package/lib/capture.mjs +443 -0
- package/lib/command.mjs +44 -0
- package/lib/creds.mjs +35 -0
- package/lib/folders.mjs +76 -0
- package/lib/ignore.mjs +208 -0
- package/lib/init.mjs +114 -0
- package/lib/journey.mjs +269 -0
- package/lib/login.mjs +25 -0
- package/lib/machine.mjs +45 -0
- package/lib/net.mjs +87 -0
- package/lib/onboard.mjs +74 -0
- package/lib/scrub.mjs +513 -0
- package/lib/settings.mjs +46 -0
- package/lib/sources/antigravity.mjs +178 -0
- package/lib/sources/claude-code.mjs +107 -0
- package/lib/sources/codex.mjs +230 -0
- package/lib/sources/hermes.mjs +170 -0
- package/lib/sources/index.mjs +29 -0
- package/lib/sources/project-dir.mjs +13 -0
- package/lib/state.mjs +113 -0
- package/lib/status.mjs +71 -0
- package/lib/stream.mjs +67 -0
- package/lib/ui.mjs +220 -0
- package/lib/watch.mjs +164 -0
- package/package.json +21 -4
- package/templates/skills/capture/SKILL.md +107 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
diffin CLI
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 diffin. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software is proprietary. It is distributed so that people who use the diffin
|
|
6
|
+
service can install and run it on their own machines, and so that they can read
|
|
7
|
+
exactly what it does there.
|
|
8
|
+
|
|
9
|
+
You may:
|
|
10
|
+
- install and run unmodified copies of this software to use the diffin service;
|
|
11
|
+
- read the source code to audit what the software reads and sends.
|
|
12
|
+
|
|
13
|
+
You may not, without prior written permission:
|
|
14
|
+
- redistribute, sublicense or sell this software or modified versions of it;
|
|
15
|
+
- use it, or any part of it, to build or operate a competing service.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
18
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
19
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
20
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
|
|
21
|
+
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
|
|
22
|
+
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,83 @@
|
|
|
1
|
-
#
|
|
1
|
+
# diffin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
AI 에이전트와 한 작업을 diffin 에 기록하는 명령줄 도구입니다. 이 기기의 에이전트 대화 기록을 읽어, 가릴 것을 가린 뒤 diffin 서버로 보냅니다. 무엇이 작업이었는지는 서버가 정리합니다.
|
|
4
|
+
|
|
5
|
+
의존성이 없습니다. `bin/` 과 `lib/` 의 코드가 이 기기에서 도는 전부이고, 그대로 읽어 볼 수 있습니다.
|
|
6
|
+
|
|
7
|
+
## 시작하기
|
|
8
|
+
|
|
9
|
+
Node.js 20 이상이 필요합니다. diffin 앱의 연결 화면에 나오는 한 줄을 그대로 실행하세요.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
npx @diffin/cli onboard <설치토큰>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
로그인하고, 캡처 스킬을 설치하고, 첫 백필을 올린 뒤 서버가 정리를 마칠 때까지 기다립니다. 설치 토큰은 15분 동안 한 번만 쓸 수 있습니다.
|
|
16
|
+
|
|
17
|
+
## 명령
|
|
18
|
+
|
|
19
|
+
| 명령 | 하는 일 |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `diffin onboard [설치토큰]` | 로그인 → 스킬 설치 → 지난 작업 올리기 (`--project`, `--exclude`, `--yes`, `--no-wait`). 이미 로그인된 기기에서는 토큰 없이 다시 실행해 이어서 올립니다 |
|
|
22
|
+
| `diffin login <설치토큰>` | 로그인만 |
|
|
23
|
+
| `diffin init` | 캡처 스킬 설치·갱신 |
|
|
24
|
+
| `diffin capture` | 모든 세션의 새 작업 올리기 (`--this` 이번 세션만) |
|
|
25
|
+
| `diffin pause` · `diffin resume` | 기록 멈추기 · 다시 켜기 (멈춘 동안의 대화는 올라가지 않습니다) |
|
|
26
|
+
| `diffin exclude [폴더]` · `diffin include [폴더]` | 이 폴더와 그 아래를 기록하지 않기 · 다시 기록하기 |
|
|
27
|
+
| `diffin allow [폴더 \| . \| --all]` | 새 폴더를 기록하기 시작 (허용한 뒤의 작업부터 올라갑니다. 사람이 터미널에서 실행) |
|
|
28
|
+
| `diffin folders` | 기록하는 폴더 목록을 보고 고치기 (사람이 터미널에서 실행) |
|
|
29
|
+
| `diffin status` | 이 기기의 상태 보기 |
|
|
30
|
+
| `diffin --version` | 버전 |
|
|
31
|
+
|
|
32
|
+
## 기록은 어떻게 되나
|
|
33
|
+
|
|
34
|
+
- **백필**은 처음 한 번, 최근 작업을 한꺼번에 올립니다.
|
|
35
|
+
- **캡처**는 그 뒤의 새 작업을 올립니다. 작업이 일단락되면 에이전트가 턴을 마치며 `diffin capture` 를 실행합니다. 하루에 몇 번 정도입니다. 확인 창은 없습니다. 설치할 때 동의했고, 아래 방법으로 언제든 범위를 줄이거나 멈출 수 있기 때문입니다.
|
|
36
|
+
- 캡처 사이에는 간격이 있습니다. 서버가 다음 가능 시각을 알려 주고 이 기기가 그것을 기억합니다. 그 전에 실행하면 아무것도 보내지 않고 그 시각만 말합니다. 그동안의 작업은 다음 캡처에 함께 올라가므로 잃는 것은 없습니다.
|
|
37
|
+
- 언제 기록할지는 에이전트가 캡처 스킬의 지침만 보고 판단합니다. 에이전트의 설정에 훅을 심지 않습니다.
|
|
38
|
+
|
|
39
|
+
지원하는 에이전트: Claude Code, Codex, Antigravity, Hermes. 스킬은 Cursor · Gemini CLI 에도 설치되지만, 그 세션의 캡처는 아직 지원하지 않습니다.
|
|
40
|
+
|
|
41
|
+
## 이 기기 밖으로 나가는 것
|
|
42
|
+
|
|
43
|
+
보내는 것:
|
|
44
|
+
|
|
45
|
+
- 내가 한 말과 에이전트가 한 말
|
|
46
|
+
- 도구 호출의 이름과, 파일 경로 · 명령의 앞 130자 · URL · 검색어
|
|
47
|
+
- 셸 명령이나 그 안의 스크립트가 쓴 파일의 경로 (경로만, 내용은 아님)
|
|
48
|
+
- 시각, 작업 폴더, 모델 이름과 토큰 수
|
|
49
|
+
|
|
50
|
+
보내지 않는 것:
|
|
51
|
+
|
|
52
|
+
- 도구 출력, 파일 본문, 이미지, 사고(thinking) 블록
|
|
53
|
+
- 위에 적지 않은 그 밖의 모든 항목 — 허용한 항목만 보내는 방식입니다
|
|
54
|
+
|
|
55
|
+
보내기 전에 가리는 것: API 키·토큰 같은 비밀값, 이메일 주소, 홈 폴더 경로와 이 기기의 사용자 이름.
|
|
56
|
+
|
|
57
|
+
Windows 에서도 같습니다. `C:\Users\<이름>` 은 `~` 로 가려지고(Git Bash 의 `/c/Users/<이름>`, WSL 의 `/mnt/c/Users/<이름>` 도), 폴더와 파일 경로는 `/` 로 통일해 보냅니다.
|
|
58
|
+
|
|
59
|
+
## 기록하지 않게 하기
|
|
60
|
+
|
|
61
|
+
- **처음 올릴 때 고르기**: 첫 연결은 작업한 폴더 목록을 먼저 보여 줍니다. 모두 선택돼 있고, `→` 로 하위 폴더까지 펼쳐 볼 수 있습니다. 올리지 않을 폴더를 해제하면 그 폴더와 그 아래에서 한 대화는 올라가지 않고, 그 뒤로도 기록하지 않습니다. `diffin exclude` / `diffin include` 로 나중에 바꿀 수 있습니다.
|
|
62
|
+
- **새 폴더는 허용해야 올라갑니다**: 처음에 켠 폴더와 그 아래만 기록합니다. 다른 폴더에서 한 작업은 이 기기에 그대로 두고, 캡처 결과에 「새 폴더 N곳은 아직 기록하지 않습니다」라고 알립니다. 터미널에서 `diffin allow <폴더>`(지금 폴더는 `diffin allow .`)나 `diffin folders` 로 허용하거나, 앱에서 허용할 수 있습니다. 허용한 시점 이후의 작업만 올라갑니다. 이 두 명령은 에이전트가 대신 실행할 수 없습니다.
|
|
63
|
+
- **폴더 제외**: `diffin exclude <폴더>` 를 실행하거나, 그 폴더에 `.diffin-ignore` 라는 빈 파일을 두면 그 폴더와 그 아래 모든 폴더의 세션을 올리지 않습니다. 캡처와 백필 모두 그렇습니다. 처음 연결하기 전에 두면 첫 백필에서도 빠집니다. 한 번만 빼려면 `--exclude <폴더 이름의 일부>` 를 쓸 수 있습니다. 이미 올라간 기록은 지워지지 않으니 앱에서 지우세요.
|
|
64
|
+
- **멈춤**: `diffin pause` 를 실행하면 `diffin resume` 을 실행할 때까지 기록하지 않습니다. 멈춘 동안 나눈 대화는 다시 켠 뒤에도 올라가지 않습니다.
|
|
65
|
+
- **이미 올라간 기록**: 앱에서 지울 수 있습니다.
|
|
66
|
+
|
|
67
|
+
## 이 기기에 만드는 것
|
|
68
|
+
|
|
69
|
+
| 위치 | 내용 |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `~/.diffin/credentials.json` | 연결 토큰 — 기록을 올리는 데만 쓸 수 있고 만료되지 않습니다. 끊으려면 이 파일을 지우거나 앱에서 모든 기기 로그아웃을 누르세요 (macOS·Linux 는 소유자만 읽는 권한, Windows 는 사용자 프로필 폴더의 기본 권한) |
|
|
72
|
+
| `~/.diffin/state.json` | 멈춘 구간, 마지막 기록 시각, 다음 기록이 가능한 시각 |
|
|
73
|
+
| `~/.claude/skills/capture/` 등 | 에이전트별 캡처 스킬 |
|
|
74
|
+
| `~/.claude/settings.json` | `diffin capture` 허용 한 줄 |
|
|
75
|
+
| `~/.codex/rules/default.rules` | `diffin capture` 허용 규칙 하나 (Codex 가 있을 때). Codex 는 자기가 실행하는 명령의 네트워크를 막아 두는데, 이 규칙이 있어야 기록을 올릴 수 있습니다. 파일의 다른 규칙은 건드리지 않습니다 |
|
|
76
|
+
|
|
77
|
+
## 문제가 생기면
|
|
78
|
+
|
|
79
|
+
`diffin status` 가 계정, 서버, 에이전트별 스킬 버전, 일시정지 여부, 다음 기록이 가능한 시각을 보여 줍니다. 오류의 자세한 내용은 `DIFFIN_DEBUG=1 diffin <명령>` 으로 볼 수 있습니다.
|
|
80
|
+
|
|
81
|
+
## 라이선스
|
|
82
|
+
|
|
83
|
+
[LICENSE](./LICENSE) 를 보세요.
|
package/bin/diffin.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env -S NODE_OPTIONS=--no-warnings node
|
|
2
|
+
// diffin — capture your AI work history. The only code that runs on a user's machine.
|
|
3
|
+
// Dependency-free by design: nothing here should ever need `npm install` to audit.
|
|
4
|
+
//
|
|
5
|
+
// The first line replaces NODE_OPTIONS for this process (2026-10-11). Node reads NODE_OPTIONS before any code runs, so
|
|
6
|
+
// a broken value in the person's shell — measured: cmux injects `--require=<tmp>/restore-node-options.cjs`, the OS cleans
|
|
7
|
+
// that temp file, and every node process dies with MODULE_NOT_FOUND — killed `diffin capture` before a line of it ran,
|
|
8
|
+
// and the agent saw only a stack trace. This CLI needs no option from the environment. `env -S` is on macOS and on
|
|
9
|
+
// Linux (coreutils 8.30+); npm's Windows shim reads the same line and sets the variable the same way.
|
|
10
|
+
|
|
11
|
+
const commands = {
|
|
12
|
+
// gone 2026-10-07: the first backfill is onboard's, and running onboard again picks it up where it stopped
|
|
13
|
+
backfill: () => import('../lib/ui.mjs').then(({ fail }) => { fail('이 명령은 없어졌습니다.', '지난 작업을 올리거나 이어서 올리려면 npx @diffin/cli onboard 를 실행하세요.'); process.exitCode = 1; }),
|
|
14
|
+
capture: () => import('../lib/capture.mjs').then((m) => m.capture(process.argv.slice(3))),
|
|
15
|
+
pause: () => import('../lib/capture.mjs').then((m) => m.pause()),
|
|
16
|
+
resume: () => import('../lib/capture.mjs').then((m) => m.resume()),
|
|
17
|
+
init: () => import('../lib/init.mjs').then((m) => m.init(process.argv.slice(3))),
|
|
18
|
+
login: () => import('../lib/login.mjs').then((m) => m.login(process.argv.slice(3))),
|
|
19
|
+
onboard: () => import('../lib/onboard.mjs').then((m) => m.onboard(process.argv.slice(3))),
|
|
20
|
+
status: () => import('../lib/status.mjs').then((m) => m.status()),
|
|
21
|
+
exclude: () => import('../lib/status.mjs').then((m) => m.exclude(process.argv.slice(3))),
|
|
22
|
+
include: () => import('../lib/status.mjs').then((m) => m.include(process.argv.slice(3))),
|
|
23
|
+
allow: () => import('../lib/folders.mjs').then((m) => m.allow(process.argv.slice(3))),
|
|
24
|
+
folders: () => import('../lib/folders.mjs').then((m) => m.folders()),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const cmd = process.argv[2];
|
|
28
|
+
// stdout, unlike everything else here: a version is what a script or a bug report reads
|
|
29
|
+
if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
|
|
30
|
+
const { version } = await import('../lib/status.mjs');
|
|
31
|
+
console.log(version());
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
const run = commands[cmd];
|
|
35
|
+
const help = !cmd || cmd === 'help' || cmd === '--help' || cmd === '-h';
|
|
36
|
+
if (!run) {
|
|
37
|
+
const { bold, dim, out } = await import('../lib/ui.mjs');
|
|
38
|
+
if (!help) out(`알 수 없는 명령: ${cmd}\n`);
|
|
39
|
+
out(`${bold('diffin')} — AI 와 한 작업을 기록합니다.\n`);
|
|
40
|
+
out('사용법');
|
|
41
|
+
out(` diffin onboard [설치토큰] 로그인 → 캡처 스킬 설치 → 지난 작업 올리기 ${dim('(다시 실행하면 이어서 올립니다)')}`);
|
|
42
|
+
out(` diffin login <설치토큰> 앱의 연결 화면에서 받은 토큰으로 로그인`);
|
|
43
|
+
out(` diffin init 캡처 스킬 설치 또는 갱신 ${dim('(onboard 가 이미 합니다)')}`);
|
|
44
|
+
out(dim(' --project <문자열> 이 폴더만'));
|
|
45
|
+
out(dim(' --exclude <문자열> 이 폴더 제외'));
|
|
46
|
+
out(dim(' --yes 올릴 폴더를 묻지 않고 전부'));
|
|
47
|
+
out(dim(' --no-wait 서버가 정리를 마칠 때까지 기다리지 않음'));
|
|
48
|
+
out(` diffin capture 이 기기의 모든 세션의 새 작업 올리기 ${dim('(작업이 일단락되면 에이전트가 실행)')}`);
|
|
49
|
+
out(` diffin capture --this 이번 세션의 새 작업만 올리기`);
|
|
50
|
+
out(dim(' --from <ISO> --to <ISO> 지난 구간을 다시 기록 (이미 기록된 발화는 중복되지 않음)'));
|
|
51
|
+
out(` diffin pause 기록 멈추기 ${dim('(멈춘 동안의 대화는 올라가지 않습니다)')}`);
|
|
52
|
+
out(` diffin resume 다시 기록하기`);
|
|
53
|
+
out(` diffin status 이 기기의 상태 보기 ${dim('(계정 · 스킬 · 일시정지 · 다음 기록 가능 시각)')}`);
|
|
54
|
+
out(` diffin --version 버전`);
|
|
55
|
+
out(` diffin exclude [폴더] 이 폴더와 그 아래는 기록하지 않기 ${dim('(기본: 지금 폴더)')}`);
|
|
56
|
+
out(` diffin include [폴더] 다시 기록하기`);
|
|
57
|
+
out(` diffin allow [폴더|--all] 새 폴더를 기록하기 ${dim('(기본: 지금 폴더 · 사람이 터미널에서 실행)')}`);
|
|
58
|
+
out(` diffin folders 기록하는 폴더 목록을 보고 고치기 ${dim('(사람이 터미널에서 실행)')}`);
|
|
59
|
+
out(dim(' 폴더에 .diffin-ignore 파일을 두어도 그 폴더와 그 아래는 기록하지 않습니다.'));
|
|
60
|
+
process.exit(help ? 0 : 1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await run();
|
|
65
|
+
} catch (e) {
|
|
66
|
+
const { fail } = await import('../lib/ui.mjs');
|
|
67
|
+
fail(e?.message ?? String(e), process.env.DIFFIN_DEBUG ? e?.stack : undefined);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
package/lib/agents.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Which agents live on this machine, where each one reads skills, and which one we
|
|
2
|
+
// are running under right now.
|
|
3
|
+
//
|
|
4
|
+
// The skill file is an open standard (agentskills.io); what differs per agent is the
|
|
5
|
+
// directory it reads and the transcript it writes. Install goes to every agent that is
|
|
6
|
+
// present; capture works only where an adapter can read the transcript.
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const HOME = () => process.env.DIFFIN_HOME ?? homedir();
|
|
12
|
+
|
|
13
|
+
// `source` is the key the server's coverage watermark and the capture run use;
|
|
14
|
+
// `capture` says an adapter can read this agent's transcript. Hermes keeps skills
|
|
15
|
+
// under <category>/<name>, so its target dir carries our category.
|
|
16
|
+
export const AGENTS = [
|
|
17
|
+
{ id: 'cc', name: 'Claude Code', home: '.claude', skills: '.claude/skills', capture: true, source: 'cc' },
|
|
18
|
+
{ id: 'hermes', name: 'Hermes', home: '.hermes', skills: '.hermes/skills/diffin', capture: true, source: 'hermes' },
|
|
19
|
+
{ id: 'codex', name: 'Codex', home: '.codex', skills: '.codex/skills', capture: true, source: 'codex' },
|
|
20
|
+
// Antigravity reads global skills from ~/.gemini/config/skills (its own word for it, 2026-10-04) — not ~/.gemini/skills,
|
|
21
|
+
// which is Gemini CLI's; the two share ~/.gemini and nothing else
|
|
22
|
+
{ id: 'antigravity', name: 'Antigravity', home: '.gemini/antigravity-cli', skills: '.gemini/config/skills', capture: true, source: 'antigravity' },
|
|
23
|
+
{ id: 'cursor', name: 'Cursor', home: '.cursor', skills: '.cursor/skills', capture: false },
|
|
24
|
+
{ id: 'gemini', name: 'Gemini CLI', home: '.gemini', skills: '.gemini/skills', capture: false },
|
|
25
|
+
];
|
|
26
|
+
/** the cross-agent alias Cursor and Gemini also read; used only if it already exists */
|
|
27
|
+
export const SHARED_SKILLS = '.agents/skills';
|
|
28
|
+
|
|
29
|
+
export function presentAgents() {
|
|
30
|
+
return AGENTS.filter((a) => existsSync(join(HOME(), a.home)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** every known agent, present on this machine or not — the matrix init shows */
|
|
34
|
+
export function agentMatrix() {
|
|
35
|
+
return AGENTS.map((a) => ({ agent: a, present: existsSync(join(HOME(), a.home)), skillDir: join(HOME(), a.skills) }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function skillTargets() {
|
|
39
|
+
const out = presentAgents().map((a) => ({ agent: a, dir: join(HOME(), a.skills) }));
|
|
40
|
+
if (existsSync(join(HOME(), SHARED_SKILLS))) out.push({ agent: { id: 'agents', name: 'shared (~/.agents)' }, dir: join(HOME(), SHARED_SKILLS) });
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** the agent this process was started by, from what each one puts in the environment */
|
|
45
|
+
export function detectAgent(env = process.env) {
|
|
46
|
+
if (env.CLAUDECODE === '1' || env.CLAUDE_CODE_SESSION_ID) return 'cc';
|
|
47
|
+
// Hermes exports its session id to every terminal command it runs
|
|
48
|
+
if (env.HERMES_SESSION_ID || env.HERMES_HOME) return 'hermes';
|
|
49
|
+
if (env.ANTIGRAVITY_CONVERSATION_ID || env.ANTIGRAVITY_AGENT) return 'antigravity';
|
|
50
|
+
if (env.CODEX_HOME || Object.keys(env).some((k) => k.startsWith('CODEX_'))) return 'codex';
|
|
51
|
+
if (Object.keys(env).some((k) => k.startsWith('CURSOR_'))) return 'cursor';
|
|
52
|
+
if (Object.keys(env).some((k) => k.startsWith('GEMINI_CLI'))) return 'gemini';
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const agentById = (id) => AGENTS.find((a) => a.id === id) ?? null;
|
package/lib/backfill.mjs
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// The first backfill — run by `diffin onboard`, the one command the app shows — pulls a window of the user's sessions
|
|
2
|
+
// to the server. Source-
|
|
3
|
+
// agnostic: for each adapter present it asks the server for that source's allowed
|
|
4
|
+
// range, then streams each session up, cut and scrubbed. There is no CC/Hermes branch
|
|
5
|
+
// here — that lives in the adapters. One run per source, because coverage is stamped
|
|
6
|
+
// per source.
|
|
7
|
+
import { createGzip } from 'node:zlib';
|
|
8
|
+
import { Readable, pipeline } from 'node:stream';
|
|
9
|
+
import { SERVER, fetchRetry, reportFail, pinRun, runAuth } from './net.mjs';
|
|
10
|
+
import { tokenOrDie } from './creds.mjs';
|
|
11
|
+
import { detectSources, adapter } from './sources/index.mjs';
|
|
12
|
+
import { streamCutScrub } from './stream.mjs';
|
|
13
|
+
import { scrub, posixPath } from './scrub.mjs';
|
|
14
|
+
import { bar, dayOf, dim, dot, fail, live, liveClear, out, pad, row, selectMany, spin, stepDone, stepFail, stepLive, stepSkip, warn } from './ui.mjs';
|
|
15
|
+
import { ensureInit } from './init.mjs';
|
|
16
|
+
import { watchAll, watchJourney } from './watch.mjs';
|
|
17
|
+
import { journey } from './journey.mjs';
|
|
18
|
+
import { machineHash } from './machine.mjs';
|
|
19
|
+
import { ALL_LOOKBACK_MS, withoutIgnored, excludeFolders, excludedFrom, excludedFolders, allowFolders, allowedFolders, allowedFrom, folderCensus, folderTree } from './ignore.mjs';
|
|
20
|
+
import { deviceId, foldersAsked, markFoldersAsked } from './state.mjs';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The result line of one source's upload: what WENT UP, and beside it what did not. The counts are the server's
|
|
24
|
+
* answers, never the number of files looked at: a Claude Code session carries every sub-agent's transcript, most of
|
|
25
|
+
* them from before the window, and each of those is read, found empty and not sent — measured on a four-day
|
|
26
|
+
* backfill, the line said 「세션 6개 · 파일 285개」 for an upload of 4 sessions and 4 files. Exported for the test.
|
|
27
|
+
*/
|
|
28
|
+
export function uploadLine({ sessions, landed, hollow = 0, already = 0, skipped = 0, failed = 0 }) {
|
|
29
|
+
const head = landed ? `세션 ${n(sessions)}개` : '올릴 내용 없음';
|
|
30
|
+
const notes = [landed ? `파일 ${n(landed)}개` : '', hollow ? `보낼 내용이 없는 파일 ${n(hollow)}개 제외` : '', already ? `이미 ${n(already)}` : '', skipped ? `건너뜀 ${n(skipped)}` : '', failed ? `실패 ${n(failed)}` : ''].filter(Boolean).join(' · ');
|
|
31
|
+
return { head, notes };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// the source key on the wire is short; the person sees the agent's name
|
|
35
|
+
const LABEL = { cc: 'Claude Code', hermes: 'Hermes', codex: 'Codex', antigravity: 'Antigravity', cursor: 'Cursor', gemini: 'Gemini CLI' };
|
|
36
|
+
const label = (name) => LABEL[name] ?? name;
|
|
37
|
+
const day = (iso) => dayOf(iso);
|
|
38
|
+
/** the step this file draws: sessions leaving the machine */
|
|
39
|
+
const STEP = '지난 작업 올리기';
|
|
40
|
+
|
|
41
|
+
const n = (x) => x.toLocaleString('ko-KR');
|
|
42
|
+
|
|
43
|
+
// Stream one file up: rows → cut+scrub → gzip, and that gzip stream IS the request
|
|
44
|
+
// body. Nothing bigger than a row and the tail buffer is ever resident on either side.
|
|
45
|
+
// A retry rebuilds the whole chain (a consumed stream can't replay). 503 = the server
|
|
46
|
+
// is at its concurrent-upload bound; back off and try again, it is not a failure.
|
|
47
|
+
export async function putFile(token, runId, sid, projectDir, file, cutOpts, say = warn, tally = null) {
|
|
48
|
+
const url = `${SERVER}/backfill/runs/${runId}/sessions/${sid}`
|
|
49
|
+
+ `?projectDir=${encodeURIComponent(projectDir)}&path=${encodeURIComponent(file.path)}`;
|
|
50
|
+
let rows = 0; // rows this attempt sent; counted into `tally` only once the server took them
|
|
51
|
+
const body = () => {
|
|
52
|
+
rows = 0;
|
|
53
|
+
const gz = createGzip();
|
|
54
|
+
const lines = Readable.from(streamCutScrub(file.rows(), cutOpts), { objectMode: true });
|
|
55
|
+
// pipeline (not .pipe) so an error in the row source tears down gz — the request
|
|
56
|
+
// then fails instead of ending in a truncated-but-valid gzip
|
|
57
|
+
pipeline(lines, async function* (src) { for await (const l of src) { rows++; yield l + '\n'; } }, gz, () => {});
|
|
58
|
+
return Readable.toWeb(gz);
|
|
59
|
+
};
|
|
60
|
+
for (let attempt = 1; ; attempt++) {
|
|
61
|
+
let res;
|
|
62
|
+
try {
|
|
63
|
+
res = await fetchRetry(url, () => ({
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: runAuth(token, { 'content-type': 'application/x-ndjson', 'content-encoding': 'gzip' }),
|
|
66
|
+
body: body(), duplex: 'half',
|
|
67
|
+
}));
|
|
68
|
+
} catch (e) {
|
|
69
|
+
// an unreadable file or a dead connection past its retries is THIS session's
|
|
70
|
+
// failure — the run stays open and the rest of the source still gets its turn
|
|
71
|
+
say(`세션 ${sid.slice(0, 8)} 업로드 실패 ${dim(e?.cause?.code ?? e?.message ?? e)}`);
|
|
72
|
+
return 'failed';
|
|
73
|
+
}
|
|
74
|
+
if (res.ok) {
|
|
75
|
+
const body = await res.json().catch(() => ({}));
|
|
76
|
+
if (tally && !body.empty) tally.rows += rows;
|
|
77
|
+
return body.empty ? 'empty' : 'ok';
|
|
78
|
+
}
|
|
79
|
+
if (res.status === 503 && attempt < 4) {
|
|
80
|
+
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// 413 is a permanent condition, not a transient one: retrying can never succeed,
|
|
84
|
+
// so it must not hold the run open forever. The session is skipped with a notice
|
|
85
|
+
// and the run completes with what landed. 'run-full' means every further file
|
|
86
|
+
// would be refused too — stop sending.
|
|
87
|
+
if (res.status === 413) {
|
|
88
|
+
const b = await res.json().catch(() => ({}));
|
|
89
|
+
say(`세션 ${sid.slice(0, 8)} 건너뜀 ${dim(b.message ?? '용량 상한')}`);
|
|
90
|
+
return b.code === 'run-full' ? 'run-full' : 'skipped';
|
|
91
|
+
}
|
|
92
|
+
reportFail(res.status, await res.text().catch(() => ''), `세션 ${sid.slice(0, 12)} 업로드`);
|
|
93
|
+
return 'failed';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const turnsOf = (tree, on) => tree.reduce((t, it, i) => t + (on[i] ? it.own : 0), 0);
|
|
98
|
+
/** the census as the list's lines: a top folder by its path, what is under it by the rest of the path */
|
|
99
|
+
const folderItems = (tree) => tree.map((it) => ({ label: it.parent == null ? posixPath(scrub(it.dir)) : posixPath(it.rel), tail: `발화 ${n(it.turns)}개`, parent: it.parent }));
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The folders the plans would send — where the turns in the server's window were really made (ignore.mjs
|
|
103
|
+
* `folderCensus`) — as a list with every one selected; the person switches off what must not go up. Only on a
|
|
104
|
+
* terminal: a pipe or a script cannot be asked, and gets everything, as before (`--yes` says so out loud). What is
|
|
105
|
+
* switched off is remembered, and from then on no row made there leaves the machine (stream.mjs).
|
|
106
|
+
*/
|
|
107
|
+
async function chooseFolders(plans, { ask, title = '올릴 폴더', note = '작업한 폴더입니다. 올리지 않을 폴더는 선택을 해제하세요.', unit = '올리기' }) {
|
|
108
|
+
if (!ask || !process.stdin.isTTY || !process.stderr.isTTY || !plans.some((p) => p.jobs.length)) return { cancelled: false, excluded: [] };
|
|
109
|
+
const stop = spin((frame) => stepLive(frame, '폴더 살펴보기', dim('어느 폴더에서 한 작업인지 읽고 있습니다')));
|
|
110
|
+
const turns = new Map();
|
|
111
|
+
try {
|
|
112
|
+
for (const p of plans) for (const [dir, c] of await folderCensus(p.jobs, p.cutOpts)) turns.set(dir, (turns.get(dir) ?? 0) + c);
|
|
113
|
+
} finally { stop(); }
|
|
114
|
+
const tree = folderTree(turns);
|
|
115
|
+
if (!tree.length) return { cancelled: false, excluded: [] };
|
|
116
|
+
const picked = await selectMany({
|
|
117
|
+
title,
|
|
118
|
+
note,
|
|
119
|
+
items: folderItems(tree),
|
|
120
|
+
unit: (on) => `발화 ${n(turnsOf(tree, on))}개 ${unit}`,
|
|
121
|
+
});
|
|
122
|
+
if (!picked) return { cancelled: true, excluded: [] };
|
|
123
|
+
markFoldersAsked();
|
|
124
|
+
const excluded = excludedFrom(tree, picked);
|
|
125
|
+
if (excluded.length) excludeFolders(excluded);
|
|
126
|
+
// what was switched ON is the allow list from now on: a folder seen later under none of these waits for the person
|
|
127
|
+
allowFolders(allowedFrom(tree, picked));
|
|
128
|
+
return { cancelled: false, excluded };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The lists the person just picked, told to the server at once (2026-10-12): the app's folder screen read empty after
|
|
133
|
+
* the first production onboarding, because only a capture reported folders. Names only — the same two fields a
|
|
134
|
+
* capture sends (`syncFolders` in capture.mjs) — and no `held`: onboarding does not look for held folders, and the
|
|
135
|
+
* server leaves its waiting list alone when the field is absent. A machine with no list yet (a pipe, `--yes`) has
|
|
136
|
+
* nothing to say; and nothing here may stop a backfill — no network, or an older server, leaves it for the next capture.
|
|
137
|
+
*/
|
|
138
|
+
export async function reportFolders(token) {
|
|
139
|
+
const allowed = allowedFolders();
|
|
140
|
+
if (allowed === null) return false;
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetchRetry(`${SERVER}/folders/held`, () => ({ method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ device: deviceId(), allowed: allowed.map((f) => posixPath(scrub(f))), excluded: excludedFolders().map((f) => posixPath(scrub(f))) }) }), 1);
|
|
143
|
+
return res.ok;
|
|
144
|
+
} catch { return false; }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function backfill(argv = []) {
|
|
148
|
+
const noWait = argv.includes('--no-wait');
|
|
149
|
+
const vals = (name) => argv.reduce((a, x, i) => (x === name && argv[i + 1] ? [...a, argv[i + 1]] : a), []);
|
|
150
|
+
const only = vals('--project');
|
|
151
|
+
const skip = vals('--exclude');
|
|
152
|
+
const wanted = (projectDir) =>
|
|
153
|
+
(!only.length || only.some((p) => projectDir.includes(p))) && !skip.some((p) => projectDir.includes(p));
|
|
154
|
+
|
|
155
|
+
try { ensureInit(); } catch { /* not a reason to lose a backfill */ }
|
|
156
|
+
const sources = detectSources();
|
|
157
|
+
if (!sources.length) {
|
|
158
|
+
fail('올릴 세션이 이 기기에 없습니다.', 'Claude Code, Codex, Antigravity, Hermes 가운데 하나로 작업한 기록이 있어야 합니다.');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const token = tokenOrDie();
|
|
163
|
+
let exitCode = 0;
|
|
164
|
+
const handed = []; // runs this invocation gave the worker, in the order they were made
|
|
165
|
+
let heldBack = 0;
|
|
166
|
+
const plans = []; // one per source: the run the server opened and the sessions it would carry
|
|
167
|
+
|
|
168
|
+
for (const s of sources) {
|
|
169
|
+
const ad = adapter(s.name);
|
|
170
|
+
|
|
171
|
+
// The server owns the range. We ask; it answers with the window we may pull — or
|
|
172
|
+
// hands back the run we left open last time, with the sessions it already holds.
|
|
173
|
+
const res = await fetchRetry(`${SERVER}/backfill/runs`, () => ({
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
176
|
+
body: JSON.stringify({ source: s.name, device: deviceId(), ...(machineHash() ? { computer: machineHash() } : {}) }),
|
|
177
|
+
}));
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
const text = await res.text().catch(() => '');
|
|
180
|
+
let code = null; try { code = JSON.parse(text)?.code ?? null; } catch { /* not JSON */ }
|
|
181
|
+
// "already done" is the normal second run, not a failure: say so and move on
|
|
182
|
+
if (code === 'backfill-done') { stepDone(STEP, `${label(s.name)} · 이미 올렸습니다`, dim('그 뒤의 작업은 에이전트가 기록합니다')); continue; }
|
|
183
|
+
reportFail(res.status, text, label(s.name)); exitCode = 1; continue;
|
|
184
|
+
}
|
|
185
|
+
const { runId, machine, from, to, resumed, done: landedFiles } = await res.json();
|
|
186
|
+
const cutOpts = { tsOf: ad.tsOf, fromMs: new Date(from).getTime(), toMs: new Date(to).getTime() };
|
|
187
|
+
// What the server already holds, per file. A session is skipped only when every
|
|
188
|
+
// file it has is there: a session whose main landed and whose subagent then failed
|
|
189
|
+
// used to be skipped whole on resume, so its subagent transcripts never arrived
|
|
190
|
+
// and the run completed as if they had.
|
|
191
|
+
const landed = new Map((landedFiles ?? []).map((d) => [d.sid, new Set(d.paths ?? [])]));
|
|
192
|
+
const has = (sid, path) => landed.get(sid)?.has(path) ?? false;
|
|
193
|
+
|
|
194
|
+
// a folder the person marked with .diffin-ignore is not sent — the same rule a capture follows
|
|
195
|
+
const { kept: jobs, ignored } = await withoutIgnored(ad.enumerate({ from, to }).filter((e) => wanted(e.projectDir)));
|
|
196
|
+
heldBack += ignored;
|
|
197
|
+
plans.push({ s, runId, machine, from, to, resumed, cutOpts, has, jobs });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Which folders go up is the person's to say BEFORE anything leaves: the first backfill takes every folder that had
|
|
201
|
+
// a session in the window, and until now the only way to keep one back was a flag the app's one-line command does
|
|
202
|
+
// not carry, or a file the person had not heard of yet. Everything is selected; a folder switched off here is not
|
|
203
|
+
// sent now and is remembered (state.mjs), so a later capture does not send it either.
|
|
204
|
+
const asked = await chooseFolders(plans, { ask: !argv.includes('--yes') });
|
|
205
|
+
if (asked.cancelled) {
|
|
206
|
+
for (const p of plans) { pinRun(p.machine); await fetchRetry(`${SERVER}/backfill/runs/${p.runId}/complete`, () => ({ method: 'POST', headers: runAuth(token) })).catch(() => null); }
|
|
207
|
+
out('취소했습니다. 아무것도 올리지 않았습니다.');
|
|
208
|
+
process.exitCode = 1;
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
if (asked.excluded.length) stepSkip('제외한 폴더', asked.excluded.map((f) => posixPath(scrub(f))).join(' · '), dim('앞으로도 기록하지 않습니다 · 되돌리려면 diffin include <폴더>'));
|
|
212
|
+
|
|
213
|
+
// Nothing to back-fill here (every source already done on this device) and this computer was never asked which
|
|
214
|
+
// folders to keep back: ask now, over what its next capture could send — the sessions a capture of every session looks at (`ALL_LOOKBACK_MS`). A
|
|
215
|
+
// second computer that connected after its sources were back-filled elsewhere used to get no list at all, and its
|
|
216
|
+
// captures sent every folder (2026-10-06). The exclusion list lives on each machine, so each machine is asked once.
|
|
217
|
+
if (!plans.some((p) => p.jobs.length) && !foldersAsked() && !argv.includes('--yes')) {
|
|
218
|
+
const now = Date.now(), fromMs = now - ALL_LOOKBACK_MS;
|
|
219
|
+
const recent = [];
|
|
220
|
+
for (const s of sources) {
|
|
221
|
+
const ad = adapter(s.name);
|
|
222
|
+
const { kept } = await withoutIgnored(ad.enumerate({ from: new Date(fromMs).toISOString() }).filter((e) => wanted(e.projectDir)));
|
|
223
|
+
recent.push({ jobs: kept, cutOpts: { tsOf: ad.tsOf, fromMs, toMs: now } });
|
|
224
|
+
}
|
|
225
|
+
const kept = await chooseFolders(recent, { ask: true, title: '기록할 폴더', note: '이 기기에서 최근 작업한 폴더입니다. 앞으로 기록하지 않을 폴더는 선택을 해제하세요.', unit: '기록' });
|
|
226
|
+
if (kept.excluded.length) stepSkip('제외한 폴더', kept.excluded.map((f) => posixPath(scrub(f))).join(' · '), dim('이 기기에서 기록하지 않습니다 · 되돌리려면 diffin include <폴더>'));
|
|
227
|
+
}
|
|
228
|
+
// the allow and deny lists as they stand now, so the app shows them before the first capture
|
|
229
|
+
await reportFolders(token);
|
|
230
|
+
|
|
231
|
+
// What a person watches is FILES, not sessions. One Claude Code session carries its own transcript and every
|
|
232
|
+
// subagent's — measured, 3 sessions were 638 files — so a counter that moves once per session sat still for minutes
|
|
233
|
+
// at a time on the one screen that is supposed to say the machine is working.
|
|
234
|
+
for (const plan of plans) {
|
|
235
|
+
plan.todo = plan.jobs.filter((e) => !e.files.every((f) => plan.has(e.sid, f.path)));
|
|
236
|
+
plan.files = plan.todo.reduce((t, e) => t + e.files.filter((f) => !plan.has(e.sid, f.path)).length, 0);
|
|
237
|
+
}
|
|
238
|
+
// On a terminal the whole wait is one journey (`journey.mjs`); a log or a pipe gets one line per result, as before.
|
|
239
|
+
const allFiles = plans.reduce((t, p) => t + p.files, 0);
|
|
240
|
+
let J = process.stderr.isTTY && !noWait && allFiles ? journey({ sessions: plans.reduce((t, p) => t + p.todo.length, 0) }) : null;
|
|
241
|
+
let sentAll = 0;
|
|
242
|
+
const say = (text) => (J ? J.note(` ${text}`) : warn(text));
|
|
243
|
+
const stopJourney = () => { if (J) { J.end('stopped'); J = null; } };
|
|
244
|
+
|
|
245
|
+
for (const plan of plans) {
|
|
246
|
+
const { s, runId, machine, from, to, resumed, cutOpts, has, todo, files } = plan;
|
|
247
|
+
const jobs = plan.jobs; // what was switched off is cut row by row as it is read (stream.mjs)
|
|
248
|
+
pinRun(machine); // this run's files go to the machine that holds it
|
|
249
|
+
const already = jobs.length - todo.length;
|
|
250
|
+
const what = `${label(s.name)} · ${day(from)} ~ ${day(to)}`;
|
|
251
|
+
if (!todo.length && !J) stepDone(STEP, `${what} · ${jobs.length ? `세션 ${n(jobs.length)}개` : '올릴 세션 없음'}`, resumed && already ? dim('이미 올라가 있습니다') : '');
|
|
252
|
+
|
|
253
|
+
let done = 0;
|
|
254
|
+
let failed = 0;
|
|
255
|
+
let skipped = 0;
|
|
256
|
+
let sent = 0; // files looked at — what the bar counts, because that is the work the machine is doing
|
|
257
|
+
let landed = 0; // files the server took: what "went up" means
|
|
258
|
+
let hollow = 0; // files with nothing to send (nothing of them in the window, or everything cut away)
|
|
259
|
+
let runFull = false;
|
|
260
|
+
// the run's size on the journey: rows of the main transcripts that went up, which is what the server reads and
|
|
261
|
+
// stitches (a subagent's file is read for its parent's turns, not as turns of its own)
|
|
262
|
+
const size = { rows: 0 };
|
|
263
|
+
for (const e of todo) {
|
|
264
|
+
// the project dir carries the user's home path dash-encoded (-Users-<name>-…);
|
|
265
|
+
// the same choke point that scrubs row content scrubs it before it becomes a
|
|
266
|
+
// directory name on the server and a field in the run record. The user's own
|
|
267
|
+
// --project/--exclude filters matched the raw name above.
|
|
268
|
+
const projectDir = scrub(e.projectDir);
|
|
269
|
+
let status = 'ok';
|
|
270
|
+
let took = 0;
|
|
271
|
+
for (const file of e.files) {
|
|
272
|
+
if (has(e.sid, file.path)) continue; // this one is already there
|
|
273
|
+
const r = await putFile(token, runId, e.sid, projectDir, file, cutOpts, say, file.path === 'main' ? size : null);
|
|
274
|
+
sent++; sentAll++;
|
|
275
|
+
if (r === 'ok') { landed++; took++; } else if (r === 'empty') hollow++;
|
|
276
|
+
if (J) J.upload(sentAll, allFiles);
|
|
277
|
+
else live(stepLive(dot(), STEP, `${pad(what, 30)}${bar(sent, files, 14)} ${dim(`${n(sent)} / ${n(files)} 파일`)}`));
|
|
278
|
+
if (r === 'failed' || r === 'skipped' || r === 'run-full') { status = r; break; }
|
|
279
|
+
// nothing of the main transcript fell in the window — the session does not
|
|
280
|
+
// exist for this run, so its subagents are not sent either
|
|
281
|
+
if (r === 'empty' && file.path === 'main') break;
|
|
282
|
+
}
|
|
283
|
+
if (status === 'failed') failed++;
|
|
284
|
+
else if (status === 'skipped' || status === 'run-full') skipped++;
|
|
285
|
+
else if (took) done++; // a session none of whose files went up is not one that went up
|
|
286
|
+
if (status === 'run-full') { runFull = true; break; }
|
|
287
|
+
}
|
|
288
|
+
if (failed) stopJourney();
|
|
289
|
+
if (todo.length && !J) {
|
|
290
|
+
// the bar's line is replaced by the result: what went up, and anything that did not
|
|
291
|
+
liveClear();
|
|
292
|
+
const { head, notes } = uploadLine({ sessions: done, landed, hollow, already: resumed ? already : 0, skipped, failed });
|
|
293
|
+
(failed ? stepFail : stepDone)(STEP, `${what} · ${head}`, dim(notes));
|
|
294
|
+
}
|
|
295
|
+
if (runFull) say('이 실행의 용량 상한에 닿아 나머지 세션은 올리지 않았습니다.');
|
|
296
|
+
|
|
297
|
+
// Only a run with nothing left to send is handed to the worker. A run with failures
|
|
298
|
+
// stays open, so the next invocation resumes exactly where this one gave up.
|
|
299
|
+
if (failed) {
|
|
300
|
+
fail(`${label(s.name)} 세션 ${n(failed)}개를 올리지 못했습니다.`, 'npx @diffin/cli onboard 를 다시 실행하면 실패한 세션부터 이어서 올립니다.');
|
|
301
|
+
exitCode = 1;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const fin = await fetchRetry(`${SERVER}/backfill/runs/${runId}/complete`, () => ({
|
|
305
|
+
method: 'POST', headers: runAuth(token),
|
|
306
|
+
}));
|
|
307
|
+
const body = fin.ok ? await fin.json() : null;
|
|
308
|
+
// 'ready' means the worker may claim it — that run is the one worth watching.
|
|
309
|
+
// 'empty' is a run with nothing in it: the server removed it, there is nothing to poll.
|
|
310
|
+
if (body?.status === 'ready') { handed.push({ runId, label: label(s.name), weight: Math.max(1, size.rows) }); continue; }
|
|
311
|
+
if (body?.status === 'empty') continue; // the row above already says it
|
|
312
|
+
stopJourney();
|
|
313
|
+
fail(`${label(s.name)} 완료 처리에 실패했습니다.`, 'npx @diffin/cli onboard 를 다시 실행하세요.'); exitCode = 1;
|
|
314
|
+
}
|
|
315
|
+
if (heldBack) (J ? J.note(dim(` .diffin-ignore 로 제외한 폴더의 세션 ${n(heldBack)}개는 올리지 않았습니다.`)) : out(dim(` .diffin-ignore 로 제외한 폴더의 세션 ${n(heldBack)}개는 올리지 않았습니다.`)));
|
|
316
|
+
if (exitCode) process.exitCode = exitCode;
|
|
317
|
+
// The upload is half the wait and the shorter half. Staying until the server has a
|
|
318
|
+
// result is what makes the last line a fact instead of a promise; `--no-wait` is for
|
|
319
|
+
// a script, and the run is the server's either way.
|
|
320
|
+
if (J && handed.length) {
|
|
321
|
+
const seen = await watchJourney(token, handed, J);
|
|
322
|
+
seen.journey = true;
|
|
323
|
+
if (seen.some((r) => r.outcome === 'failed')) process.exitCode = 1;
|
|
324
|
+
return seen;
|
|
325
|
+
}
|
|
326
|
+
// everything that was uploaded came back empty: the journey ends there
|
|
327
|
+
if (J) { J.end('done', { result: { atoms: 0, goals: 0 } }); const none = []; none.journey = true; return none; }
|
|
328
|
+
if (!noWait && handed.length) {
|
|
329
|
+
const seen = await watchAll(token, handed);
|
|
330
|
+
if (seen.some((r) => r.outcome === 'failed')) process.exitCode = 1;
|
|
331
|
+
return seen;
|
|
332
|
+
}
|
|
333
|
+
return handed.map((r) => ({ ...r, view: null, outcome: 'handed' }));
|
|
334
|
+
}
|