@wuyaos/pi-sync 1.2.1 → 1.3.0

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.
@@ -1,9 +1,35 @@
1
- import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { type ExtensionAPI, type ExtensionContext, type SessionShutdownEvent } from "@earendil-works/pi-coding-agent";
2
+ import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
 
4
- import { isProjectAllowed, loadConfig, refreshFooterStatusFromConfig } from "./config";
5
+ import { AGENT_DIR, isProjectAllowed, loadConfig, refreshFooterStatusFromConfig, type SyncConfig } from "./config";
6
+ import { ensureDir } from "../_shared/json-io";
5
7
  import { registerSyncCommand, uploadSessionProjectArchive } from "./menus";
6
8
 
9
+ /** 退出时自动上传会话归档的最小间隔:频繁重启不应耗尽 maxBackups 把含历史的老归档轮替掉。 */
10
+ export const EXIT_UPLOAD_MIN_INTERVAL_MS = 30 * 60 * 1000;
11
+
12
+ const exitUploadMarkerPath = path.join(AGENT_DIR, "state", "pi-sync-last-exit-upload.txt");
13
+
14
+ /**
15
+ * 判断退出自动上传是否到期。marker 不存在或距上次上传超过 minIntervalMs 时到期。
16
+ * 独立导出便于测试。
17
+ */
18
+ export function exitUploadDue(markerPath: string, nowMs: number, minIntervalMs = EXIT_UPLOAD_MIN_INTERVAL_MS): boolean {
19
+ try {
20
+ const last = Number(fs.readFileSync(markerPath, "utf8").trim());
21
+ if (!Number.isFinite(last) || last <= 0) return true;
22
+ return nowMs - last >= minIntervalMs;
23
+ } catch {
24
+ return true;
25
+ }
26
+ }
27
+
28
+ export function recordExitUpload(markerPath: string, nowMs: number): void {
29
+ ensureDir(path.dirname(markerPath));
30
+ fs.writeFileSync(markerPath, String(nowMs));
31
+ }
32
+
7
33
  export function projectDirFromSessionDir(sessionDir: string | undefined): string | undefined {
8
34
  if (!sessionDir) return undefined;
9
35
  const projectDir = path.basename(sessionDir);
@@ -11,25 +37,85 @@ export function projectDirFromSessionDir(sessionDir: string | undefined): string
11
37
  }
12
38
 
13
39
  /**
14
- * Archive-only pi-sync entrypoint.
40
+ * Archive-only pi-sync entrypoint
15
41
  *
16
- * Runtime work is intentionally limited to one cached config read at startup
17
- * and one current-project archive on shutdown. There are no per-turn hooks,
18
- * timers, live uploads, or interval counters.
42
+ * 运行时工作仅限:启动时读一次缓存配置 + 关停时的会话归档(quit 同步等待,
43
+ * reload/new/resume/fork 转后台,不阻塞 reload)。无 per-turn 钩子、无定时器、无常驻上传。
19
44
  */
