@dimina-kit/fs-core 0.2.0-dev.20260711062001 → 0.3.0-dev.6-dev.20260711140249

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.
Files changed (51) hide show
  1. package/README.md +205 -18
  2. package/dist/client.js +23 -11
  3. package/dist/disk-mirror.js +2 -2
  4. package/dist/fs-core.worker.js +101 -21
  5. package/dist/sync/binary-sidecar.js +147 -0
  6. package/dist/sync/sync-engine.js +119 -169
  7. package/dist/sync/watch-expander.js +202 -0
  8. package/dist/worker-files.cjs +32 -0
  9. package/dist/worker-files.js +35 -0
  10. package/dist/worker-lib/.tsbuildinfo +1 -0
  11. package/dist/worker-lib/engine-shared.d.ts +79 -0
  12. package/dist/worker-lib/engine-shared.d.ts.map +1 -0
  13. package/dist/worker-lib/engine-shared.js +6 -3
  14. package/dist/worker-lib/paths.d.ts +4 -0
  15. package/dist/worker-lib/paths.d.ts.map +1 -0
  16. package/dist/worker-lib/protocol.d.ts +119 -0
  17. package/dist/worker-lib/protocol.d.ts.map +1 -0
  18. package/dist/worker-lib/protocol.js +73 -0
  19. package/dist/worker-lib/rpc-types.d.ts +68 -0
  20. package/dist/worker-lib/rpc-types.d.ts.map +1 -0
  21. package/dist/worker-lib/wal-codec.d.ts +58 -0
  22. package/dist/worker-lib/wal-codec.d.ts.map +1 -0
  23. package/dist/worker-lib/wal-codec.js +8 -5
  24. package/dist/zip.js +1 -1
  25. package/package.json +25 -2
  26. package/src/__checks__/types-smoke.ts +61 -0
  27. package/src/agent-tools.ts +3 -3
  28. package/src/client-retry.test.ts +76 -0
  29. package/src/client.ts +112 -45
  30. package/src/disk-mirror.ts +2 -2
  31. package/src/fs-core-handover.test.ts +215 -0
  32. package/src/fs-core-opid-replay.test.ts +158 -0
  33. package/src/fs-core-recovery-lock.test.ts +225 -0
  34. package/src/fs-core-recovery.ts +115 -13
  35. package/src/fs-core-write-ops.ts +14 -11
  36. package/src/fs-core.worker.ts +26 -11
  37. package/src/worker-files.test.ts +39 -0
  38. package/src/worker-files.ts +43 -0
  39. package/src/worker-lib/engine-shared.ts +19 -5
  40. package/src/worker-lib/protocol.test.ts +37 -0
  41. package/src/worker-lib/protocol.ts +184 -0
  42. package/src/worker-lib/wal-codec.ts +8 -5
  43. package/src/zip.ts +1 -1
  44. package/sync/binary-sidecar.test.ts +150 -0
  45. package/sync/binary-sidecar.ts +187 -0
  46. package/sync/sync-engine-degraded.test.ts +309 -0
  47. package/sync/sync-engine.test.ts +20 -91
  48. package/sync/sync-engine.ts +134 -181
  49. package/sync/truth-port.ts +3 -3
  50. package/sync/watch-expander.test.ts +96 -0
  51. package/sync/watch-expander.ts +236 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * fs-core wire contract — the single authoritative enumeration of the error
