Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ watch 啟動時載入 `.wspc-drive/ignore` 的 exclude rules;本機 fs event
- **State 欄位**:`DriveState` 新增 optional `upload_rejections?: Record<string, DriveUploadRejection>`,`DriveUploadRejection` 為 `{ mtime_ms: number; size_bytes: number; sha256: string; code: string; message: string; cli_version: string; rejected_at: string }`。`rejected_at` 用 `driveIsoTimestamp(clock)`。`isValidDriveState()` 驗證此欄位與每筆紀錄形狀,比照 `scan_errors`。空 record 時刪除欄位,不寫空物件。`schema_version` 不變。
- **CLI version**:使用 `src/version.ts` 的 `VERSION`。
- **判定**:在 `retry.ts` 新增 `isPermanentUploadRejection(error)`:`error instanceof DriveHttpError`、`400 <= status < 500`、status 不在 `{401, 403, 408, 409, 429}`、`code !== "VERSION_CONFLICT"`。只在 `executeDrivePathAction()` 的 upload 分支、`api.uploadFile()` 丟出時判定;讀檔(`readStableUploadBody`)失敗不判定。
- **記錄**:upload 分支 catch 到 Permanent Upload Rejection 時,以 `state.scan_cache[path]` 為 fingerprint;若沒有 cache entry 或其 `sha256` 與本次 upload digest 不同,則不記錄(本輪 path error 照舊)。寫入 `state.json` 後回傳新 state,並照現行 `recordDrivePathError()` 回報。上傳成功分支刪除該 path 紀錄。
- **略過與清除**:`runDriveSyncOnce()` 在 scan 完成、`applyRenamesAsMoves()` 之後、計算 progress `total` 之前,針對每筆紀錄:path 不在 `localFiles`、scan cache fingerprint 不同或 `cli_version !== VERSION` 就刪除;其餘若本輪決策為 `upload_create`/`upload_update`,將 path 加入略過集合,並以 `recordDrivePathError(..., { op: "upload_rejected", pathError: { path, code, message, retryable: false } })` 回報。略過集合的 path 不計入 `total`、不呼叫 `executeDrivePathAction()`。紀錄有變動才寫 `state.json`。`removeExcludedState()` 一併移除 excluded path 的紀錄。Incremental scan 下 `localFiles` 仍涵蓋完整本機 view(由 scan cache 補齊),故上述判斷在 local-trigger 輪次同樣成立。
- **記錄**:upload 分支 catch 到 Permanent Upload Rejection 時,以 `state.scan_cache[path]` 為 fingerprint;若沒有 cache entry 或其 `sha256` 與本次 upload digest 不同,則不記錄(本輪 path error 照舊)。寫入 `state.json` 後回傳新 state,並照現行 `recordDrivePathError()` 回報。上傳成功分支不需另外刪除紀錄:會走到上傳的 path,其紀錄已在下述略過與清除步驟因 fingerprint 或 CLI version 不同而刪除。
- **略過與清除**:`runDriveSyncOnce()` 在 scan 完成、`applyRenamesAsMoves()` 之後、計算 progress `total` 之前,針對每筆紀錄:本輪 scan cache 沒有該 path(刪除、改名、被 exclude rule 排除或 scan error;scan cache 與 `localFiles` 涵蓋相同 path)、fingerprint 不同或 `cli_version !== VERSION` 就刪除;其餘若本輪決策為 `upload_create`/`upload_update`,將 path 加入略過集合,並以 `recordDrivePathError(..., { op: "upload_rejected", pathError: { path, code, message, retryable: false } })` 回報。略過集合的 path 不計入 `total`、不呼叫 `executeDrivePathAction()`。紀錄有變動才寫 `state.json`。Excluded path 不會出現在 scan cache,由此步驟清除,`removeExcludedState()` 不需另外處理。Incremental scan 下 `localFiles` 仍涵蓋完整本機 view(由 scan cache 補齊),故上述判斷在 local-trigger 輪次同樣成立。
- **Watch**:`runDriveWatch()` 啟動時以 `loadDriveExcludeRules(root)` 載入規則,失敗(包含 `DriveIgnoreError`)時使用不過濾的 matcher。`onChange` 在 internal path 與 temp artifact 判斷之後、加入 `dirtyPaths` 之前,以 `excludeRules.matches(drivePath)` 過濾;`ignore` event 先重新載入規則再排程 full sync。`path === undefined` 的 event 維持 full reconciliation。
- **Debug log**:略過時沿用 `error` debug event,`op: "upload_rejected"`,方便與實際上傳失敗(`op: "process"`)區分。
- **Rollout/rollback**:隨下一個 CLI minor release 發佈,wspc-drive 升級內附 CLI 即生效,不需 app 改動。舊版 CLI 讀到含 `upload_rejections` 的 `state.json` 時,現行 validator 忽略未知欄位,回退只會恢復舊的重傳行為。
Expand Down
2 changes: 1 addition & 1 deletion src/handwritten/commands/drive/exclude-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface DriveExcludeRules {
matches(path: string, kind?: "file" | "directory"): boolean
}

