negotium 0.2.1 → 0.2.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.
@@ -40,7 +40,9 @@ interface MoveOperation {
40
40
  source: string;
41
41
  staged: string;
42
42
  destination?: string;
43
- state: "pending" | "staged" | "placed";
43
+ destinationStaged?: string;
44
+ collision?: "keep-destination" | "replace-destination" | "merge-jsonl" | "merge-tasks";
45
+ state: "pending" | "staged" | "source-staged" | "destination-staged" | "placed";
44
46
  }
45
47
 
46
48
  interface MigrationJournal {
@@ -70,7 +72,9 @@ function assertNoActiveNode(stateDir: string): void {
70
72
  const infoPath = join(stateDir, runtimeName, "node-daemon.json");
71
73
  if (!existsSync(infoPath)) continue;
72
74
  try {
73
- const value = JSON.parse(readFileSync(infoPath, "utf8")) as { pid?: number };
75
+ const value = JSON.parse(readFileSync(infoPath, "utf8")) as {
76
+ pid?: number;
77
+ };
74
78
  if (typeof value.pid === "number" && processIsAlive(value.pid)) {
75
79
  throw new Error(`Refusing migration while Negotium pid ${value.pid} is active.`);
76
80
  }
@@ -121,17 +125,60 @@ function addDirectoryContents(
121
125
  }
122
126
  }
123
127
 
124
- function assertNoCollisions(operations: MoveOperation[]): void {
128
+ function pathWithin(path: string, root: string): boolean {
129
+ return path === root || path.startsWith(`${root}/`);
130
+ }
131
+
132
+ function collisionPolicy(
133
+ operation: MoveOperation,
134
+ stateDir: string,
135
+ ): NonNullable<MoveOperation["collision"]> | undefined {
136
+ const destination = operation.destination;
137
+ if (!destination || !existsSync(destination)) return undefined;
138
+ if (pathWithin(destination, join(stateDir, "runtime"))) return "keep-destination";
139
+ if (pathWithin(destination, join(stateDir, "binaries"))) return "keep-destination";
140
+ if (pathWithin(destination, join(stateDir, "secrets"))) return "replace-destination";
141
+ if (destination === join(stateDir, "data", "vault", "vault.db")) {
142
+ return "replace-destination";
143
+ }
144
+ if (pathWithin(destination, join(stateDir, "browser", "profiles"))) {
145
+ return "replace-destination";
146
+ }
147
+ if (
148
+ pathWithin(destination, join(stateDir, "data", "conversations")) &&
149
+ destination.endsWith(".jsonl")
150
+ ) {
151
+ return "merge-jsonl";
152
+ }
153
+ if (pathWithin(destination, join(stateDir, "data", "tasks")) && destination.endsWith(".json")) {
154
+ return "merge-tasks";
155
+ }
156
+ if (
157
+ statSync(operation.source).isFile() &&
158
+ statSync(destination).isFile() &&
159
+ readFileSync(operation.source).equals(readFileSync(destination))
160
+ ) {
161
+ return "keep-destination";
162
+ }
163
+ throw new Error(`Migration collision at ${destination}.`);
164
+ }
165
+
166
+ function configureCollisions(
167
+ operations: MoveOperation[],
168
+ stateDir: string,
169
+ stagingRoot: string,
170
+ ): void {
125
171
  const destinations = new Set<string>();
126
172
  for (const operation of operations) {
127
173
  if (!operation.destination) continue;
128
- if (existsSync(operation.destination)) {
129
- throw new Error(`Migration collision at ${operation.destination}.`);
130
- }
131
174
  if (destinations.has(operation.destination)) {
132
175
  throw new Error(`Multiple legacy paths target ${operation.destination}.`);
133
176
  }
134
177
  destinations.add(operation.destination);
178
+ operation.collision = collisionPolicy(operation, stateDir);
179
+ if (operation.collision && operation.collision !== "keep-destination") {
180
+ operation.destinationStaged = join(stagingRoot, randomUUID());
181
+ }
135
182
  }
136
183
  }
137
184
 
@@ -141,16 +188,115 @@ function writeJournal(path: string, journal: MigrationJournal): void {
141
188
 
142
189
  function rollbackFilesystem(journal: MigrationJournal, journalPath: string): void {
143
190
  for (const operation of [...journal.operations].reverse()) {
144
- const current = operation.state === "placed" ? operation.destination : operation.staged;
145
- if (!current || !existsSync(current) || existsSync(operation.source)) continue;
146
- mkdirSync(dirname(operation.source), { recursive: true });
147
- renameSync(current, operation.source);
191
+ if (operation.state === "placed") {
192
+ if (operation.collision === "keep-destination" || !operation.destination) {
193
+ if (existsSync(operation.staged) && !existsSync(operation.source)) {
194
+ mkdirSync(dirname(operation.source), { recursive: true });
195
+ renameSync(operation.staged, operation.source);
196
+ }
197
+ } else if (operation.destination && existsSync(operation.destination)) {
198
+ if (operation.collision === "merge-jsonl" || operation.collision === "merge-tasks") {
199
+ rmSync(operation.destination, { recursive: true, force: true });
200
+ if (existsSync(operation.staged) && !existsSync(operation.source)) {
201
+ mkdirSync(dirname(operation.source), { recursive: true });
202
+ renameSync(operation.staged, operation.source);
203
+ }
204
+ } else if (!existsSync(operation.source)) {
205
+ mkdirSync(dirname(operation.source), { recursive: true });
206
+ renameSync(operation.destination, operation.source);
207
+ }
208
+ }
209
+ } else if (existsSync(operation.staged) && !existsSync(operation.source)) {
210
+ mkdirSync(dirname(operation.source), { recursive: true });
211
+ renameSync(operation.staged, operation.source);
212
+ }
213
+ if (
214
+ operation.destination &&
215
+ operation.destinationStaged &&
216
+ existsSync(operation.destinationStaged) &&
217
+ !existsSync(operation.destination)
218
+ ) {
219
+ mkdirSync(dirname(operation.destination), { recursive: true });
220
+ renameSync(operation.destinationStaged, operation.destination);
221
+ }
148
222
  operation.state = "pending";
149
223
  }
150
224
  journal.phase = "filesystem";
151
225
  writeJournal(journalPath, journal);
152
226
  }
153
227
 
228
+ function mergedJsonl(source: string, destination: string): string {
229
+ const lines = new Set<string>();
230
+ for (const path of [source, destination]) {
231
+ for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
232
+ if (line) lines.add(line);
233
+ }
234
+ }
235
+ return lines.size > 0 ? `${[...lines].join("\n")}\n` : "";
236
+ }
237
+
238
+ interface TaskFileShape {
239
+ version: 1;
240
+ tasks: Array<{ id: string; blockedBy?: string[]; [key: string]: unknown }>;
241
+ }
242
+
243
+ function readTaskFile(path: string): TaskFileShape {
244
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<TaskFileShape>;
245
+ if (parsed.version !== 1 || !Array.isArray(parsed.tasks)) {
246
+ throw new Error(`Invalid task file during migration: ${path}`);
247
+ }
248
+ return parsed as TaskFileShape;
249
+ }
250
+
251
+ function mergedTasks(source: string, destination: string): string {
252
+ const legacy = readTaskFile(source).tasks;
253
+ const current = readTaskFile(destination).tasks;
254
+ const merged = legacy.map((task) => ({ ...task }));
255
+ const usedIds = new Set(merged.map((task) => String(task.id)));
256
+ let nextId = Math.max(0, ...[...usedIds].map(Number).filter(Number.isInteger)) + 1;
257
+ const remapped = new Map<string, string>();
258
+
259
+ for (const task of current) {
260
+ const originalId = String(task.id);
261
+ const exact = merged.find(
262
+ (candidate) =>
263
+ candidate.id === originalId && JSON.stringify(candidate) === JSON.stringify(task),
264
+ );
265
+ if (exact) {
266
+ remapped.set(originalId, originalId);
267
+ continue;
268
+ }
269
+ const id = usedIds.has(originalId) ? String(nextId++) : originalId;
270
+ usedIds.add(id);
271
+ remapped.set(originalId, id);
272
+ merged.push({ ...task, id });
273
+ }
274
+ for (const task of merged.slice(legacy.length)) {
275
+ if (task.blockedBy) task.blockedBy = task.blockedBy.map((id) => remapped.get(id) ?? id);
276
+ }
277
+ return `${JSON.stringify({ version: 1, tasks: merged }, null, 2)}\n`;
278
+ }
279
+
280
+ function placeOperation(operation: MoveOperation): void {
281
+ if (!operation.destination || operation.collision === "keep-destination") return;
282
+ mkdirSync(dirname(operation.destination), { recursive: true });
283
+ if (operation.collision === "merge-jsonl") {
284
+ writeFileSync(
285
+ operation.destination,
286
+ mergedJsonl(operation.staged, operation.destinationStaged!),
287
+ );
288
+ return;
289
+ }
290
+ if (operation.collision === "merge-tasks") {
291
+ writeFileSync(
292
+ operation.destination,
293
+ mergedTasks(operation.staged, operation.destinationStaged!),
294
+ );
295
+ return;
296
+ }
297
+ renameSync(operation.staged, operation.destination);
298
+ }
299
+
154
300
  function tablesWithColumn(database: InstanceType<typeof Database>, columnName: string): string[] {
155
301
  const tables = database
156
302
  .query<{ name: string }, []>(
@@ -512,7 +658,7 @@ export function migrateSingleUserState(
512
658
  state: "pending",
513
659
  });
514
660
  }
515
- assertNoCollisions(operations);
661
+ configureCollisions(operations, stateDir, stagingRoot);
516
662
  journal = {
517
663
  id: SINGLE_USER_MIGRATION_ID,
518
664
  sourcePrincipal: source,
@@ -529,30 +675,57 @@ export function migrateSingleUserState(
529
675
  try {
530
676
  for (const operation of journal.operations) {
531
677
  // Reconcile a crash between rename(2) and the following journal write.
678
+ if (operation.state === "staged") operation.state = "source-staged";
679
+ if (operation.state === "pending" && !existsSync(operation.source)) {
680
+ if (existsSync(operation.staged)) operation.state = "source-staged";
681
+ else if (operation.destination && existsSync(operation.destination)) {
682
+ operation.state = "placed";
683
+ }
684
+ }
685
+ if (operation.state === "pending") {
686
+ mkdirSync(dirname(operation.staged), { recursive: true });
687
+ renameSync(operation.source, operation.staged);
688
+ operation.state = "source-staged";
689
+ writeJournal(journalPath, journal);
690
+ }
532
691
  if (
533
- operation.state === "pending" &&
534
- !existsSync(operation.source) &&
535
- existsSync(operation.staged)
692
+ operation.state === "source-staged" &&
693
+ operation.collision === "replace-destination" &&
694
+ operation.destination &&
695
+ operation.destinationStaged &&
696
+ !existsSync(operation.staged) &&
697
+ existsSync(operation.destination) &&
698
+ existsSync(operation.destinationStaged)
536
699
  ) {
537
- operation.state = "staged";
700
+ operation.state = "placed";
701
+ writeJournal(journalPath, journal);
538
702
  }
539
703
  if (
540
- operation.state === "staged" &&
704
+ operation.state === "destination-staged" &&
705
+ operation.collision !== "merge-jsonl" &&
706
+ operation.collision !== "merge-tasks" &&
707
+ operation.collision !== "keep-destination" &&
541
708
  operation.destination &&
542
709
  !existsSync(operation.staged) &&
543
710
  existsSync(operation.destination)
544
711
  ) {
545
712
  operation.state = "placed";
713
+ writeJournal(journalPath, journal);
546
714
  }
547
- if (operation.state === "pending") {
548
- mkdirSync(dirname(operation.staged), { recursive: true });
549
- renameSync(operation.source, operation.staged);
550
- operation.state = "staged";
715
+ if (operation.state === "source-staged") {
716
+ if (operation.destination && operation.destinationStaged) {
717
+ if (existsSync(operation.destination) && !existsSync(operation.destinationStaged)) {
718
+ renameSync(operation.destination, operation.destinationStaged);
719
+ }
720
+ if (!existsSync(operation.destinationStaged)) {
721
+ throw new Error(`Missing collision backup for ${operation.destination}.`);
722
+ }
723
+ }
724
+ operation.state = "destination-staged";
551
725
  writeJournal(journalPath, journal);
552
726
  }
553
- if (operation.state === "staged" && operation.destination) {
554
- mkdirSync(dirname(operation.destination), { recursive: true });
555
- renameSync(operation.staged, operation.destination);
727
+ if (operation.state === "destination-staged") {
728
+ placeOperation(operation);
556
729
  operation.state = "placed";
557
730
  writeJournal(journalPath, journal);
558
731
  }
@@ -598,10 +771,21 @@ export function migrateSingleUserState(
598
771
  migratedTables,
599
772
  };
600
773
  for (const store of ["conversations", "tasks", "users"]) {
601
- rmSync(join(stateDir, "data", store, source), { recursive: true, force: true });
774
+ rmSync(join(stateDir, "data", store, source), {
775
+ recursive: true,
776
+ force: true,
777
+ });
602
778
  }
603
- rmSync(join(stateDir, "workspace", "browser-profiles"), { recursive: true, force: true });
604
- writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600, flag: "wx" });
779
+ rmSync(join(stateDir, "run"), { recursive: true, force: true });
780
+ rmSync(join(stateDir, "bin"), { recursive: true, force: true });
781
+ rmSync(join(stateDir, "workspace", "browser-profiles"), {
782
+ recursive: true,
783
+ force: true,
784
+ });
785
+ writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, {
786
+ mode: 0o600,
787
+ flag: "wx",
788
+ });
605
789
  journal.phase = "complete";
606
790
  rmSync(stagingRoot, { recursive: true, force: true });
607
791
  return {
@@ -1 +1 @@
1
- export const NEGOTIUM_VERSION = "0.2.1";
1
+ export const NEGOTIUM_VERSION = "0.2.2";
@@ -1 +1 @@
1
- export declare const NEGOTIUM_VERSION = "0.2.1";
1
+ export declare const NEGOTIUM_VERSION = "0.2.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "negotium",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "Install the Negotium multi-agent runtime and CLI with one package",
6
6
  "license": "Apache-2.0",