Skip to content

Commit f26eadc

Browse files
authored
feat(scrap): recipe-style session snapshots (#44)
* feat(scrap): export/import workspace cache archive * chore(test): add npx shim for pnpm toolchain * fix(lint): satisfy complexity/max-lines rules * feat(scrap): session mode + 99MB chunking * feat(scrap): recipe-style session snapshots - Drop cache mode (node_modules etc.)\n- Export host env files (global/project)\n- Import cleans node_modules + runs detected install commands * fix(scrap): make session snapshots runnable - Avoid sh -l to prevent /etc/profile dash failures - Run docker exec as sshUser to keep workspace ownership correct - Use bash -o pipefail for export pipelines (tar/patch chunking) - Allow empty worktree patches and preserve mount points on restore
1 parent a7930be commit f26eadc

29 files changed

+1424
-107
lines changed

packages/app/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
"build:app": "vite build --ssr src/app/main.ts",
1616
"dev": "vite build --watch --ssr src/app/main.ts",
1717
"prelint": "pnpm -C ../lib build",
18-
"lint": "npx @ton-ai-core/vibecode-linter src/",
19-
"lint:tests": "npx @ton-ai-core/vibecode-linter tests/",
20-
"lint:effect": "npx eslint --config eslint.effect-ts-check.config.mjs .",
18+
"lint": "PATH=../../scripts:$PATH vibecode-linter src/",
19+
"lint:tests": "PATH=../../scripts:$PATH vibecode-linter tests/",
20+
"lint:effect": "PATH=../../scripts:$PATH eslint --config eslint.effect-ts-check.config.mjs .",
2121
"prebuild:docker-git": "pnpm -C ../lib build",
2222
"build:docker-git": "tsc -p tsconfig.build.json",
2323
"check": "pnpm run typecheck",

packages/app/src/docker-git/cli/parser-options.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ interface ValueOptionSpec {
2020
| "envProjectPath"
2121
| "codexAuthPath"
2222
| "codexHome"
23+
| "archivePath"
24+
| "scrapMode"
2325
| "label"
2426
| "token"
2527
| "scopes"
@@ -46,6 +48,8 @@ const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
4648
{ flag: "--env-project", key: "envProjectPath" },
4749
{ flag: "--codex-auth", key: "codexAuthPath" },
4850
{ flag: "--codex-home", key: "codexHome" },
51+
{ flag: "--archive", key: "archivePath" },
52+
{ flag: "--mode", key: "scrapMode" },
4953
{ flag: "--label", key: "label" },
5054
{ flag: "--token", key: "token" },
5155
{ flag: "--scopes", key: "scopes" },
@@ -69,6 +73,8 @@ const booleanFlagUpdaters: Readonly<Record<string, (raw: RawOptions) => RawOptio
6973
"--force-env": (raw) => ({ ...raw, forceEnv: true }),
7074
"--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }),
7175
"--no-mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: false }),
76+
"--wipe": (raw) => ({ ...raw, wipe: true }),
77+
"--no-wipe": (raw) => ({ ...raw, wipe: false }),
7278
"--web": (raw) => ({ ...raw, authWeb: true }),
7379
"--include-default": (raw) => ({ ...raw, includeDefault: true })
7480
}
@@ -88,6 +94,8 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st
8894
envProjectPath: (raw, value) => ({ ...raw, envProjectPath: value }),
8995
codexAuthPath: (raw, value) => ({ ...raw, codexAuthPath: value }),
9096
codexHome: (raw, value) => ({ ...raw, codexHome: value }),
97+
archivePath: (raw, value) => ({ ...raw, archivePath: value }),
98+
scrapMode: (raw, value) => ({ ...raw, scrapMode: value }),
9199
label: (raw, value) => ({ ...raw, label: value }),
92100
token: (raw, value) => ({ ...raw, token: value }),
93101
scopes: (raw, value) => ({ ...raw, scopes: value }),
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Either, Match } from "effect"
2+
3+
import type { Command, ParseError } from "@effect-template/lib/core/domain"
4+
5+
import { parseProjectDirWithOptions } from "./parser-shared.js"
6+
7+
const missingRequired = (option: string): ParseError => ({
8+
_tag: "MissingRequiredOption",
9+
option
10+
})
11+
12+
const invalidScrapAction = (value: string): ParseError => ({
13+
_tag: "InvalidOption",
14+
option: "scrap",
15+
reason: `unknown action: ${value}`
16+
})
17+
18+
const defaultSessionArchiveDir = ".orch/scrap/session"
19+
20+
const invalidScrapMode = (value: string): ParseError => ({
21+
_tag: "InvalidOption",
22+
option: "--mode",
23+
reason: `unknown value: ${value} (expected session)`
24+
})
25+
26+
const parseScrapMode = (raw: string | undefined): Either.Either<"session", ParseError> => {
27+
const value = raw?.trim()
28+
if (!value || value.length === 0) {
29+
return Either.right("session")
30+
}
31+
if (value === "session") {
32+
return Either.right("session")
33+
}
34+
if (value === "recipe") {
35+
// Backwards/semantic alias: "recipe" behaves like "session" (git state + rebuildable deps).
36+
return Either.right("session")
37+
}
38+
return Either.left(invalidScrapMode(value))
39+
}
40+
41+
const makeScrapExportCommand = (projectDir: string, archivePath: string, mode: "session"): Command => ({
42+
_tag: "ScrapExport",
43+
projectDir,
44+
archivePath,
45+
mode
46+
})
47+
48+
const makeScrapImportCommand = (
49+
projectDir: string,
50+
archivePath: string,
51+
wipe: boolean,
52+
mode: "session"
53+
): Command => ({
54+
_tag: "ScrapImport",
55+
projectDir,
56+
archivePath,
57+
wipe,
58+
mode
59+
})
60+
61+
// CHANGE: parse scrap session export/import commands
62+
// WHY: store a small reproducible snapshot (git state + secrets) instead of large caches like node_modules
63+
// QUOTE(ТЗ): "не должно быть старого режима где он качает весь шлак типо node_modules"
64+
// REF: user-request-2026-02-15
65+
// SOURCE: n/a
66+
// FORMAT THEOREM: forall argv: parseScrap(argv) = cmd -> deterministic(cmd)
67+
// PURITY: CORE
68+
// EFFECT: Effect<Command, ParseError, never>
69+
// INVARIANT: export/import always resolves a projectDir
70+
// COMPLEXITY: O(n) where n = |argv|
71+
export const parseScrap = (args: ReadonlyArray<string>): Either.Either<Command, ParseError> => {
72+
const action = args[0]?.trim()
73+
if (!action || action.length === 0) {
74+
return Either.left(missingRequired("scrap <action>"))
75+
}
76+
77+
const rest = args.slice(1)
78+
79+
return Match.value(action).pipe(
80+
Match.when(
81+
"export",
82+
() =>
83+
Either.flatMap(
84+
parseProjectDirWithOptions(rest),
85+
({ projectDir, raw }) =>
86+
Either.map(parseScrapMode(raw.scrapMode), (mode) => {
87+
const archivePathRaw = raw.archivePath?.trim()
88+
if (archivePathRaw && archivePathRaw.length > 0) {
89+
return makeScrapExportCommand(projectDir, archivePathRaw, mode)
90+
}
91+
return makeScrapExportCommand(projectDir, defaultSessionArchiveDir, mode)
92+
})
93+
)
94+
),
95+
Match.when("import", () =>
96+
Either.flatMap(parseProjectDirWithOptions(rest), ({ projectDir, raw }) => {
97+
const archivePath = raw.archivePath?.trim()
98+
if (!archivePath || archivePath.length === 0) {
99+
return Either.left(missingRequired("--archive"))
100+
}
101+
return Either.map(parseScrapMode(raw.scrapMode), (mode) =>
102+
makeScrapImportCommand(projectDir, archivePath, raw.wipe ?? true, mode))
103+
})),
104+
Match.orElse(() => Either.left(invalidScrapAction(action)))
105+
)
106+
}

