dsh-plugin-effort-declare 0.1.0 → 0.1.2

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.
@@ -22,6 +22,16 @@ export interface RouteDraft {
22
22
  compatPresent: boolean
23
23
  }
24
24
 
25
+ /** JSON-stable equality matching pathOps (key order included). */
26
+ export function sliceEqual(left: unknown, right: unknown): boolean {
27
+ return JSON.stringify(left) === JSON.stringify(right)
28
+ }
29
+
30
+ /** Whether two settings slices differ. */
31
+ export function sliceChanged(before: unknown, after: unknown): boolean {
32
+ return !sliceEqual(before, after)
33
+ }
34
+
25
35
  /** Build a draft from the stored user subtree (never from effective `value`). */
26
36
  export function routeDraftFromUserProfile(args: {
27
37
  provider: string
@@ -89,9 +99,132 @@ export function applySaveSuccess(
89
99
  })
90
100
  }
91
101
 
102
+ function modelRowId(row: Record<string, unknown>): string {
103
+ return String(row.id)
104
+ }
105
+
106
+ function indexById(rows: readonly Record<string, unknown>[]): Map<string, Record<string, unknown>> {
107
+ const map = new Map<string, Record<string, unknown>>()
108
+ for (const row of rows) {
109
+ const id = modelRowId(row)
110
+ if (!map.has(id)) map.set(id, row)
111
+ }
112
+ return map
113
+ }
114
+
115
+ function effortsPresence(row: Record<string, unknown> | undefined): { present: boolean; value: unknown } {
116
+ if (row === undefined || !Object.hasOwn(row, 'reasoningEfforts')) {
117
+ return { present: false, value: undefined }
118
+ }
119
+ return { present: true, value: row.reasoningEfforts }
120
+ }
121
+
122
+ function effortsEqual(
123
+ left: { present: boolean; value: unknown },
124
+ right: { present: boolean; value: unknown },
125
+ ): boolean {
126
+ if (left.present !== right.present) return false
127
+ if (!left.present) return true
128
+ return sliceEqual(left.value, right.value)
129
+ }
130
+
131
+ function overlayLocalEfforts(
132
+ incomingRow: Record<string, unknown>,
133
+ prevRow: Record<string, unknown>,
134
+ ): Record<string, unknown> {
135
+ const next = structuredClone(incomingRow)
136
+ if (Object.hasOwn(prevRow, 'reasoningEfforts')) {
137
+ next.reasoningEfforts = structuredClone(prevRow.reasoningEfforts)
138
+ } else {
139
+ delete next.reasoningEfforts
140
+ }
141
+ return next
142
+ }
143
+
144
+ function objectKeyChanged(
145
+ left: Record<string, unknown>,
146
+ right: Record<string, unknown>,
147
+ key: string,
148
+ ): boolean {
149
+ const leftHas = Object.hasOwn(left, key)
150
+ const rightHas = Object.hasOwn(right, key)
151
+ if (leftHas !== rightHas) return true
152
+ if (!leftHas) return false
153
+ return sliceChanged(left[key], right[key])
154
+ }
155
+
156
+ /**
157
+ * Membership follows the latest user-layer models list (Models page add/delete).
158
+ * Local unsaved `reasoningEfforts` (including a cleared key) overlay by id.
159
+ */
160
+ export function mergeModelsById(args: {
161
+ prevModels: readonly Record<string, unknown>[]
162
+ prevOriginal: readonly Record<string, unknown>[]
163
+ incomingModels: readonly Record<string, unknown>[]
164
+ incomingOriginal: readonly Record<string, unknown>[]
165
+ }): { models: Record<string, unknown>[]; conflicted: boolean } {
166
+ const prevById = indexById(args.prevModels)
167
+ const prevOrigById = indexById(args.prevOriginal)
168
+ const incomingOrigById = indexById(args.incomingOriginal)
169
+ const incomingIds = new Set(args.incomingModels.map(modelRowId))
170
+ let conflicted = false
171
+
172
+ const models = args.incomingModels.map((incomingRow) => {
173
+ const id = modelRowId(incomingRow)
174
+ const prevRow = prevById.get(id)
175
+ if (prevRow === undefined) return structuredClone(incomingRow)
176
+ const prevOrig = prevOrigById.get(id)
177
+ const localDirty = !effortsEqual(effortsPresence(prevRow), effortsPresence(prevOrig))
178
+ if (!localDirty) return structuredClone(incomingRow)
179
+ const incomingOrig = incomingOrigById.get(id)
180
+ if (!effortsEqual(effortsPresence(prevOrig), effortsPresence(incomingOrig))) {
181
+ conflicted = true
182
+ }
183
+ return overlayLocalEfforts(incomingRow, prevRow)
184
+ })
185
+
186
+ for (const [id, prevRow] of prevById) {
187
+ if (incomingIds.has(id)) continue
188
+ const prevOrig = prevOrigById.get(id)
189
+ if (effortsEqual(effortsPresence(prevRow), effortsPresence(prevOrig))) continue
190
+ if (!effortsEqual(effortsPresence(prevOrig), effortsPresence(incomingOrigById.get(id)))) {
191
+ conflicted = true
192
+ }
193
+ }
194
+
195
+ return { models, conflicted }
196
+ }
197
+
92
198
  /**
93
- * Apply a freshly loaded table. Dirty cards keep models/compat; originals and
94
- * revision follow the incoming snapshot so a later save is against the new user layer.
199
+ * Three-way compat merge: locally changed keys stay local; everything else
200
+ * follows incoming. Conflict only when a locally dirty key also moved in originals.
201
+ */
202
+ export function mergeCompat(args: {
203
+ prev: Record<string, unknown>
204
+ prevOriginal: Record<string, unknown>
205
+ incoming: Record<string, unknown>
206
+ incomingOriginal: Record<string, unknown>
207
+ }): { compat: Record<string, unknown>; conflicted: boolean } {
208
+ if (!sliceChanged(args.prev, args.prevOriginal)) {
209
+ return { compat: cloneObject(args.incoming), conflicted: false }
210
+ }
211
+ const compat = cloneObject(args.incoming)
212
+ let conflicted = false
213
+ const keys = new Set([...Object.keys(args.prev), ...Object.keys(args.prevOriginal)])
214
+ for (const key of keys) {
215
+ if (!objectKeyChanged(args.prev, args.prevOriginal, key)) continue
216
+ if (objectKeyChanged(args.prevOriginal, args.incomingOriginal, key)) conflicted = true
217
+ if (Object.hasOwn(args.prev, key)) compat[key] = structuredClone(args.prev[key])
218
+ else delete compat[key]
219
+ }
220
+ return { compat, conflicted }
221
+ }
222
+
223
+ /**
224
+ * Apply a freshly loaded table. Membership and metadata follow incoming;
225
+ * unsaved reasoningEfforts / dirty compat keys overlay by id. Conflict only
226
+ * when a locally dirty field also changed in originals (revision-only bumps
227
+ * and sibling-card saves do not warn).
95
228
  */