const emptyDriveExcludeRules: DriveExcludeRules = {
export const emptyDriveExcludeRules: DriveExcludeRules = {
size: 0,
matches: () => false,
}
Expand Down
34 changes: 33 additions & 1 deletion src/handwritten/commands/drive/path-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import {
} from "./local-mutations.js"
import { classifyMergeText, conflictCopyPath, mergeText3 } from "./merge.js"
import { resolveInsideRoot, validateDrivePath } from "./path-policy.js"
import { VERSION } from "../../../version.js"
import {
isDriveAuthFailure,
isPermanentUploadRejection,
isRetryableDriveFailure,
type DrivePathErrorSummary,
} from "./retry.js"
Expand Down Expand Up @@ -64,7 +66,14 @@ export async function executeDrivePathAction(
if (action.type === "upload_create" || action.type === "upload_update") {
const localPath = resolveInsideRoot(root, path)
const { body, digest: uploadDigest } = await readStableUploadBody(localPath, local)
const uploaded = await api.uploadFile(state.library_id, path, body, uploadDigest, action.expectedEntryVersion)
let uploaded: UploadDriveFileResponse
try {
uploaded = await api.uploadFile(state.library_id, path, body, uploadDigest, action.expectedEntryVersion)
} catch (error) {
if (!isPermanentUploadRejection(error)) throw error
await recordDrivePathError(summary, undefined, path, error, { debug, op: "process" })
return { state: await rememberUploadRejection(root, state, path, uploadDigest, error, clock), stop: false }
}
durableStateRequired = true
const nextState = cloneDriveState(state)
nextState.entries[path] = stateEntryFromRemote(uploaded.entry, uploadDigest, clock)
Expand Down Expand Up @@ -504,9 +513,32 @@ export function cloneDriveState(state: DriveState): DriveState {
entries: { ...state.entries },
conflicts: { ...state.conflicts },
...(state.scan_errors === undefined ? {} : { scan_errors: { ...state.scan_errors } }),
...(state.upload_rejections === undefined ? {} : { upload_rejections: { ...state.upload_rejections } }),
}
}

// The scan cache is the fingerprint; it must describe the bytes just rejected.
async function rememberUploadRejection(
root: string,
state: DriveState,
path: string,
uploadDigest: string,
error: unknown,
clock: DriveClock,
): Promise<DriveState> {
const scanned = state.scan_cache?.[path]
if (scanned === undefined || scanned.sha256 !== uploadDigest) return state
const { mtime_ms, size_bytes, sha256 } = scanned
const { code, message } = drivePathErrorSummary(path, error)
const nextState = cloneDriveState(state)
nextState.upload_rejections = {
...nextState.upload_rejections,
[path]: { mtime_ms, size_bytes, sha256, code, message, cli_version: VERSION, rejected_at: driveIsoTimestamp(clock) },
}
await writeDriveState(root, nextState, clock)
return nextState
}

async function recordConflict(
root: string,
state: DriveState,
Expand Down
12 changes: 12 additions & 0 deletions src/handwritten/commands/drive/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,18 @@ export function isRetryableDriveFailure(error: unknown): boolean {
return classifyDriveRetry(error, 0) !== undefined
}

const NON_PERMANENT_UPLOAD_STATUSES = new Set([401, 403, 408, 409, 429])

export function isPermanentUploadRejection(error: unknown): boolean {
return (
error instanceof DriveHttpError &&
error.status >= 400 &&
error.status < 500 &&
!NON_PERMANENT_UPLOAD_STATUSES.has(error.status) &&
error.code !== "VERSION_CONFLICT"
)
}

export function isDriveAuthFailure(error: unknown): boolean {
const failure = error instanceof DriveRetryableSyncError ? error.cause : error
const status = failure instanceof DriveHttpError ? failure.status : structuredStatus(failure)
Expand Down
32 changes: 32 additions & 0 deletions src/handwritten/commands/drive/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ export interface DriveScanError {
retryable: boolean
}

export interface DriveUploadRejection {
mtime_ms: number
size_bytes: number
sha256: string
code: string
message: string
cli_version: string
rejected_at: string
}

export interface DriveState {
schema_version: 1
library_id: string
Expand All @@ -59,6 +69,7 @@ export interface DriveState {
realtime?: DriveRealtimeState
scan_cache?: Record<string, DriveScanCacheEntry>
scan_errors?: Record<string, DriveScanError>
upload_rejections?: Record<string, DriveUploadRejection>
// Latest manifest cursor seen; enables since_cursor delta fetches.
manifest_cursor?: string
}
Expand Down Expand Up @@ -313,6 +324,7 @@ function isValidDriveState(value: unknown): value is DriveState {
(value.realtime !== undefined && !isDriveRealtimeState(value.realtime)) ||
(value.scan_cache !== undefined && !isRecord(value.scan_cache)) ||
(value.scan_errors !== undefined && !isRecord(value.scan_errors)) ||
(value.upload_rejections !== undefined && !isRecord(value.upload_rejections)) ||
(value.manifest_cursor !== undefined && typeof value.manifest_cursor !== "string")
) {
return false
Expand All @@ -333,6 +345,11 @@ function isValidDriveState(value: unknown): value is DriveState {
if (!isDriveScanError(error)) return false
}
}
if (value.upload_rejections !== undefined) {
for (const rejection of Object.values(value.upload_rejections)) {
if (!isDriveUploadRejection(rejection)) return false
}
}
return true
}

Expand All @@ -355,3 +372,18 @@ function isDriveScanError(value: unknown): value is DriveScanError {
typeof value.retryable === "boolean"
)
}

function isDriveUploadRejection(value: unknown): value is DriveUploadRejection {
const allowedKeys = new Set(["mtime_ms", "size_bytes", "sha256", "code", "message", "cli_version", "rejected_at"])
return (
isRecord(value) &&
Object.keys(value).every((key) => allowedKeys.has(key)) &&
typeof value.mtime_ms === "number" &&
typeof value.size_bytes === "number" &&
typeof value.sha256 === "string" &&
typeof value.code === "string" &&
typeof value.message === "string" &&
typeof value.cli_version === "string" &&
typeof value.rejected_at === "string"
)
}
41 changes: 35 additions & 6 deletions src/handwritten/commands/drive/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type DriveStateEntry,
} from "./state.js"
import { render } from "../../output/render.js"
import { VERSION } from "../../../version.js"
import {
DriveRetryableSyncError,
isRetryableDriveFailure,
Expand Down Expand Up @@ -177,19 +178,47 @@ export async function runDriveSyncOnce(
throw error
}

const rejectedPaths = new Set<string>()
const uploadRejections = { ...state.upload_rejections }
for (const [path, rejection] of Object.entries(uploadRejections)) {
const scanned = state.scan_cache?.[path]
if (
scanned?.mtime_ms !== rejection.mtime_ms ||
scanned.size_bytes !== rejection.size_bytes ||
scanned.sha256 !== rejection.sha256 ||
rejection.cli_version !== VERSION
) {
delete uploadRejections[path]
continue
}
const action = decideDriveAction(state.entries[path], localFiles[path], remoteFiles[path])
if (action.type !== "upload_create" && action.type !== "upload_update") continue
rejectedPaths.add(path)
const pathError = { path, code: rejection.code, message: rejection.message, retryable: false }
await recordDrivePathError(summary, undefined, path, undefined, {
appendPathResult: true,
debug,
op: "upload_rejected",
pathError,
})
}
if (Object.keys(uploadRejections).length !== Object.keys(state.upload_rejections ?? {}).length) {
state = { ...state, upload_rejections: uploadRejections }
if (Object.keys(uploadRejections).length === 0) delete state.upload_rejections
await writeDriveState(root, state, clock)
}

const pendingPaths = paths.filter((path) => !movedPaths.has(path) && !rejectedPaths.has(path))
// decideDriveAction is pure and reads only this path's slices of the
// initial state, so this pre-pass total matches the loop's actions.
const total = paths.filter(
(path) =>
!movedPaths.has(path) &&
isActionableAction(decideDriveAction(state.entries[path], localFiles[path], remoteFiles[path])),
const total = pendingPaths.filter((path) =>
isActionableAction(decideDriveAction(state.entries[path], localFiles[path], remoteFiles[path])),
).length
let processed = 0
onProgress?.(processed, total)

const processStartedMs = Date.now()
for (const path of paths) {
if (movedPaths.has(path)) continue
for (const path of pendingPaths) {
const remote = remoteFiles[path]
const local = localFiles[path]
const action = decideDriveAction(state.entries[path], local, remote)
Expand Down
16 changes: 15 additions & 1 deletion src/handwritten/commands/drive/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { basename, relative, resolve } from "node:path"
import { loadRealtimeAuthHeaders } from "../../auth/load-sdk-client.js"
import { render, shouldOutputJson } from "../../output/render.js"
import { createDriveDebugLogger, noopDriveDebugLogger, type DriveDebugLogger } from "./debug-log.js"
import { emptyDriveExcludeRules, loadDriveExcludeRules, type DriveExcludeRules } from "./exclude-rules.js"
import { createDriveRealtimeSource } from "./realtime.js"
import { isInternalSyncArtifactName } from "./scanner.js"
import { DRIVE_DIR, ensureDriveRealtimeState, readDriveState, writeDriveRealtimeState } from "./state.js"
Expand Down Expand Up @@ -96,6 +97,17 @@ export async function runDriveWatch(root: string, options: DriveWatchOptions = {
let stopWatch: (() => void) | undefined
let stopError: unknown
let cleanupSignalListeners = () => {}
let excludeRules: DriveExcludeRules = emptyDriveExcludeRules
let excludeRulesLoad = Promise.resolve()

// Serialized so an older ignore read can never overwrite a newer one. An
// unparsable ignore file filters nothing; sync reports the parse error.
function reloadExcludeRules(): Promise<void> {
excludeRulesLoad = excludeRulesLoad.then(async () => {
excludeRules = await loadDriveExcludeRules(root).catch(() => emptyDriveExcludeRules)
})
return excludeRulesLoad
}

async function requestSync(): Promise<void> {
if (stopped) return
Expand Down Expand Up @@ -252,6 +264,7 @@ export async function runDriveWatch(root: string, options: DriveWatchOptions = {
writeRealtimeState: (next) => writeDriveRealtimeState(root, next),
})
}
await reloadExcludeRules()
source = options.source ?? createDefaultWatchSource(root)
source.onChange((path) => {
if (path === undefined) {
Expand All @@ -264,13 +277,14 @@ export async function runDriveWatch(root: string, options: DriveWatchOptions = {
if (drivePath === `${DRIVE_DIR}/ignore`) {
fullReconciliationRequired = true
dbg.log("fs_event", { path: drivePath, full_reconciliation: true })
scheduleSync(debounceMs, "ignore")
void reloadExcludeRules().then(() => scheduleSync(debounceMs, "ignore"))
return
}
if (isDriveInternalPath(root, path)) return
// Download/backup/merge temp files are our own writes; reacting to them
// would chain an extra sync after every applied remote change.
if (isInternalSyncArtifactName(basename(path))) return
if (excludeRules.matches(drivePath)) return
dirtyPaths.add(drivePath)
dbg.log("fs_event", { path: drivePath })
scheduleSync(debounceMs, "local")
Expand Down
21 changes: 20 additions & 1 deletion test/handwritten/drive/retry.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"
import { DateTime } from "luxon"
import { DriveHttpError, classifyDriveRetry, parseRetryAfter } from "../../../src/handwritten/commands/drive/retry.js"
import { DriveHttpError, classifyDriveRetry, isPermanentUploadRejection, parseRetryAfter } from "../../../src/handwritten/commands/drive/retry.js"

describe("Drive retry policy", () => {
it("parses Retry-After delta seconds", () => {
Expand Down Expand Up @@ -47,4 +47,23 @@ describe("Drive retry policy", () => {

expect(classifyDriveRetry(reset, 1_000)).toEqual({ reason: "transient", delayMs: 1_000 })
})

it.each([
[new DriveHttpError(400), true],
[new DriveHttpError(413), true],
[new DriveHttpError(422, { code: "FILE_TOO_LARGE" }), true],
[new DriveHttpError(401), false],
[new DriveHttpError(403), false],
[new DriveHttpError(408), false],
[new DriveHttpError(409), false],
[new DriveHttpError(429), false],
[new DriveHttpError(412, { code: "VERSION_CONFLICT" }), false],
[new DriveHttpError(399), false],
[new DriveHttpError(500), false],
[new DriveHttpError(502), false],
[Object.assign(new Error("HTTP 413"), { status: 413 }), false],
[new Error("local file changed after scan"), false],
])("classifies %s as a permanent upload rejection: %s", (error, expected) => {
expect(isPermanentUploadRejection(error)).toBe(expected)
})
})
57 changes: 57 additions & 0 deletions test/handwritten/drive/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,63 @@ describe("drive state", () => {
}
})

it("round-trips upload rejections in schema version 1", async () => {
const root = await mkdtemp(join(tmpdir(), "wspc-drive-state-upload-rejections-"))
const state = await initDriveState(root, "lib_1")
state.upload_rejections = {
"big.jsonl": {
mtime_ms: 1_789_000_000_000,
size_bytes: 291_000_000,
sha256: "a".repeat(64),
code: "DRIVE_PATH_ERROR",
message: "HTTP 413",
cli_version: "0.12.0",
rejected_at: "2026-09-18T00:00:00.000Z",
},
}

await writeDriveState(root, state)

expect(await readDriveState(root)).toMatchObject({
schema_version: 1,
upload_rejections: state.upload_rejections,
})
})

it("rejects malformed upload rejection records", async () => {
const root = await mkdtemp(join(tmpdir(), "wspc-drive-state-bad-upload-rejections-"))
const path = join(root, ".wspc-drive", "state.json")
await mkdir(join(root, ".wspc-drive"), { recursive: true })
const baseState = {
schema_version: 1,
library_id: "lib_1",
created_at: "2026-06-21T00:00:00.000Z",
updated_at: "2026-06-21T00:00:00.000Z",
entries: {},
conflicts: {},
}
const valid = {
mtime_ms: 1,
size_bytes: 2,
sha256: "a".repeat(64),
code: "DRIVE_PATH_ERROR",
message: "HTTP 413",
cli_version: "0.12.0",
rejected_at: "2026-09-18T00:00:00.000Z",
}
const malformed = [
["big.jsonl"],
{ "big.jsonl": { ...valid, size_bytes: "2" } },
{ "big.jsonl": { ...valid, cli_version: undefined } },
{ "big.jsonl": { ...valid, response_body: "secret" } },
]

for (const uploadRejections of malformed) {
await writeFile(path, JSON.stringify({ ...baseState, upload_rejections: uploadRejections }), { mode: 0o600 })
await expect(readDriveState(root)).rejects.toThrow(/unsupported \.wspc-drive\/state\.json schema/)
}
})

it("rejects malformed extended conflict metadata", async () => {
const root = await mkdtemp(join(tmpdir(), "wspc-drive-state-bad-conflict-meta-"))
await mkdir(join(root, ".wspc-drive"), { recursive: true })
Expand Down
Loading
Loading