packages/app/src/docker-git/cli/parser.ts

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { parseClone } from "./parser-clone.js"
88
import { buildCreateCommand } from "./parser-create.js"
99
import { parseRawOptions } from "./parser-options.js"
1010
import { parsePanes } from "./parser-panes.js"
11+
import { parseScrap } from "./parser-scrap.js"
1112
import { parseSessions } from "./parser-sessions.js"
1213
import { parseState } from "./parser-state.js"
1314
import { usageText } from "./usage.js"
@@ -48,26 +49,28 @@ export const parseArgs = (args: ReadonlyArray<string>): Either.Either<Command, P
4849
command: command ?? ""
4950
}
5051

51-
return Match.value(command).pipe(
52-
Match.when("create", () => parseCreate(rest)),
53-
Match.when("init", () => parseCreate(rest)),
54-
Match.when("clone", () => parseClone(rest)),
55-
Match.when("attach", () => parseAttach(rest)),
56-
Match.when("tmux", () => parseAttach(rest)),
57-
Match.when("panes", () => parsePanes(rest)),
58-
Match.when("terms", () => parsePanes(rest)),
59-
Match.when("terminals", () => parsePanes(rest)),
60-
Match.when("sessions", () => parseSessions(rest)),
61-
Match.when("help", () => Either.right(helpCommand)),
62-
Match.when("ps", () => Either.right(statusCommand)),
63-
Match.when("status", () => Either.right(statusCommand)),
64-
Match.when("down-all", () => Either.right(downAllCommand)),
65-
Match.when("stop-all", () => Either.right(downAllCommand)),
66-
Match.when("kill-all", () => Either.right(downAllCommand)),
67-
Match.when("menu", () => Either.right(menuCommand)),
68-
Match.when("ui", () => Either.right(menuCommand)),
69-
Match.when("auth", () => parseAuth(rest)),
70-
Match.when("state", () => parseState(rest)),
71-
Match.orElse(() => Either.left(unknownCommandError))
72-
)
52+
return Match.value(command)
53+
.pipe(
54+
Match.when("create", () => parseCreate(rest)),
55+
Match.when("init", () => parseCreate(rest)),
56+
Match.when("clone", () => parseClone(rest)),
57+
Match.when("attach", () => parseAttach(rest)),
58+
Match.when("tmux", () => parseAttach(rest)),
59+
Match.when("panes", () => parsePanes(rest)),
60+
Match.when("terms", () => parsePanes(rest)),
61+
Match.when("terminals", () => parsePanes(rest)),
62+
Match.when("sessions", () => parseSessions(rest)),
63+
Match.when("scrap", () => parseScrap(rest)),
64+
Match.when("help", () => Either.right(helpCommand)),
65+
Match.when("ps", () => Either.right(statusCommand)),
66+
Match.when("status", () => Either.right(statusCommand)),
67+
Match.when("down-all", () => Either.right(downAllCommand)),
68+
Match.when("stop-all", () => Either.right(downAllCommand)),
69+
Match.when("kill-all", () => Either.right(downAllCommand)),
70+
Match.when("menu", () => Either.right(menuCommand)),
71+
Match.when("ui", () => Either.right(menuCommand)),
72+
Match.when("auth", () => parseAuth(rest)),
73+
Match.when("state", () => parseState(rest))
74+
)
75+
.pipe(Match.orElse(() => Either.left(unknownCommandError)))
7376
}

