Skip to content

Commit 4e2de13

Browse files
author
easonLiangWorldedtech
committed
rebuild unit u3 on the fixed chain
1 parent 625a976 commit 4e2de13

2 files changed

Lines changed: 167 additions & 0 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, it, expect, vi } from "vitest"
2+
3+
import { ObservationRegistry } from "../observationRegistry"
4+
5+
describe("ObservationRegistry", () => {
6+
it("observe → get returns the recorded version and observedAt", () => {
7+
const reg = new ObservationRegistry()
8+
reg.observe("/a/b/c.ts", "1:2:300:4000000000:5000000000")
9+
10+
const obs = reg.get("/a/b/c.ts")
11+
expect(obs).toBeDefined()
12+
expect(obs!.version).toBe("1:2:300:4000000000:5000000000")
13+
expect(typeof obs!.observedAt).toBe("number")
14+
})
15+
16+
it("re-observe replaces the entry with a fresh observedAt", () => {
17+
vi.useFakeTimers()
18+
const reg = new ObservationRegistry()
19+
reg.observe("/a/b/c.ts", "v1")
20+
const first = reg.get("/a/b/c.ts")!
21+
expect(first.version).toBe("v1")
22+
23+
vi.advanceTimersByTime(50)
24+
reg.observe("/a/b/c.ts", "v2")
25+
const second = reg.get("/a/b/c.ts")!
26+
expect(second.version).toBe("v2")
27+
expect(second.observedAt).toBeGreaterThan(first.observedAt)
28+
29+
vi.useRealTimers()
30+
})
31+
32+
it("has returns true for observed paths, false otherwise", () => {
33+
const reg = new ObservationRegistry()
34+
reg.observe("/x.ts", "t1")
35+
expect(reg.has("/x.ts")).toBe(true)
36+
expect(reg.has("/y.ts")).toBe(false)
37+
})
38+
39+
it("size reflects the number of observed entries", () => {
40+
const reg = new ObservationRegistry()
41+
expect(reg.size).toBe(0)
42+
reg.observe("/a.ts", "t1")
43+
reg.observe("/b.ts", "t2")
44+
expect(reg.size).toBe(2)
45+
})
46+
47+
it("clear removes all entries and resets size to 0", () => {
48+
const reg = new ObservationRegistry()
49+
reg.observe("/a.ts", "t1")
50+
reg.observe("/b.ts", "t2")
51+
reg.clear()
52+
expect(reg.size).toBe(0)
53+
expect(reg.get("/a.ts")).toBeUndefined()
54+
expect(reg.has("/b.ts")).toBe(false)
55+
})
56+
57+
it("get on empty registry returns undefined", () => {
58+
const reg = new ObservationRegistry()
59+
expect(reg.get("/any.ts")).toBeUndefined()
60+
})
61+
62+
it("separate instances are independent — observing in one does not appear in the other", () => {
63+
const regA = new ObservationRegistry()
64+
const regB = new ObservationRegistry()
65+
regA.observe("/shared.ts", "v1")
66+
expect(regA.get("/shared.ts")).toBeDefined()
67+
expect(regB.get("/shared.ts")).toBeUndefined()
68+
regB.observe("/shared.ts", "v2")
69+
expect(regA.get("/shared.ts")!.version).toBe("v1")
70+
expect(regB.get("/shared.ts")!.version).toBe("v2")
71+
})
72+
73+
describe("completeness scope (S4b follow-up #46)", () => {
74+
it("defaults to a complete observation when the read scope is not given", () => {
75+
const reg = new ObservationRegistry()
76+
reg.observe("/a/b/c.ts", "v1")
77+
78+
expect(reg.get("/a/b/c.ts")!.complete).toBe(true)
79+
})
80+
81+
it("records a partial observation when the read only returned a view of the file", () => {
82+
const reg = new ObservationRegistry()
83+
reg.observe("/a/b/c.ts", "v1", false)
84+
85+
expect(reg.get("/a/b/c.ts")!.complete).toBe(false)
86+
})
87+
88+
it("re-observing replaces the entry's completeness with the new read's scope", () => {
89+
const reg = new ObservationRegistry()
90+
reg.observe("/a/b/c.ts", "v1", false)
91+
reg.observe("/a/b/c.ts", "v2")
92+
93+
const obs = reg.get("/a/b/c.ts")!
94+
expect(obs.version).toBe("v2")
95+
expect(obs.complete).toBe(true)
96+
})
97+
98+
it("re-observing with a partial scope downgrades a previously complete entry", () => {
99+
const reg = new ObservationRegistry()
100+
reg.observe("/a/b/c.ts", "v1")
101+
reg.observe("/a/b/c.ts", "v2", false)
102+
103+
const obs = reg.get("/a/b/c.ts")!
104+
expect(obs.version).toBe("v2")
105+
expect(obs.complete).toBe(false)
106+
})
107+
})
108+
})
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Per-task file observation registry (upstream epic #1375, phase A2).
3+
*
4+
* Each Task owns its own instance so parent and subtask observations are
5+
* independent. The S4 guarded-write will compare these versions against the
6+
* token recomputed pre-write to detect stale reads or file replacement.
7+
*
8+
* Pure in-memory — zero I/O, no dependencies. The S4 guarded-write consults
9+
* these observations for the version check and for the completeness check that
10+
* gates a full-file replacement.
11+
*/
12+
13+
export interface FileObservation {
14+
/** Version token derived from on-disk fs.stat (bigint mode). */
15+
version: string
16+
/** Millisecond timestamp when the observation was recorded. */
17+
observedAt: number
18+
/**
19+
* Whether the read that produced this observation returned the complete
20+
* file. A slice, line-range, truncated, or indentation-block read returns
21+
* only a view of the file; such an observation authorizes targeted edits
22+
* on the view the model saw, but never a full-file replacement.
23+
*/
24+
complete: boolean
25+
}
26+
27+
export class ObservationRegistry {
28+
private readonly entries = new Map<string, FileObservation>()
29+
30+
/**
31+
* Record an observation for a file at its absolute path.
32+
*
33+
* Re-observing replaces the entry with a fresh observedAt timestamp, the
34+
* new version token, and the read's completeness. `complete` defaults to
35+
* true for callers that read the whole file themselves (spec doubles,
36+
* WriteToFileTool). A caller whose read is internal to a targeted edit must
37+
* carry the model's prior completeness instead, so the tool's own read cannot
38+
* upgrade a partial read into authority for a full-file replacement.
39+
*/
40+
observe(absolutePath: string, version: string, complete: boolean = true): void {
41+
this.entries.set(absolutePath, { version, observedAt: Date.now(), complete })
42+
}
43+
44+
get(absolutePath: string): FileObservation | undefined {
45+
return this.entries.get(absolutePath)
46+
}
47+
48+
has(absolutePath: string): boolean {
49+
return this.entries.has(absolutePath)
50+
}
51+
52+
clear(): void {
53+
this.entries.clear()
54+
}
55+
56+
get size(): number {
57+
return this.entries.size
58+
}
59+
}

0 commit comments

Comments
 (0)