96
229
  export function mergeLoadedDrafts(
97
230
  current: readonly RouteDraft[],
@@ -103,13 +236,27 @@ export function mergeLoadedDrafts(
103
236
  const drafts = incoming.map((next) => {
104
237
  const prev = currentByProvider.get(next.provider)
105
238
  if (prev === undefined || !options.preserveDirty || !draftDirty(prev)) return next
106
- conflicted.push(next.provider)
239
+ const modelsMerge = mergeModelsById({
240
+ prevModels: prev.models,
241
+ prevOriginal: prev.originalModels,
242
+ incomingModels: next.models,
243
+ incomingOriginal: next.originalModels,
244
+ })
245
+ const compatMerge = mergeCompat({
246
+ prev: prev.compat,
247
+ prevOriginal: prev.originalCompat,
248
+ incoming: next.compat,
249
+ incomingOriginal: next.originalCompat,
250
+ })
251
+ if (modelsMerge.conflicted || compatMerge.conflicted) conflicted.push(next.provider)
107
252
  return {
108
- ...prev,
253
+ provider: next.provider,
109
254
  displayName: next.displayName,
110
255
  settingsPath: next.settingsPath,
111
256
  revision: next.revision,
257
+ models: modelsMerge.models,
112
258
  originalModels: cloneModels(next.originalModels),
259
+ compat: compatMerge.compat,
113
260
  originalCompat: cloneObject(next.originalCompat),
114
261
  compatPresent: next.compatPresent,
115
262
  }