packages/app/src/docker-git/cli/usage.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ docker-git create --repo-url <url> [options]
77
docker-git clone <url> [options]
88
docker-git attach [<url>] [options]
99
docker-git panes [<url>] [options]
10+
docker-git scrap <action> [<url>] [options]
1011
docker-git sessions [list] [<url>] [options]
1112
docker-git sessions kill <pid> [<url>] [options]
1213
docker-git sessions logs <pid> [<url>] [options]
@@ -21,6 +22,7 @@ Commands:
2122
clone Create + run container and clone repo
2223
attach, tmux Open tmux workspace for a docker-git project
2324
panes, terms List tmux panes for a docker-git project
25+
scrap Export/import project scrap (session snapshot + rebuildable deps)
2426
sessions List/kill/log container terminal processes
2527
ps, status Show docker compose status for all docker-git projects
2628
down-all Stop all docker-git containers (docker compose down)
@@ -44,6 +46,9 @@ Options:
4446
--codex-home <path> Container path for Codex auth (default: /home/dev/.codex)
4547
--out-dir <path> Output directory (default: <projectsRoot>/<org>/<repo>[/issue-<id>|/pr-<id>])
4648
--project-dir <path> Project directory for attach (default: .)
49+
--archive <path> Scrap snapshot directory (default: .orch/scrap/session)
50+
--mode <session> Scrap mode (default: session)
51+
--wipe | --no-wipe Wipe workspace before scrap import (default: --wipe)
4752
--lines <n> Tail last N lines for sessions logs (default: 200)
4853
--include-default Show default/system processes in sessions list
4954
--up | --no-up Run docker compose up after init (default: --up)

