1
/**2
* Event-only filesystem observation policy; it registers no service. A weak owner/target map3
* records every authoritative presence/absence observation, single-slot intent listeners derive4
* guards from that state, and the provider performs the atomic freshness/no-clobber check. Without5
* this plugin, tools retain the bare provider's unconditional mutation behavior. See the package6
* README for composition rules.7
* @module @deepseek-ai/dsh-fs-observation-policy8
*/10
import type { Context } from '@deepseek-ai/cordis'11
import { FsError } from '@deepseek-ai/dsh-fs'12
import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'13
import type { FsObservationActor } from './types.ts'15
export type { FsObservationActor } from './types.ts'17
/**18
* Per-context observed-file state and the three `fs/*` decisions over it. One19
* instance is created per `apply()` so disposal can drop all state for HMR.20
*/21
class ObservedStateGate {22
/**23
* Observed-file state, keyed first by the owner object (weakly held, so a24
* collected session frees its state), then by {@link FsTarget.targetKey}. An25
* entry's presence is the prior-observation record; its discriminant keeps26
* confirmed absence distinct from an unseen target.27
*/28
private observed = new WeakMap<object, Map<string, FsObservation>>()30
/**31
* Derive the observed-state owner from the opaque event actor — normally the32
* active agent session. `undefined` when no owner can be derived (e.g. a33
* direct tool call with no agent); such calls read freely but cannot satisfy34
* the write/edit prior-observation policy.35
*/36
private owner(actor: object | undefined): object | undefined {37
// tsgolint treats object as assignable to weak FsObservationActor, while tsc still requires the structural cast for property access.38
// See the analyzer-divergence consequence in .agents/notes/archived/process/2026-07-29-oxlint-linter.md.39
// oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- The analyzers disagree on this weak type.40
return (actor as FsObservationActor | undefined)?.agent?.session41
}43
private get(owner: object, targetKey: string): FsObservation | undefined {44
return this.observed.get(owner)?.get(targetKey)45
}47
private set(owner: object, targetKey: string, observation: FsObservation): void {48
let byTarget = this.observed.get(owner)49
if (!byTarget) {50
byTarget = new Map()51
this.observed.set(owner, byTarget)52
}53
byTarget.set(targetKey, observation)54
}56
/** Drop all recorded state (HMR safety / disposal). */57
clear(): void {58
this.observed = new WeakMap()59
}61
/**62
* Decide the write intent: unseen or confirmed absent ⇒ `createIfAbsent`;63
* confirmed present ⇒ `replaceIfVersion` at the observed version.64
*/65
writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent {66
const owner = this.owner(actor)67
const prior = owner ? this.get(owner, target.targetKey) : undefined68
return prior?.kind === 'present'69
? { kind: 'replaceIfVersion', version: prior.version }70
: { kind: 'createIfAbsent' }71
}73
/**74
* Decide the edit version guard: unseen rejects with `FS_NOT_OBSERVED`,75
* confirmed absence rejects with `FS_NOT_FOUND`, and presence supplies the76
* observed version as the CAS basis.77
*/78
editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {79
const owner = this.owner(actor)80
const prior = owner ? this.get(owner, target.targetKey) : undefined81
if (!owner || prior === undefined) {82
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')83
}84
if (prior.kind === 'absent') {85
throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND')86
}87
return { version: prior.version }88
}90
/** Record an authoritative present or absent observation for this owner and target. */91
observe(target: FsTarget, observation: FsObservation, actor: object | undefined): void {92
const owner = this.owner(actor)93
if (owner) this.set(owner, target.targetKey, observation)94
}95
}97
/** Cordis plugin name used by loader diagnostics. */98
export const name = 'fs-observation-policy'100
/**101
* Register the three `fs/*` listeners. No `inject` — this plugin reads no102
* services; it operates only on its own `WeakMap`. The waterfalls are unbound103
* (the tool dispatches them with no `this`), so the listeners take the raw104
* `(target, actor, next)` arguments.105
*/106
export function apply(ctx: Context): void {107
const gate = new ObservedStateGate()109
ctx.effect(() => () => {110
// Drop all recorded state on disposal so a reloaded plugin starts clean111
// (HMR safety). The WeakMap itself would be GC'd, but replacing it makes the112
// release observable and immediate for tests.113
gate.clear()114
}, 'fs-observation-policy observed-state teardown')116
// fs/write-intent: occupy the single decision slot — do NOT call next().117
// Deferred through Promise.resolve().then so the declared Promise return type118
// holds (a throw rejects, never escapes synchronously through the waterfall).119
ctx.on('fs/write-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor)))121
// fs/edit-intent: occupy the single decision slot — do not call next().122
ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor)))124
// fs/observed must remain synchronous and non-throwing: emit does not await125
// promises, and successful mutations have already committed. WeakMap.set126
// satisfies that contract for both presence and absence.127
ctx.on('fs/observed', (target, observation, actor) => {128
gate.observe(target, observation, actor)129
})130
}