3
+ * codes, event names, mode values, and message shapes that cross the worker
4
+ * boundary. Both ends compile against this module (worker side:
5
+ * engine-shared.ts's `rpcErr` signature; main-thread side: client.ts), and
6
+ * consumers should match on these exported symbols instead of quoting string
7
+ * literals from worker source or citing dist line numbers — those references
8
+ * rot on every version bump while the wire strings silently stay load-bearing.
9
+ *
10
+ * Lib-neutral (types + pure predicates only) so it stays compilable by both
11
+ * the DOM and the WebWorker tsc programs — see this directory's convention in
12
+ * ../../tsconfig.json.
13
+ */
14
+ /**
15
+ * Single-writer lifecycle of one fs-core worker, as observed by a client:
16
+ * starting (HELLO sent, WELCOME not yet received) | writer (holds the Web
17
+ * Locks writer lease) | readonly (queued for the lease, or handed it over) |
18
+ * draining (worker-side handover transient — a client never observes it for
19
+ * long; see fs-core-recovery.ts, which converges to readonly before
20
+ * broadcasting) | dead (FATAL received, the worker is unusable).
21
+ */
22
+ export type FsCoreMode = 'starting' | 'writer' | 'readonly' | 'draining' | 'dead';
23
+ /**
24
+ * Every `code` a rejected fs-core RPC can carry (client-side: `error.code` on
25
+ * the rejection; wire-side: the `code` field of a `{ok: false}` reply). The
26
+ * worker's `rpcErr` factory is typed against this list, so a code appearing
27
+ * here and a code the worker can actually throw are the same set by
28
+ * construction.
29
+ */
30
+ export declare const FS_CORE_ERROR_CODES: readonly ["readonly", "draining", "turn-closed", "turn-active", "turn-quota", "agent-token-required", "agent-token-gate-armed", "restore-conflict", "cas-mismatch", "edit-no-match", "edit-ambiguous", "not-found", "bad-path", "bad-args", "bad-op", "derived-readonly", "rotate-needed", "internal"];
31
+ export type FsCoreErrorCode = (typeof FS_CORE_ERROR_CODES)[number];
32
+ /** Extra fields a `restore-conflict` rejection carries (see `rpcErr`'s `extra`). */
33
+ export interface FsCoreErrorExtras {
34
+ humanPaths?: string[];
35
+ auditGap?: boolean;
36
+ }
37
+ /**
38
+ * `error.code` of `error` when it looks like an fs-core RPC rejection,
39
+ * else `undefined`. Purely structural — works on the plain `Error` the client
40
+ * materializes as well as on a structured-clone of it.
41
+ */
42
+ export declare function getFsCoreErrorCode(error: unknown): FsCoreErrorCode | undefined;
43
+ /** True when `error` is an fs-core RPC rejection carrying exactly `code`. */
44
+ export declare function isFsCoreErrorCode(error: unknown, code: FsCoreErrorCode): boolean;
45
+ /** Names of the unsolicited events the core worker pushes to its client. */
46
+ export type FsCoreEventName = 'writer-granted' | 'writer-lost' | 'fs-change';
47
+ /** First message after HELLO; resolves `ProjectFsClient.connect`. */
48
+ export interface CoreWelcomeMessage {
49
+ type: 'WELCOME';
50
+ epoch: number;
51
+ memGen: number;
52
+ readonly: boolean;
53
+ mode: FsCoreMode;
54
+ }
55
+ /** Unrecoverable worker failure — the worker is unusable afterwards. */
56
+ export interface CoreFatalMessage {
57
+ type: 'FATAL';
58
+ error: string;
59
+ }
60
+ /** Liveness reply to the client's PING. */
61
+ export interface CorePongMessage {
62
+ type: 'PONG';
63
+ t?: number;
64
+ }
65
+ /**
66
+ * Unsolicited event push. `writer-granted`/`writer-lost` drive the client's
67
+ * `mode`; `fs-change` reports a committed write batch (`paths` when ≤ 32,
68
+ * else just `count`; `restore` carries the checkpoint id a restore replayed).
69
+ */
70
+ export interface CoreEventMessage {
71
+ evt: FsCoreEventName;
72
+ gen?: number;
73
+ actor?: string;
74
+ paths?: string[];
75
+ count?: number;
76
+ restore?: string;
77
+ }
78
+ /** RPC reply, success. */
79
+ export interface CoreReplyOkMessage {
80
+ id: number;
81
+ ok: true;
82
+ result: unknown;
83
+ }
84
+ /** RPC reply, failure — `code` is one of {@link FS_CORE_ERROR_CODES}. */
85
+ export interface CoreReplyErrMessage extends FsCoreErrorExtras {
86
+ id: number;
87
+ ok: false;
88
+ code: FsCoreErrorCode;
89
+ error: string;
90
+ }
91
+ /** Everything the core worker's main port can deliver, as a discriminated union. */
92
+ export type CoreWireMessage = CoreWelcomeMessage | CoreFatalMessage | CorePongMessage | CoreEventMessage | CoreReplyOkMessage | CoreReplyErrMessage;
93
+ /**
94
+ * Loosely-shaped dynamic view of {@link CoreWireMessage}: every field
95
+ * optional, distinguished at runtime by which ones are present. This matches
96
+ * how `ProjectFsClient` actually reads the port (each field independently
97
+ * checked), and is the type its `onChange` callbacks receive; the union above
98
+ * is the authoritative shape each individual message satisfies.
99
+ */
100
+ export interface CoreMessage {
101
+ type?: string;
102
+ evt?: string;
103
+ error?: string;
104
+ mode?: FsCoreMode;
105
+ readonly?: boolean;
106
+ gen?: number;
107
+ id?: number;
108
+ ok?: boolean;
109
+ result?: unknown;
110
+ code?: string;
111
+ humanPaths?: string[];
112
+ auditGap?: boolean;
113
+ actor?: string;
114
+ paths?: string[];
115
+ count?: number;
116
+ restore?: string;
117
+ t?: number;
118
+ }
119
+ //# sourceMappingURL=protocol.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../../src/worker-lib/protocol.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,73 @@
1
+ /**
2
+ * fs-core wire contract — the single authoritative enumeration of the error
3
+ * codes, event names, mode values, and message shapes that cross the worker
4
+ * boundary. Both ends compile against this module (worker side:
5
+ * engine-shared.ts's `rpcErr` signature; main-thread side: client.ts), and
6
+ * consumers should match on these exported symbols instead of quoting string
7
+ * literals from worker source or citing dist line numbers — those references
8
+ * rot on every version bump while the wire strings silently stay load-bearing.
9
+ *
10
+ * Lib-neutral (types + pure predicates only) so it stays compilable by both
11
+ * the DOM and the WebWorker tsc programs — see this directory's convention in
12
+ * ../../tsconfig.json.
13
+ */
14
+ /**
15
+ * Every `code` a rejected fs-core RPC can carry (client-side: `error.code` on
16
+ * the rejection; wire-side: the `code` field of a `{ok: false}` reply). The
17
+ * worker's `rpcErr` factory is typed against this list, so a code appearing
18
+ * here and a code the worker can actually throw are the same set by
19
+ * construction.
20
+ */
21
+ export const FS_CORE_ERROR_CODES = [
22
+ /** Write attempted while this worker does not hold the writer lease (another tab owns it). */
23
+ 'readonly',
24
+ /** Write attempted while the writer lease handover is in progress. */
25
+ 'draining',
26
+ /** Agent write without an active matching turn, or the turn has expired. */
27
+ 'turn-closed',
28
+ /** `turnBegin` while another turn is already active. */
29
+ 'turn-active',
30
+ /** Per-turn op quota exceeded. */
31
+ 'turn-quota',
32
+ /** Agent write without the armed agent token (see `armAgentTokenGate`). */
33
+ 'agent-token-required',
34
+ /** `armAgentTokenGate` re-armed with a DIFFERENT token (same token replays are ok+idempotent). */
35
+ 'agent-token-gate-armed',
36
+ /** `restore` refused: the audit window no longer covers `baseGen`, or human edits landed since (carries `humanPaths`/`auditGap` extras). */
37
+ 'restore-conflict',
38
+ /** Optimistic-concurrency failure: `ifMatch` mismatch, or the target path already exists. */
39
+ 'cas-mismatch',
40
+ /** `edit`'s old string not found in the file. */
41
+ 'edit-no-match',
42
+ /** `edit`'s old string matches more than once. */
43
+ 'edit-ambiguous',
44
+ /** Path (or checkpoint id) does not exist. */
45
+ 'not-found',
46
+ /** Path failed normalization (absolute, `..`, empty, ...). */
47
+ 'bad-path',
48
+ /** Malformed argument (non-string content, missing turnId, ...). */
49
+ 'bad-args',
50
+ /** Unknown RPC op name. */
51
+ 'bad-op',
52
+ /** Write into a derived (read-only) area. */
53
+ 'derived-readonly',
54
+ /** Internal write-path signal (WAL segment rotation needed); not expected to surface to clients. */
55
+ 'rotate-needed',
56
+ /** Fallback for a worker-side error that carried no code of its own. */
57
+ 'internal',
58
+ ];
59
+ /**
60
+ * `error.code` of `error` when it looks like an fs-core RPC rejection,
61
+ * else `undefined`. Purely structural — works on the plain `Error` the client
62
+ * materializes as well as on a structured-clone of it.
63
+ */
64
+ export function getFsCoreErrorCode(error) {
65
+ if (!error || typeof error !== 'object' || !('code' in error))
66
+ return undefined;
67
+ const code = error.code;
68
+ return FS_CORE_ERROR_CODES.includes(code) ? code : undefined;
69
+ }
70
+ /** True when `error` is an fs-core RPC rejection carrying exactly `code`. */
71
+ export function isFsCoreErrorCode(error, code) {
72
+ return getFsCoreErrorCode(error) === code;
73
+ }
@@ -0,0 +1,68 @@
1
+ /** Per-op RPC argument shapes (lib-neutral type aliases only) —— handleRpc's
2
+ * dispatch table narrows the incoming `Record<string, unknown>` bag to one of
3
+ * these via a single `as` per op, matching each op method's own destructured
4
+ * parameter type below. */
5
+ export interface WriteArgs {
6
+ path: string;
7
+ content: unknown;
8
+ ifMatch?: number | null;
9
+ actor?: string;
10
+ turnId?: string;
11
+ agentToken?: string;
12
+ opId?: string;
13
+ }
14
+ export interface EditArgs {
15
+ path: string;
16
+ old: string;
17
+ next: string;
18
+ ifMatch?: number;
19
+ actor?: string;
20
+ turnId?: string;
21
+ agentToken?: string;
22
+ opId?: string;
23
+ }
24
+ export interface RmArgs {
25
+ path: string;
26
+ actor?: string;
27
+ turnId?: string;
28
+ agentToken?: string;
29
+ opId?: string;
30
+ }
31
+ export interface MvArgs {
32
+ from: string;
33
+ to: string;
34
+ actor?: string;
35
+ turnId?: string;
36
+ agentToken?: string;
37
+ opId?: string;
38
+ }
39
+ export interface CheckpointArgs {
40
+ actor?: string;
41
+ turnId?: string;
42
+ agentToken?: string;
43
+ opId?: string;
44
+ }
45
+ export interface RestoreArgs {
46
+ cpId: string;
47
+ baseGen?: number;
48
+ force?: boolean;
49
+ actor?: string;
50
+ turnId?: string;
51
+ agentToken?: string;
52
+ opId?: string;
53
+ }
54
+ export interface TurnBeginArgs {
55
+ turnId: string;
56
+ ttlMs?: number;
57
+ opId?: string;
58
+ }
59
+ export interface TurnEndArgs {
60
+ turnId: string;
61
+ }
62
+ export interface ReadArgs {
63
+ path: string;
64
+ }
65
+ export interface DiffArgs {
66
+ turnId?: string;
67
+ }
68
+ //# sourceMappingURL=rpc-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rpc-types.d.ts","sourceRoot":"","sources":["../../src/worker-lib/rpc-types.ts"],"names":[],"mappings":"AAAA;;;2BAG2B;AAC3B,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,OAAO,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AACD,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AACD,MAAM,WAAW,MAAM;IACrB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AACD,MAAM,WAAW,MAAM;IACrB,IAAI,EAAE,MAAM,CAAA;IACZ,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AACD,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AACD,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AACD,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AACD,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAA;CACf;AACD,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAA;CACb;AACD,MAAM,WAAW,QAAQ;IACvB,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * WAL 编解码(纯函数,lib 中立)—— crc32、superblock 槽、WAL 记录成帧/解析、
3
+ * sha256hex。从 fs-core.worker.ts 抽出:不含任何 OPFS/Worker 专属 API,可被
4
+ * 主 tsconfig(DOM lib)与 tsconfig.worker.json(WebWorker lib)两个 program
5
+ * 同时编译,因此 zip.ts(主 program 侧)与 fs-core.worker.ts(worker program
6
+ * 侧)都能 import 同一份 crc32/CRC_TABLE 实现(消除重复代码)。sync/ 侧
7
+ * (独立 rootDir 的第三个 tsc program)经 package.json 的 `imports` 自引用
8
+ * (`#worker-lib/wal-codec.js`,见 tsconfig.worker-lib.build.json)复用同一份
9
+ * sha256hex,同样消除重复代码。
10
+ *
11
+ * WAL 记录成帧:
12
+ * [u32 len][u64 gen][u32 epoch][u8 opcode][u16 metaLen][meta JSON][u32 crc32][u8 0xC1]
13
+ * len = len 字段之后的字节数;crc 覆盖 gen..meta 末尾。
14
+ */
15
+ export declare const enc: TextEncoder;
16
+ export declare const dec: TextDecoder;
17
+ export declare const CRC_TABLE: Uint32Array<ArrayBuffer>;
18
+ export declare function crc32(bytes: Uint8Array, start?: number, end?: number): number;
19
+ export declare function sha256hex(bytes: Uint8Array): Promise<string>;
20
+ export interface SlotInfo {
21
+ epoch: number;
22
+ compactGen: number;
23
+ walStartGen: number;
24
+ manifestCrc: number;
25
+ }
26
+ export declare const SLOT_SIZE = 64;
27
+ export declare function encodeSlot(s: SlotInfo): Uint8Array;
28
+ export declare function decodeSlot(bytes: Uint8Array): SlotInfo | null;
29
+ /** WAL 记录的 meta 载荷形状 —— 各 opcode 只填自己用到的字段(落 WAL 的持久
30
+ * 线格式,字段名/结构不可随意变更,见 fs-core.worker.ts 里对应 op* 方法)。 */
31
+ export interface WalMeta {
32
+ opId?: string;
33
+ path?: string;
34
+ from?: string;
35
+ to?: string;
36
+ actor?: string;
37
+ turnId?: string;
38
+ ifMatch?: number | null;
39
+ payload?: {
40
+ inline?: string;
41
+ h?: string;
42
+ };
43
+ cpId?: string;
44
+ h?: string;
45
+ }
46
+ export interface WalRecord {
47
+ gen: number;
48
+ epoch: number;
49
+ opcode: number;
50
+ meta: WalMeta;
51
+ }
52
+ export declare function frameRecord(gen: number, epoch: number, opcode: number, meta: unknown): Uint8Array;
53
+ /** 解析一条记录;返回 {rec, next} 或 null(framing/CRC/commit 任一失败)。 */
54
+ export declare function parseRecord(u8: Uint8Array, off: number): {
55
+ rec: WalRecord;
56
+ next: number;
57
+ } | null;
58
+ //# sourceMappingURL=wal-codec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wal-codec.d.ts","sourceRoot":"","sources":["../../src/worker-lib/wal-codec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,GAAG,aAAoB,CAAA;AACpC,eAAO,MAAM,GAAG,aAAoB,CAAA;AAGpC,eAAO,MAAM,SAAS,0BAQlB,CAAA;AACJ,wBAAgB,KAAK,CAAC,KAAK,EAAE,UAAU,EAAE,KAAK,SAAI,EAAE,GAAG,GAAE,MAAqB,GAAG,MAAM,CAItF;AAED,wBAAsB,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAGlE;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,EAAE,MAAM,CAAA;IACnB,WAAW,EAAE,MAAM,CAAA;CACpB;AAGD,eAAO,MAAM,SAAS,KAAK,CAAA;AAI3B,wBAAgB,UAAU,CAAC,CAAC,EAAE,QAAQ,GAAG,UAAU,CAUlD;AACD,wBAAgB,UAAU,CAAC,KAAK,EAAE,UAAU,GAAG,QAAQ,GAAG,IAAI,CAW7D;AAED;uDACuD;AACvD,MAAM,WAAW,OAAO;IACtB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IACzC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,CAAC,CAAC,EAAE,MAAM,CAAA;CACX;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,OAAO,CAAA;CACd;AAGD,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,UAAU,CAgBjG;AACD,6DAA6D;AAC7D,wBAAgB,WAAW,CAAC,EAAE,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAiBhG"}
@@ -1,9 +1,12 @@
1
1
  /**
2
- * WAL 编解码(纯函数,lib 中立)—— crc32、superblock 槽、WAL 记录成帧/解析。
3
- * fs-core.worker.ts 抽出:不含任何 OPFS/Worker 专属 API,可被主 tsconfig
4
- * (DOM lib)与 tsconfig.worker.json(WebWorker lib)两个 program 同时编译,
5
- * 因此 zip.ts(主 program 侧)与 fs-core.worker.ts(worker program 侧)
6
- * 都能 import 同一份 crc32/CRC_TABLE 实现(消除重复代码)。
2
+ * WAL 编解码(纯函数,lib 中立)—— crc32、superblock 槽、WAL 记录成帧/解析、
3
+ * sha256hex。从 fs-core.worker.ts 抽出:不含任何 OPFS/Worker 专属 API,可被
4
+ * 主 tsconfig(DOM lib)与 tsconfig.worker.json(WebWorker lib)两个 program
5
+ * 同时编译,因此 zip.ts(主 program 侧)与 fs-core.worker.ts(worker program
6
+ * 侧)都能 import 同一份 crc32/CRC_TABLE 实现(消除重复代码)。sync/ 侧
7
+ * (独立 rootDir 的第三个 tsc program)经 package.json 的 `imports` 自引用
8
+ * (`#worker-lib/wal-codec.js`,见 tsconfig.worker-lib.build.json)复用同一份
9
+ * sha256hex,同样消除重复代码。
7
10
  *
8
11
  * WAL 记录成帧:
9
12
  * [u32 len][u64 gen][u32 epoch][u8 opcode][u16 metaLen][meta JSON][u32 crc32][u8 0xC1]
package/dist/zip.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * store-only ZIP 打包(P5 导出出口)—— 无压缩、无依赖,全浏览器可用。
2
+ * store-only ZIP 打包(导出出口)—— 无压缩、无依赖,全浏览器可用。
3
3
  * 项目导出走"另一介质"才真正提升持久等级;zip 是最低门槛的那条出口。
4
4
  */
5
5
  import { crc32 } from './worker-lib/wal-codec.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dimina-kit/fs-core",
3
- "version": "0.2.0-dev.20260711062001",
3
+ "version": "0.3.0-dev.6-dev.20260711140249",
4
4
  "description": "Zero-dependency OPFS WAL filesystem core for the web, with an agent capability gate.",
5
5
  "keywords": [
6
6
  "dimina",
@@ -25,6 +25,12 @@
25
25
  "url": "https://github.com/EchoTechFE/dimina-kit/issues"
26
26
  },
27
27
  "homepage": "https://github.com/EchoTechFE/dimina-kit/tree/main/packages/fs-core#readme",
28
+ "imports": {
29
+ "#worker-lib/wal-codec.js": {
30
+ "types": "./src/worker-lib/wal-codec.ts",
31
+ "default": "./dist/worker-lib/wal-codec.js"
32
+ }
33
+ },
28
34
  "exports": {
29
35
  "./client": {
30
36
  "types": "./src/client.ts",
@@ -45,6 +51,23 @@
45
51
  "./sync": {
46
52
  "types": "./sync/sync-engine.ts",
47
53
  "default": "./dist/sync/sync-engine.js"
54
+ },
55
+ "./sync/binary-sidecar": {
56
+ "types": "./sync/binary-sidecar.ts",
57
+ "default": "./dist/sync/binary-sidecar.js"
58
+ },
59
+ "./sync/watch-expander": {
60
+ "types": "./sync/watch-expander.ts",
61
+ "default": "./dist/sync/watch-expander.js"
62
+ },
63
+ "./protocol": {
64
+ "types": "./src/worker-lib/protocol.ts",
65
+ "default": "./dist/worker-lib/protocol.js"
66
+ },
67
+ "./worker-files": {
68
+ "types": "./src/worker-files.ts",
69
+ "require": "./dist/worker-files.cjs",
70
+ "default": "./dist/worker-files.js"
48
71
  }
49
72
  },
50
73
  "files": [
@@ -65,7 +88,7 @@
65
88
  "access": "public"
66
89
  },
67
90
  "scripts": {
68
- "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.sync.build.json && node build-workers.js",
91
+ "build": "tsc -p tsconfig.worker-lib.build.json && tsc -p tsconfig.build.json && tsc -p tsconfig.sync.build.json && node build-workers.js",
69
92
  "check-types": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.worker.json",
70
93
  "lint": "eslint . --max-warnings 0",
71
94
  "test": "vitest run --reporter=default --reporter=json --outputFile.json=test-report.json --coverage.enabled --coverage.reporter=json-summary --coverage.reportsDirectory=coverage",
@@ -4,6 +4,9 @@
4
4
  * Not a runtime test — only needs to satisfy `tsc --noEmit`.
5
5
  */
6
6
  import type { ProjectFsClient } from '@dimina-kit/fs-core/client'
7
+ import type {
8
+ FsCheckpointOpts, FsRestoreOpts, FsTurnBeginOpts, FsWriteCallOpts,
9
+ } from '@dimina-kit/fs-core/client'
7
10
  import type { AgentTool, AgentToolsFs } from '@dimina-kit/fs-core/agent-tools'
8
11
  import { createAgentTools } from '@dimina-kit/fs-core/agent-tools'
9
12
  import type { DiskMirrorFs } from '@dimina-kit/fs-core/disk-mirror'
@@ -21,3 +24,61 @@ export function typesSmoke(client: ProjectFsClient, fs: AgentToolsFs & DiskMirro
21
24
  void client.projectId
22
25
  return { tool: agent.byName.fs_read, zip, mirrorActive: mirror.active }
23
26
  }
27
+
28
+ // ── Write-API contract smoke: client write methods must carry their real
29
+ // worker-side opts/result shapes end to end, not the current `Record<string,
30
+ // unknown> → Promise<unknown>` erasure (src/client.ts:200-232). Guards that
31
+ // FsWriteCallOpts/FsCheckpointOpts/FsRestoreOpts/FsTurnBeginOpts and their
32
+ // matching result types are exported from `/client` and threaded onto every
33
+ // write method's signature. Not a runtime test — `tsc --noEmit` only.
34
+ export async function fsWriteContractSmoke(client: ProjectFsClient): Promise<void> {
35
+ // 1) write()'s result carries {gen, rev} — `Promise<unknown>` cannot be
36
+ // assigned here, so this line only compiles once the return type is real.
37
+ const written: { gen: number; rev: number } = await client.write('a', 'b')
38
+ void written
39
+
40
+ // 2) every FsWriteCallOpts field is accepted by write()'s third param.
41
+ const fullOpts: FsWriteCallOpts = { actor: 'agent', turnId: 't', agentToken: 'x', ifMatch: 3 }
42
+ void client.write('a', 'b', fullOpts)
43
+
44
+ // 3) checkpoint/turnBegin/turnEnd expose their op-specific extra fields.
45
+ const cp = await client.checkpoint()
46
+ const cpId: string = cp.cpId
47
+ const turn = await client.turnBegin('t', { ttlMs: 1 })
48
+ const expiresAt: number = turn.expiresAt
49
+ const ended = await client.turnEnd('t')
50
+ const closed: boolean = ended.closed
51
+ void [cpId, expiresAt, closed]
52
+
53
+ // restore()/checkpoint() share the {actor,turnId,agentToken} shape by
54
+ // construction (FsRestoreOpts extends FsCheckpointOpts).
55
+ const restoreOpts: FsRestoreOpts = { baseGen: 1, force: true, actor: 'human' }
56
+ const checkpointOpts: FsCheckpointOpts = restoreOpts
57
+ void checkpointOpts
58
+ const beginOpts: FsTurnBeginOpts = { ttlMs: 500 }
59
+ void beginOpts
60
+ }
61
+
62
+ // ── Reverse guards: opts objects that violate the contract must NOT
63
+ // type-check against it. Expressed as an "assign to never on regression"
64
+ // trick (no @ts-expect-error — that counts as a type-escape under this
65
+ // repo's ratchet): `Assignable<Bad, Good>` is `false` today, so `Guard`
66
+ // below is `true` and the `const` assignment is inert; if a future change
67
+ // widens FsWriteCallOpts enough that `Bad` becomes assignable, `Guard`
68
+ // collapses to `never` and this file stops compiling.
69
+ type Assignable<T, U> = [T] extends [U] ? true : false
70
+ type RejectsBadActorLiteral = Assignable<{ actor: 'robot' }, FsWriteCallOpts> extends false ? true : never
71
+ const _guardRejectsBadActorLiteral: RejectsBadActorLiteral = true
72
+ void _guardRejectsBadActorLiteral
73
+
74
+ // A misspelled key (`turnid` instead of `turnId`) is an excess-property
75
+ // error, which TS only raises for a *fresh* object literal in assignment/
76
+ // call position — not through a structural `extends` check like the one
77
+ // above (every FsWriteCallOpts field is optional, so `{ turnid: 'x' }`
78
+ // structurally satisfies it once the mismatched key is erased by widening).
79
+ // Expressing "this typo must be rejected" as a standing assertion would
80
+ // therefore need either @ts-expect-error (banned by the ratchet) or a
81
+ // direct literal assignment that hard-fails compilation forever (defeats
82
+ // the point of a check file meant to go green). Left unexpressed here;
83
+ // verified manually instead — see below.
84
+ // const _typoRejected: FsWriteCallOpts = { turnid: 'x' } // <- real tsc error today: "'turnid' does not exist in type 'FsWriteCallOpts'"
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Agent 工具面(P4)—— MCP 形状的工具表包装 ProjectFsClient。
2
+ * Agent 工具面 —— MCP 形状的工具表包装 ProjectFsClient。
3
3
  *
4
- * 设计对应架构文档 §13:Agent 拿到的不是裸 FS,而是这张工具表 +(宿主铸造的)
4
+ * 设计立场:Agent 拿到的不是裸 FS,而是这张工具表 +(宿主铸造的)
5
5
  * turn 能力。要点:
6
6
  * - execute 自动注入 {actor:'agent', turnId}:turnId 由 beginTurn 铸造并闭包持有,
7
7
  * 不出现在工具参数里 —— 模型伪造不了别的 turn。
8
8
  * - 真正的执法在 fs-core(checkTurn 与 WAL append 同一同步块);这里只是便利面。
9
- * - inputSchema 用简化记法(K2 交付 Agent 组时再换成正式 JSON Schema + MCP server 包装)。
9
+ * - inputSchema 用简化记法(对外交付 Agent 集成时再换成正式 JSON Schema + MCP server 包装)。
10
10
  * - fs_read 返回完整内容,绝不截断(上游 8000 字符腰斩教训)。
11
11
  *
12
12
  * `AgentToolsFs` only declares the methods this file actually calls on the
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Guards ProjectFsClient's write-RPC timeout semantics: a timed-out write op
3
+ * (which always carries an idempotent opId) is re-sent EXACTLY once with the
4
+ * same opId — the retry's own timer rejects terminally instead of re-entering
5
+ * the retry branch — and a plain read RPC (no opId) rejects on first timeout.
6
+ */
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { ProjectFsClient } from './client.js'
9
+
10
+ type Internals = Omit<ProjectFsClient, 'core'> & {
11
+ pending: Map<number, unknown>
12
+ seq: number
13
+ // Narrow structural stand-in for the real `Worker` — `_rpc` only posts.
14
+ core: { postMessage: (msg: unknown) => void }
15
+ _rpc<T>(op: string, args: Record<string, unknown>, opts?: { opId?: string; timeout?: number }): Promise<T>
16
+ }
17
+
18
+ function makeRpcClient(): { client: Internals; posted: Array<{ id: number; op: string; opId?: string }> } {
19
+ const posted: Array<{ id: number; op: string; opId?: string }> = []
20
+ const c = new ProjectFsClient() as unknown as Internals
21
+ c.pending = new Map()
22
+ c.seq = 0
23
+ c.core = { postMessage: (msg) => posted.push(msg as { id: number; op: string; opId?: string }) }
24
+ return { client: c, posted }
25
+ }
26
+
27
+ describe('ProjectFsClient — write-RPC timeout retry', () => {
28
+ afterEach(() => {
29
+ vi.useRealTimers()
30
+ })
31
+
32
+ it('re-sends once with the SAME opId, then rejects terminally on the retry timeout', async () => {
33
+ vi.useFakeTimers()
34
+ const { client, posted } = makeRpcClient()
35
+ const p = client._rpc('write', { path: 'a.txt' }, { opId: 'op-1', timeout: 1000 })
36
+ const rejected = expect(p).rejects.toThrow('write timeout (after retry)')
37
+ expect(posted).toHaveLength(1)
38
+
39
+ await vi.advanceTimersByTimeAsync(1000) // first timeout → one retry
40
+ expect(posted).toHaveLength(2)
41
+ expect(posted[1]!.opId).toBe('op-1')
42
+ expect(posted[1]!.id).not.toBe(posted[0]!.id)
43
+
44
+ await vi.advanceTimersByTimeAsync(1000) // retry timeout → terminal reject, no third send
45
+ await rejected
46
+ expect(posted).toHaveLength(2)
47
+ expect(client.pending.size).toBe(0)
48
+ })
49
+
50
+ it('a second timed-out call still gets its own retry (per-call, not per-client-lifetime)', async () => {
51
+ vi.useFakeTimers()
52
+ const { client, posted } = makeRpcClient()
53
+ const first = client._rpc('write', { path: 'a.txt' }, { opId: 'op-1', timeout: 1000 })
54
+ const firstRejected = expect(first).rejects.toThrow('timeout (after retry)')
55
+ await vi.advanceTimersByTimeAsync(2000)
56
+ await firstRejected
57
+
58
+ const second = client._rpc('write', { path: 'b.txt' }, { opId: 'op-2', timeout: 1000 })
59
+ const secondRejected = expect(second).rejects.toThrow('timeout (after retry)')
60
+ await vi.advanceTimersByTimeAsync(1000)
61
+ // The second call retried too — 2 sends for op-1 + 2 sends for op-2.
62
+ expect(posted.filter((m) => m.opId === 'op-2')).toHaveLength(2)
63
+ await vi.advanceTimersByTimeAsync(1000)
64
+ await secondRejected
65
+ })
66
+
67
+ it('an opId-less RPC with a timeout rejects on first timeout without a retry', async () => {
68
+ vi.useFakeTimers()
69
+ const { client, posted } = makeRpcClient()
70
+ const p = client._rpc('compact', {}, { timeout: 500 })
71
+ const rejected = expect(p).rejects.toThrow('compact timeout')
72
+ await vi.advanceTimersByTimeAsync(500)
73
+ await rejected
74
+ expect(posted).toHaveLength(1)
75
+ })
76
+ })