Skip to content

Commit d50a2c1

Browse files
committed
feat(scrap): session mode + 99MB chunking
1 parent c4436d6 commit d50a2c1

18 files changed

Lines changed: 1032 additions & 357 deletions

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface ValueOptionSpec {
2121
| "codexAuthPath"
2222
| "codexHome"
2323
| "archivePath"
24+
| "scrapMode"
2425
| "label"
2526
| "token"
2627
| "scopes"
@@ -48,6 +49,7 @@ const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
4849
{ flag: "--codex-auth", key: "codexAuthPath" },
4950
{ flag: "--codex-home", key: "codexHome" },
5051
{ flag: "--archive", key: "archivePath" },
52+
{ flag: "--mode", key: "scrapMode" },
5153
{ flag: "--label", key: "label" },
5254
{ flag: "--token", key: "token" },
5355
{ flag: "--scopes", key: "scopes" },
@@ -93,6 +95,7 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st
9395
codexAuthPath: (raw, value) => ({ ...raw, codexAuthPath: value }),
9496
codexHome: (raw, value) => ({ ...raw, codexHome: value }),
9597
archivePath: (raw, value) => ({ ...raw, archivePath: value }),
98+
scrapMode: (raw, value) => ({ ...raw, scrapMode: value }),
9699
label: (raw, value) => ({ ...raw, label: value }),
97100
token: (raw, value) => ({ ...raw, token: value }),
98101
scopes: (raw, value) => ({ ...raw, scopes: value }),

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

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,44 @@ const invalidScrapAction = (value: string): ParseError => ({
1515
reason: `unknown action: ${value}`
1616
})
1717

18-
const defaultArchivePath = ".orch/scrap/workspace.tar.gz"
18+
const defaultCacheArchivePath = ".orch/scrap/workspace.tar.gz"
19+
const defaultSessionArchiveDir = ".orch/scrap/session"
1920

20-
const makeScrapExportCommand = (projectDir: string, archivePath: string): Command => ({
21+
const invalidScrapMode = (value: string): ParseError => ({
22+
_tag: "InvalidOption",
23+
option: "--mode",
24+
reason: `unknown value: ${value} (expected cache|session)`
25+
})
26+
27+
const parseScrapMode = (raw: string | undefined): Either.Either<"cache" | "session", ParseError> => {
28+
const value = raw?.trim()
29+
if (!value || value.length === 0) {
30+
return Either.right("cache")
31+
}
32+
if (value === "cache" || value === "session") {
33+
return Either.right(value)
34+
}
35+
return Either.left(invalidScrapMode(value))
36+
}
37+
38+
const makeScrapExportCommand = (projectDir: string, archivePath: string, mode: "cache" | "session"): Command => ({
2139
_tag: "ScrapExport",
2240
projectDir,
23-
archivePath
41+
archivePath,
42+
mode
2443
})
2544

2645
const makeScrapImportCommand = (
2746
projectDir: string,
2847
archivePath: string,
29-
wipe: boolean
48+
wipe: boolean,
49+
mode: "cache" | "session"
3050
): Command => ({
3151
_tag: "ScrapImport",
3252
projectDir,
3353
archivePath,
34-
wipe
54+
wipe,
55+
mode
3556
})
3657

3758
// CHANGE: parse scrap (workspace cache) export/import commands
@@ -56,21 +77,27 @@ export const parseScrap = (args: ReadonlyArray<string>): Either.Either<Command,
5677
Match.when(
5778
"export",
5879
() =>
59-
Either.map(parseProjectDirWithOptions(rest), ({ projectDir, raw }) =>
60-
makeScrapExportCommand(
61-
projectDir,
62-
raw.archivePath?.trim() && raw.archivePath.trim().length > 0
63-
? raw.archivePath.trim()
64-
: defaultArchivePath
65-
))
80+
Either.flatMap(
81+
parseProjectDirWithOptions(rest),
82+
({ projectDir, raw }) =>
83+
Either.map(parseScrapMode(raw.scrapMode), (mode) => {
84+
const archivePathRaw = raw.archivePath?.trim()
85+
if (archivePathRaw && archivePathRaw.length > 0) {
86+
return makeScrapExportCommand(projectDir, archivePathRaw, mode)
87+
}
88+
const defaultPath = mode === "session" ? defaultSessionArchiveDir : defaultCacheArchivePath
89+
return makeScrapExportCommand(projectDir, defaultPath, mode)
90+
})
91+
)
6692
),
6793
Match.when("import", () =>
6894
Either.flatMap(parseProjectDirWithOptions(rest), ({ projectDir, raw }) => {
6995
const archivePath = raw.archivePath?.trim()
7096
if (!archivePath || archivePath.length === 0) {
7197
return Either.left(missingRequired("--archive"))
7298
}
73-
return Either.right(makeScrapImportCommand(projectDir, archivePath, raw.wipe ?? true))
99+
return Either.map(parseScrapMode(raw.scrapMode), (mode) =>
100+
makeScrapImportCommand(projectDir, archivePath, raw.wipe ?? true, mode))
74101
})),
75102
Match.orElse(() => Either.left(invalidScrapAction(action)))
76103
)

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Commands:
2222
clone Create + run container and clone repo
2323
attach, tmux Open tmux workspace for a docker-git project
2424
panes, terms List tmux panes for a docker-git project
25-
scrap Export/import workspace cache (dependencies, .env, build artifacts)
25+
scrap Export/import project scrap (cache archive or session snapshot)
2626
sessions List/kill/log container terminal processes
2727
ps, status Show docker compose status for all docker-git projects
2828
down-all Stop all docker-git containers (docker compose down)
@@ -46,7 +46,8 @@ Options:
4646
--codex-home <path> Container path for Codex auth (default: /home/dev/.codex)
4747
--out-dir <path> Output directory (default: <projectsRoot>/<org>/<repo>[/issue-<id>|/pr-<id>])
4848
--project-dir <path> Project directory for attach (default: .)
49-
--archive <path> Scrap archive path (export: output, import: input; default: .orch/scrap/workspace.tar.gz)
49+
--archive <path> Scrap archive path (cache: file, session: directory)
50+
--mode <cache|session> Scrap mode (default: cache)
5051
--wipe | --no-wipe Wipe workspace before scrap import (default: --wipe)
5152
--lines <n> Tail last N lines for sessions logs (default: 200)
5253
--include-default Show default/system processes in sessions list

packages/lib/src/core/command-options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface RawOptions {
2727
readonly codexHome?: string
2828
readonly enableMcpPlaywright?: boolean
2929
readonly archivePath?: string
30+
readonly scrapMode?: string
3031
readonly wipe?: boolean
3132
readonly label?: string
3233
readonly token?: string

packages/lib/src/core/domain.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,17 +71,21 @@ export interface SessionsLogsCommand {
7171
readonly lines: number
7272
}
7373

74+
export type ScrapMode = "cache" | "session"
75+
7476
export interface ScrapExportCommand {
7577
readonly _tag: "ScrapExport"
7678
readonly projectDir: string
7779
readonly archivePath: string
80+
readonly mode: ScrapMode
7881
}
7982

8083
export interface ScrapImportCommand {
8184
readonly _tag: "ScrapImport"
8285
readonly projectDir: string
8386
readonly archivePath: string
8487
readonly wipe: boolean
88+
readonly mode: ScrapMode
8589
}
8690

8791
export interface HelpCommand {

packages/lib/src/shell/errors.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ export class ScrapArchiveNotFoundError extends Data.TaggedError("ScrapArchiveNot
5656
readonly path: string
5757
}> {}
5858

59+
export class ScrapArchiveInvalidError extends Data.TaggedError("ScrapArchiveInvalidError")<{
60+
readonly path: string
61+
readonly message: string
62+
}> {}
63+
5964
export class ScrapTargetDirUnsupportedError extends Data.TaggedError("ScrapTargetDirUnsupportedError")<{
6065
readonly sshUser: string
6166
readonly targetDir: string

packages/lib/src/usecases/errors.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
InputCancelledError,
1515
InputReadError,
1616
PortProbeError,
17+
ScrapArchiveInvalidError,
1718
ScrapArchiveNotFoundError,
1819
ScrapTargetDirUnsupportedError,
1920
ScrapWipeRefusedError
@@ -27,6 +28,7 @@ export type AppError =
2728
| DockerCommandError
2829
| ConfigNotFoundError
2930
| ConfigDecodeError
31+
| ScrapArchiveInvalidError
3032
| ScrapArchiveNotFoundError
3133
| ScrapTargetDirUnsupportedError
3234
| ScrapWipeRefusedError
@@ -79,6 +81,10 @@ const renderPrimaryError = (error: NonParseError): string | null =>
7981
{ _tag: "ScrapArchiveNotFoundError" },
8082
({ path }) => `Scrap archive not found: ${path} (run docker-git scrap export first)`
8183
),
84+
Match.when(
85+
{ _tag: "ScrapArchiveInvalidError" },
86+
({ message, path }) => `Invalid scrap archive: ${path}\nDetails: ${message}`
87+
),
8288
Match.when({ _tag: "ScrapTargetDirUnsupportedError" }, ({ reason, sshUser, targetDir }) =>
8389
[
8490
`Cannot use scrap with targetDir ${targetDir}.`,
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { Effect } from "effect"
2+
3+
import type { PlatformError } from "@effect/platform/Error"
4+
import type { FileSystem as Fs } from "@effect/platform/FileSystem"
5+
import type { Path as PathService } from "@effect/platform/Path"
6+
7+
import type { ScrapExportCommand, ScrapImportCommand } from "../core/domain.js"
8+
import { readProjectConfig } from "../shell/config.js"
9+
import { ScrapArchiveInvalidError, ScrapArchiveNotFoundError } from "../shell/errors.js"
10+
import { resolveBaseDir } from "../shell/paths.js"
11+
import { resolvePathFromCwd } from "./path-helpers.js"
12+
import {
13+
chunkManifestSuffix,
14+
decodeChunkManifest,
15+
listChunkParts,
16+
maxGitBlobBytes,
17+
removeChunkArtifacts,
18+
sumFileSizes,
19+
writeChunkManifest
20+
} from "./scrap-chunks.js"
21+
import { buildScrapTemplate, eitherToEffect, ensureSafeScrapImportWipe, runShell, shellEscape } from "./scrap-common.js"
22+
import { deriveScrapWorkspaceRelativePath } from "./scrap-path.js"
23+
import type { ScrapError, ScrapRequirements } from "./scrap-types.js"
24+
25+
const scrapImage = "alpine:3.20"
26+
27+
type CacheArchiveInput = {
28+
readonly baseAbs: string
29+
readonly partsAbs: ReadonlyArray<string>
30+
}
31+
32+
const workspacePathFromRelative = (relative: string): string =>
33+
relative.length === 0 ? "/volume" : `/volume/${relative}`
34+
35+
const resolveCacheArchiveInput = (
36+
fs: Fs,
37+
path: PathService,
38+
projectDir: string,
39+
archivePath: string
40+
): Effect.Effect<CacheArchiveInput, ScrapArchiveNotFoundError | ScrapArchiveInvalidError | PlatformError> =>
41+
Effect.gen(function*(_) {
42+
const baseAbs = resolvePathFromCwd(path, projectDir, archivePath)
43+
const exists = yield* _(fs.exists(baseAbs))
44+
if (exists) {
45+
const stat = yield* _(fs.stat(baseAbs))
46+
if (stat.type === "File") {
47+
return { baseAbs, partsAbs: [baseAbs] }
48+
}
49+
}
50+
51+
const manifestAbs = `${baseAbs}${chunkManifestSuffix}`
52+
const manifestExists = yield* _(fs.exists(manifestAbs))
53+
if (!manifestExists) {
54+
return yield* _(Effect.fail(new ScrapArchiveNotFoundError({ path: baseAbs })))
55+
}
56+
57+
const manifestText = yield* _(fs.readFileString(manifestAbs))
58+
const manifest = yield* _(decodeChunkManifest(manifestAbs, manifestText))
59+
if (manifest.parts.length === 0) {
60+
return yield* _(
61+
Effect.fail(new ScrapArchiveInvalidError({ path: manifestAbs, message: "manifest.parts is empty" }))
62+
)
63+
}
64+
65+
const dir = path.dirname(baseAbs)
66+
const partsAbs = manifest.parts.map((part) => path.join(dir, part))
67+
for (const partAbs of partsAbs) {
68+
const partExists = yield* _(fs.exists(partAbs))
69+
if (!partExists) {
70+
return yield* _(Effect.fail(new ScrapArchiveNotFoundError({ path: partAbs })))
71+
}
72+
}
73+
74+
return { baseAbs, partsAbs }
75+
})
76+
77+
const buildCacheExportScript = (volumeName: string, workspacePath: string, partsPrefix: string): string => {
78+
const volumeMount = `${volumeName}:/volume:ro`
79+
const innerScript = [
80+
"set -e",
81+
`SRC=${shellEscape(workspacePath)}`,
82+
"if [ ! -d \"$SRC\" ]; then echo \"Workspace dir not found: $SRC\" >&2; exit 2; fi",
83+
"tar czf - -C \"$SRC\" ."
84+
].join("; ")
85+
86+
return [
87+
"set -e",
88+
`docker run --rm --user 1000:1000 -v ${shellEscape(volumeMount)} ${scrapImage} sh -lc ${shellEscape(innerScript)}`,
89+
`| split -b ${maxGitBlobBytes} -d -a 5 - ${shellEscape(partsPrefix)}`
90+
].join(" ")
91+
}
92+
93+
export const exportScrapCache = (
94+
command: ScrapExportCommand
95+
): Effect.Effect<void, ScrapError, ScrapRequirements> =>
96+
Effect.gen(function*(_) {
97+
const { fs, path, resolved } = yield* _(resolveBaseDir(command.projectDir))
98+
const config = yield* _(readProjectConfig(resolved))
99+
const template = buildScrapTemplate(config)
100+
101+
const relative = yield* _(eitherToEffect(deriveScrapWorkspaceRelativePath(template.sshUser, template.targetDir)))
102+
const workspacePath = workspacePathFromRelative(relative)
103+
104+
const archiveAbs = resolvePathFromCwd(path, resolved, command.archivePath)
105+
const archiveDir = path.dirname(archiveAbs)
106+
const archiveBase = path.basename(archiveAbs)
107+
const partsPrefix = `${archiveAbs}.part`
108+
109+
yield* _(fs.makeDirectory(archiveDir, { recursive: true }))
110+
yield* _(removeChunkArtifacts(fs, path, archiveAbs))
111+
yield* _(fs.remove(archiveAbs, { force: true }))
112+
113+
yield* _(
114+
Effect.log(
115+
[
116+
`Project: ${resolved}`,
117+
"Mode: cache",
118+
`Volume: ${template.volumeName}`,
119+
`Workspace: ${template.targetDir}`,
120+
`Archive: ${archiveAbs} (+parts, max ${maxGitBlobBytes} bytes each)`
121+
].join("\n")
122+
)
123+
)
124+
125+
const script = buildCacheExportScript(template.volumeName, workspacePath, partsPrefix)
126+
yield* _(runShell(resolved, "scrap export cache", script))
127+
128+
const partsAbs = yield* _(listChunkParts(fs, path, archiveAbs))
129+
const totalSize = yield* _(sumFileSizes(fs, partsAbs))
130+
yield* _(writeChunkManifest(fs, path, archiveAbs, totalSize, partsAbs))
131+
132+
yield* _(Effect.log(`Scrap cache export complete: ${archiveBase}${chunkManifestSuffix}`))
133+
}).pipe(Effect.asVoid)
134+
135+
const buildCacheImportScript = (
136+
volumeName: string,
137+
workspacePath: string,
138+
wipe: boolean
139+
): { readonly dockerRun: string; readonly innerScript: string } => {
140+
const wipeLine = wipe ? "rm -rf \"$DST\"" : ":"
141+
const innerScript = [
142+
"set -e",
143+
`DST=${shellEscape(workspacePath)}`,
144+
wipeLine,
145+
"mkdir -p \"$DST\"",
146+
"tar xzf - -C \"$DST\""
147+
].join("; ")
148+
149+
const volumeMount = `${volumeName}:/volume`
150+
const dockerRun = [
151+
"docker run --rm -i",
152+
"--user 1000:1000",
153+
`-v ${shellEscape(volumeMount)}`,
154+
scrapImage,
155+
"sh -lc",
156+
shellEscape(innerScript)
157+
].join(" ")
158+
159+
return { dockerRun, innerScript }
160+
}
161+
162+
export const importScrapCache = (
163+
command: ScrapImportCommand
164+
): Effect.Effect<void, ScrapError, ScrapRequirements> =>
165+
Effect.gen(function*(_) {
166+
const { fs, path, resolved } = yield* _(resolveBaseDir(command.projectDir))
167+
const config = yield* _(readProjectConfig(resolved))
168+
const template = buildScrapTemplate(config)
169+
170+
const relative = yield* _(eitherToEffect(deriveScrapWorkspaceRelativePath(template.sshUser, template.targetDir)))
171+
yield* _(ensureSafeScrapImportWipe(command.wipe, template, relative))
172+
const workspacePath = workspacePathFromRelative(relative)
173+
174+
const archiveInput = yield* _(resolveCacheArchiveInput(fs, path, resolved, command.archivePath))
175+
176+
yield* _(
177+
Effect.log(
178+
[
179+
`Project: ${resolved}`,
180+
"Mode: cache",
181+
`Volume: ${template.volumeName}`,
182+
`Workspace: ${template.targetDir}`,
183+
`Archive: ${archiveInput.baseAbs}`,
184+
`Wipe: ${command.wipe ? "yes" : "no"}`
185+
].join("\n")
186+
)
187+
)
188+
189+
const { dockerRun } = buildCacheImportScript(template.volumeName, workspacePath, command.wipe)
190+
const catArgs = archiveInput.partsAbs.map((p) => shellEscape(p)).join(" ")
191+
const script = ["set -e", `cat ${catArgs} | ${dockerRun}`].join("; ")
192+
yield* _(runShell(resolved, "scrap import cache", script))
193+
194+
yield* _(Effect.log("Scrap cache import complete."))
195+
}).pipe(Effect.asVoid)

0 commit comments

Comments
 (0)