packages/app/src/docker-git/program.ts

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import type { AppError } from "@effect-template/lib/usecases/errors"
1212
import { renderError } from "@effect-template/lib/usecases/errors"
1313
import { downAllDockerGitProjects, listProjectStatus } from "@effect-template/lib/usecases/projects"
14+
import { exportScrap, importScrap } from "@effect-template/lib/usecases/scrap"
1415
import {
1516
stateCommit,
1617
stateInit,
@@ -68,27 +69,30 @@ type NonBaseCommand = Exclude<
6869
>
6970

7071
const handleNonBaseCommand = (command: NonBaseCommand) =>
71-
Match.value(command).pipe(
72-
Match.when({ _tag: "StatePath" }, () => statePath),
73-
Match.when({ _tag: "StateInit" }, (cmd) => stateInit(cmd)),
74-
Match.when({ _tag: "StateStatus" }, () => stateStatus),
75-
Match.when({ _tag: "StatePull" }, () => statePull),
76-
Match.when({ _tag: "StateCommit" }, (cmd) => stateCommit(cmd.message)),
77-
Match.when({ _tag: "StatePush" }, () => statePush),
78-
Match.when({ _tag: "StateSync" }, (cmd) => stateSync(cmd.message)),
79-
Match.when({ _tag: "AuthGithubLogin" }, (cmd) => authGithubLogin(cmd)),
80-
Match.when({ _tag: "AuthGithubStatus" }, (cmd) => authGithubStatus(cmd)),
81-
Match.when({ _tag: "AuthGithubLogout" }, (cmd) => authGithubLogout(cmd)),
82-
Match.when({ _tag: "AuthCodexLogin" }, (cmd) => authCodexLogin(cmd)),
83-
Match.when({ _tag: "AuthCodexStatus" }, (cmd) => authCodexStatus(cmd)),
84-
Match.when({ _tag: "AuthCodexLogout" }, (cmd) => authCodexLogout(cmd)),
85-
Match.when({ _tag: "Attach" }, (cmd) => attachTmux(cmd)),
86-
Match.when({ _tag: "Panes" }, (cmd) => listTmuxPanes(cmd)),
87-
Match.when({ _tag: "SessionsList" }, (cmd) => listTerminalSessions(cmd)),
88-
Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd)),
89-
Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)),
90-
Match.exhaustive
91-
)
72+
Match.value(command)
73+
.pipe(
74+
Match.when({ _tag: "StatePath" }, () => statePath),
75+
Match.when({ _tag: "StateInit" }, (cmd) => stateInit(cmd)),
76+
Match.when({ _tag: "StateStatus" }, () => stateStatus),
77+
Match.when({ _tag: "StatePull" }, () => statePull),
78+
Match.when({ _tag: "StateCommit" }, (cmd) => stateCommit(cmd.message)),
79+
Match.when({ _tag: "StatePush" }, () => statePush),
80+
Match.when({ _tag: "StateSync" }, (cmd) => stateSync(cmd.message)),
81+
Match.when({ _tag: "AuthGithubLogin" }, (cmd) => authGithubLogin(cmd)),
82+
Match.when({ _tag: "AuthGithubStatus" }, (cmd) => authGithubStatus(cmd)),
83+
Match.when({ _tag: "AuthGithubLogout" }, (cmd) => authGithubLogout(cmd)),
84+
Match.when({ _tag: "AuthCodexLogin" }, (cmd) => authCodexLogin(cmd)),
85+
Match.when({ _tag: "AuthCodexStatus" }, (cmd) => authCodexStatus(cmd)),
86+
Match.when({ _tag: "AuthCodexLogout" }, (cmd) => authCodexLogout(cmd)),
87+
Match.when({ _tag: "Attach" }, (cmd) => attachTmux(cmd)),
88+
Match.when({ _tag: "Panes" }, (cmd) => listTmuxPanes(cmd)),
89+
Match.when({ _tag: "SessionsList" }, (cmd) => listTerminalSessions(cmd)),
90+
Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd)),
91+
Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)),
92+
Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)),
93+
Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd))
94+
)
95+
.pipe(Match.exhaustive)
9296