45
+ let exitUploadInFlight = false;
46
+
47
+ /** 测试用:重置后台任务标志。 */
48
+ export function resetExitUploadInFlight(): void {
49
+ exitUploadInFlight = false;
50
+ }
51
+
52
+ export interface ExitUploadDeps {
53
+ upload?: typeof uploadSessionProjectArchive;
54
+ config?: SyncConfig;
55
+ now?: number;
56
+ markerPath?: string;
57
+ }
58
+
59
+ /**
60
+ * 退出自动上传会话归档。
61
+ * quit:进程即将终止,同步等待上传完成(fire-and-forget 会被终止)。
62
+ * reload/new/resume/fork:进程存活,上传转入后台,不阻塞 reload;
63
+ * 旧 runner 的 ctx.signal 在 teardown 后会失效,后台任务必须用独立 AbortController。
64
+ */
65
+ export async function handleSessionShutdown(
66
+ event: Pick<SessionShutdownEvent, "reason">,
67
+ ctx: ExtensionContext,
68
+ deps: ExitUploadDeps = {},
69
+ ): Promise<void> {
70
+ const config = deps.config ?? loadConfig();
71
+ if (!config.backupOnExit || !config.backupSessions) return;
72
+ if (!config.webdavUrl || !config.webdavUser || !config.webdavPass) return;
73
+ const projectDir = projectDirFromSessionDir(ctx.sessionManager.getSessionDir());
74
+ if (!projectDir || !isProjectAllowed(projectDir, config)) return;
75
+ const now = deps.now ?? Date.now();
76
+ const markerPath = deps.markerPath ?? exitUploadMarkerPath;
77
+ if (!exitUploadDue(markerPath, now)) return;
78
+
79
+ const upload = deps.upload ?? uploadSessionProjectArchive;
80
+ const finish = (uploaded: boolean): void => {
81
+ if (uploaded) recordExitUpload(markerPath, now);
82
+ };
83
+
84
+ if (event.reason === "quit") {
85
+ finish(await upload(ctx, config, projectDir, true));
86
+ return;
87
+ }
88
+
89
+ if (exitUploadInFlight) return;
90
+ exitUploadInFlight = true;
91
+ const controller = new AbortController();
92
+ const detachedCtx = {
93
+ signal: controller.signal,
94
+ ui: {
95
+ notify: (message: string, type?: "info" | "warning" | "error"): void => {
96
+ try { ctx.ui.notify(message, type); } catch { /* UI 可能已随 reload 重建 */ }
97
+ },
98
+ },
99
+ } as unknown as ExtensionContext;
100
+ void (async (): Promise<void> => {
101
+ try {
102
+ finish(await upload(detachedCtx, config, projectDir, false));
103
+ } catch {
104
+ // 后台归档失败只影响本次节流窗口,不影响会话;marker 不写入,下个窗口重试。
105
+ } finally {
106
+ exitUploadInFlight = false;
107
+ }
108
+ })();
109
+ }
110
+
20
111
  export default function registerSyncExtension(pi: ExtensionAPI): void {
21
112
  pi.on("session_start", async (_event, ctx) => {
22
113
  const config = loadConfig();
23
114
  refreshFooterStatusFromConfig(ctx, config);
24
115
  });
25
116
 
26
- pi.on("session_shutdown", async (_event, ctx) => {
27
- const config = loadConfig();
28
- if (!config.backupOnExit || !config.backupSessions) return;
29
- if (!config.webdavUrl || !config.webdavUser || !config.webdavPass) return;
30
- const projectDir = projectDirFromSessionDir(ctx.sessionManager.getSessionDir());
31
- if (!projectDir || !isProjectAllowed(projectDir, config)) return;
32
- await uploadSessionProjectArchive(ctx, config, projectDir, true);
117
+ pi.on("session_shutdown", async (event, ctx) => {
118
+ await handleSessionShutdown(event, ctx);
33
119
  });
34
120
 
35
121
  registerSyncCommand(pi);
@@ -12,7 +12,15 @@ export const WEBDAV_AGENT_SKILLS_DIR = "backup/skills/";
12
12
  export const WEBDAV_SESSIONS_ARCHIVE_DIR = "backup/sessions/";
13
13
 
14
14
  export const ensureTrailingSlash = (url: string): string => url.endsWith("/") ? url : `${url}/`;
15
- export const webdavDirBase = (config: SyncConfig, remoteDir: string): string => ensureTrailingSlash(config.webdavUrl) + remoteDir.replace(/^\/+/, "");
15
+ /**
16
+ * 拼接 WebDAV 目录 URL。remoteDir 逐段 encodeURIComponent,与 ensureWebdavDirectory
17
+ * 的编码一致;否则含中文/空格的 cwd 转义目录在严格 WebDAV 实现上会 400。
18
+ */
19
+ export const webdavDirBase = (config: SyncConfig, remoteDir: string): string =>
20
+ remoteDir.split("/").filter(Boolean).reduce(
21
+ (url, segment) => `${url}${encodeURIComponent(segment)}/`,
22
+ ensureTrailingSlash(config.webdavUrl),
23
+ );
16
24
  export const webdavAuth = (config: SyncConfig): string => "Basic " + Buffer.from(`${config.webdavUser}:${resolvePassword(config.webdavPass)}`).toString("base64");
17
25
 
18
26
  export async function webdavList(url: string, auth: string, ctx: ExtensionContext, filter?: (name: string) => boolean): Promise<string[]> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wuyaos/pi-sync",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "WebDAV archive backup and restore for Pi agent data, shared skills, and per-project sessions",
5
5
  "keywords": [
6
6
  "pi-package",