@dimina-kit/fs-core 0.2.0-dev.20260710085051 → 0.3.0-dev.6-dev.20260711133419
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.
- package/README.md +205 -18
- package/dist/client.js +23 -11
- package/dist/disk-mirror.js +2 -2
- package/dist/fs-core.worker.js +101 -21
- package/dist/sync/binary-sidecar.js +147 -0
- package/dist/sync/sync-engine.js +119 -169
- package/dist/sync/watch-expander.js +202 -0
- package/dist/worker-files.cjs +32 -0
- package/dist/worker-files.js +35 -0
- package/dist/worker-lib/.tsbuildinfo +1 -0
- package/dist/worker-lib/engine-shared.d.ts +79 -0
- package/dist/worker-lib/engine-shared.d.ts.map +1 -0
- package/dist/worker-lib/engine-shared.js +6 -3
- package/dist/worker-lib/paths.d.ts +4 -0
- package/dist/worker-lib/paths.d.ts.map +1 -0
- package/dist/worker-lib/protocol.d.ts +119 -0
- package/dist/worker-lib/protocol.d.ts.map +1 -0
- package/dist/worker-lib/protocol.js +73 -0
- package/dist/worker-lib/rpc-types.d.ts +68 -0
- package/dist/worker-lib/rpc-types.d.ts.map +1 -0
- package/dist/worker-lib/wal-codec.d.ts +58 -0
- package/dist/worker-lib/wal-codec.d.ts.map +1 -0
- package/dist/worker-lib/wal-codec.js +8 -5
- package/dist/zip.js +1 -1
- package/package.json +25 -2
- package/src/__checks__/types-smoke.ts +61 -0
- package/src/agent-tools.ts +3 -3
- package/src/client-retry.test.ts +76 -0
- package/src/client.ts +112 -45
- package/src/disk-mirror.ts +2 -2
- package/src/fs-core-handover.test.ts +215 -0
- package/src/fs-core-opid-replay.test.ts +158 -0
- package/src/fs-core-recovery-lock.test.ts +225 -0
- package/src/fs-core-recovery.ts +115 -13
- package/src/fs-core-write-ops.ts +14 -11
- package/src/fs-core.worker.ts +26 -11
- package/src/worker-files.test.ts +39 -0
- package/src/worker-files.ts +43 -0
- package/src/worker-lib/engine-shared.ts +19 -5
- package/src/worker-lib/protocol.test.ts +37 -0
- package/src/worker-lib/protocol.ts +184 -0
- package/src/worker-lib/wal-codec.ts +8 -5
- package/src/zip.ts +1 -1
- package/sync/binary-sidecar.test.ts +150 -0
- package/sync/binary-sidecar.ts +187 -0
- package/sync/sync-engine-degraded.test.ts +309 -0
- package/sync/sync-engine.test.ts +20 -91
- package/sync/sync-engine.ts +134 -181
- package/sync/truth-port.ts +3 -3
- package/sync/watch-expander.test.ts +96 -0
- 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
|
-
*
|
|
4
|
-
* (DOM lib)与 tsconfig.worker.json(WebWorker lib)两个 program
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dimina-kit/fs-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-dev.6-dev.20260711133419",
|
|
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'"
|
package/src/agent-tools.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Agent
|
|
2
|
+
* Agent 工具面 —— MCP 形状的工具表包装 ProjectFsClient。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
|
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
|
+
})
|