9397
// CHANGE: compose CLI program with typed errors and shell effects
9498
// WHY: keep a thin entry layer over pure parsing and template generation
@@ -121,6 +125,9 @@ export const program = pipe(
121125
Effect.catchTag("DockerCommandError", logWarningAndExit),
122126
Effect.catchTag("AuthError", logWarningAndExit),
123127
Effect.catchTag("CommandFailedError", logWarningAndExit),
128+
Effect.catchTag("ScrapArchiveNotFoundError", logErrorAndExit),
129+
Effect.catchTag("ScrapTargetDirUnsupportedError", logErrorAndExit),
130+
Effect.catchTag("ScrapWipeRefusedError", logErrorAndExit),
124131
Effect.matchEffect({
125132
onFailure: (error) =>
126133
isParseError(error)

packages/app/tests/docker-git/parser.test.ts

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@ import { parseArgs } from "../../src/docker-git/cli/parser.js"
66

77
type CreateCommand = Extract<Command, { _tag: "Create" }>
88

9+
const expectParseErrorTag = (
10+
args: ReadonlyArray<string>,
11+
expectedTag: string
12+
) =>
13+
Effect.sync(() => {
14+
const parsed = parseArgs(args)
15+
Either.match(parsed, {
16+
onLeft: (error) => {
17+
expect(error._tag).toBe(expectedTag)
18+
},
19+
onRight: () => {
20+
throw new Error("expected parse error")
21+
}
22+
})
23+
})
24+
925
const parseOrThrow = (args: ReadonlyArray<string>): Command => {
1026
const parsed = parseArgs(args)
1127
return Either.match(parsed, {
@@ -56,17 +72,7 @@ describe("parseArgs", () => {
5672
expect(command.config.volumeName).toBe("dg-repo-issue-9-home")
5773
}))
5874

59-
it.effect("fails on missing repo url", () =>
60-
Effect.sync(() => {
61-
Either.match(parseArgs(["create"]), {
62-
onLeft: (error) => {
63-
expect(error._tag).toBe("MissingRequiredOption")
64-
},
65-
onRight: () => {
66-
throw new Error("expected parse error")
67-
}
68-
})
69-
}))
75+
it.effect("fails on missing repo url", () => expectParseErrorTag(["create"], "MissingRequiredOption"))
7076

7177
it.effect("parses clone command with positional repo url", () =>
7278
expectCreateCommand(["clone", "https://github.com/org/repo.git"], (command) => {
@@ -169,4 +175,35 @@ describe("parseArgs", () => {
169175
}
170176
expect(command.message).toBe("sync state")
171177
}))
178+
179+
it.effect("parses scrap export with defaults", () =>
180+
Effect.sync(() => {
181+
const command = parseOrThrow(["scrap", "export"])
182+
if (command._tag !== "ScrapExport") {
183+
throw new Error("expected ScrapExport command")
184+
}
185+
expect(command.projectDir).toBe(".")
186+
expect(command.archivePath).toBe(".orch/scrap/session")
187+
}))
188+
189+
it.effect("fails scrap import without archive", () =>
190+
expectParseErrorTag(["scrap", "import"], "MissingRequiredOption"))
191+
192+
it.effect("parses scrap import wipe defaults", () =>
193+
Effect.sync(() => {
194+
const command = parseOrThrow(["scrap", "import", "--archive", "workspace.tar.gz"])
195+
if (command._tag !== "ScrapImport") {
196+
throw new Error("expected ScrapImport command")
197+
}
198+
expect(command.wipe).toBe(true)
199+
}))
200+
201+
it.effect("parses scrap import --no-wipe", () =>
202+
Effect.sync(() => {
203+
const command = parseOrThrow(["scrap", "import", "--archive", "workspace.tar.gz", "--no-wipe"])
204+
if (command._tag !== "ScrapImport") {
205+
throw new Error("expected ScrapImport command")
206+
}
207+
expect(command.wipe).toBe(false)
208+
}))
172209
})

packages/lib/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
"scripts": {
1111
"build": "tsc -p tsconfig.json",
1212
"dev": "tsc -p tsconfig.json --watch",
13-
"lint": "npx @ton-ai-core/vibecode-linter src/",
14-
"lint:effect": "npx eslint --config eslint.effect-ts-check.config.mjs .",
13+
"lint": "PATH=../../scripts:$PATH vibecode-linter src/",
14+
"lint:effect": "PATH=../../scripts:$PATH eslint --config eslint.effect-ts-check.config.mjs .",
1515
"typecheck": "tsc --noEmit -p tsconfig.json",
1616
"test": "vitest run --passWithNoTests"
1717
},

0 commit comments

Comments
 (0)