@pellux/goodvibes-sdk 0.21.18 → 0.21.21

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.
Files changed (77) hide show
  1. package/dist/_internal/contracts/generated/runtime-event-domains.d.ts +1 -1
  2. package/dist/_internal/contracts/generated/runtime-event-domains.d.ts.map +1 -1
  3. package/dist/_internal/contracts/generated/runtime-event-domains.js +1 -0
  4. package/dist/_internal/daemon/index.d.ts +1 -1
  5. package/dist/_internal/daemon/index.d.ts.map +1 -1
  6. package/dist/_internal/daemon/runtime-session-routes.d.ts +12 -1
  7. package/dist/_internal/daemon/runtime-session-routes.d.ts.map +1 -1
  8. package/dist/_internal/daemon/runtime-session-routes.js +23 -5
  9. package/dist/_internal/daemon/system-route-types.d.ts +19 -0
  10. package/dist/_internal/daemon/system-route-types.d.ts.map +1 -1
  11. package/dist/_internal/daemon/system-routes.d.ts.map +1 -1
  12. package/dist/_internal/daemon/system-routes.js +18 -0
  13. package/dist/_internal/platform/companion/companion-chat-rate-limiter.d.ts +47 -3
  14. package/dist/_internal/platform/companion/companion-chat-rate-limiter.d.ts.map +1 -1
  15. package/dist/_internal/platform/companion/companion-chat-rate-limiter.js +49 -6
  16. package/dist/_internal/platform/companion/companion-chat-routes.d.ts +13 -0
  17. package/dist/_internal/platform/companion/companion-chat-routes.d.ts.map +1 -1
  18. package/dist/_internal/platform/companion/companion-chat-routes.js +38 -2
  19. package/dist/_internal/platform/config/schema-domain-runtime.d.ts.map +1 -1
  20. package/dist/_internal/platform/config/schema-domain-runtime.js +8 -0
  21. package/dist/_internal/platform/config/schema-types.d.ts +2 -2
  22. package/dist/_internal/platform/config/schema-types.d.ts.map +1 -1
  23. package/dist/_internal/platform/control-plane/method-catalog-events.d.ts.map +1 -1
  24. package/dist/_internal/platform/control-plane/method-catalog-events.js +1 -0
  25. package/dist/_internal/platform/control-plane/session-broker.d.ts +12 -0
  26. package/dist/_internal/platform/control-plane/session-broker.d.ts.map +1 -1
  27. package/dist/_internal/platform/control-plane/session-broker.js +22 -0
  28. package/dist/_internal/platform/daemon/cli.js +51 -4
  29. package/dist/_internal/platform/daemon/facade-composition.d.ts +5 -0
  30. package/dist/_internal/platform/daemon/facade-composition.d.ts.map +1 -1
  31. package/dist/_internal/platform/daemon/facade-composition.js +1 -0
  32. package/dist/_internal/platform/daemon/facade.d.ts.map +1 -1
  33. package/dist/_internal/platform/daemon/facade.js +1 -0
  34. package/dist/_internal/platform/daemon/http/router-route-contexts.d.ts +1 -0
  35. package/dist/_internal/platform/daemon/http/router-route-contexts.d.ts.map +1 -1
  36. package/dist/_internal/platform/daemon/http/router-route-contexts.js +5 -0
  37. package/dist/_internal/platform/daemon/http/router.d.ts +5 -0
  38. package/dist/_internal/platform/daemon/http/router.d.ts.map +1 -1
  39. package/dist/_internal/platform/daemon/http/router.js +1 -0
  40. package/dist/_internal/platform/daemon/http/system-route-types.d.ts +1 -1
  41. package/dist/_internal/platform/daemon/http/system-route-types.d.ts.map +1 -1
  42. package/dist/_internal/platform/daemon/types.d.ts +21 -0
  43. package/dist/_internal/platform/daemon/types.d.ts.map +1 -1
  44. package/dist/_internal/platform/pairing/companion-token.d.ts +2 -0
  45. package/dist/_internal/platform/pairing/companion-token.d.ts.map +1 -1
  46. package/dist/_internal/platform/pairing/companion-token.js +19 -9
  47. package/dist/_internal/platform/runtime/events/domain-map.d.ts +4 -2
  48. package/dist/_internal/platform/runtime/events/domain-map.d.ts.map +1 -1
  49. package/dist/_internal/platform/runtime/events/domain-map.js +1 -0
  50. package/dist/_internal/platform/runtime/events/index.d.ts +4 -1
  51. package/dist/_internal/platform/runtime/events/index.d.ts.map +1 -1
  52. package/dist/_internal/platform/runtime/events/index.js +0 -15
  53. package/dist/_internal/platform/runtime/events/workspace.d.ts +56 -0
  54. package/dist/_internal/platform/runtime/events/workspace.d.ts.map +1 -0
  55. package/dist/_internal/platform/runtime/events/workspace.js +2 -0
  56. package/dist/_internal/platform/runtime/health/effect-handlers.d.ts.map +1 -1
  57. package/dist/_internal/platform/runtime/health/effect-handlers.js +12 -1
  58. package/dist/_internal/platform/runtime/services.d.ts +19 -0
  59. package/dist/_internal/platform/runtime/services.d.ts.map +1 -1
  60. package/dist/_internal/platform/runtime/services.js +31 -0
  61. package/dist/_internal/platform/state/memory-store.d.ts +11 -0
  62. package/dist/_internal/platform/state/memory-store.d.ts.map +1 -1
  63. package/dist/_internal/platform/state/memory-store.js +22 -0
  64. package/dist/_internal/platform/state/project-index.d.ts +13 -3
  65. package/dist/_internal/platform/state/project-index.d.ts.map +1 -1
  66. package/dist/_internal/platform/state/project-index.js +31 -0
  67. package/dist/_internal/platform/tools/state/index.d.ts +23 -0
  68. package/dist/_internal/platform/tools/state/index.d.ts.map +1 -1
  69. package/dist/_internal/platform/tools/state/index.js +49 -5
  70. package/dist/_internal/platform/version.js +1 -1
  71. package/dist/_internal/platform/workspace/daemon-home.d.ts +70 -0
  72. package/dist/_internal/platform/workspace/daemon-home.d.ts.map +1 -0
  73. package/dist/_internal/platform/workspace/daemon-home.js +221 -0
  74. package/dist/_internal/platform/workspace/workspace-swap-manager.d.ts +60 -0
  75. package/dist/_internal/platform/workspace/workspace-swap-manager.d.ts.map +1 -0
  76. package/dist/_internal/platform/workspace/workspace-swap-manager.js +117 -0
  77. package/package.json +1 -1
@@ -51,6 +51,9 @@ export function createStateTool(kvState, projectIndex, options) {
51
51
  const hookDispatcher = options.hookDispatcher;
52
52
  const modeManager = options.modeManager;
53
53
  const telemetryDB = options.telemetryDB;
54
+ const workingDir = options.workingDir;
55
+ const daemonHomeDir = options.daemonHomeDir;
56
+ const swapManager = options.swapManager;
54
57
  // Session start time and telemetry are scoped per-instance so multiple
55
58
  // createStateTool() calls don't share state.
56
59
  const SESSION_START_MS = Date.now();
@@ -82,8 +85,8 @@ export function createStateTool(kvState, projectIndex, options) {
82
85
  const input = args;
83
86
  const { mode } = input;
84
87
  switch (mode) {
85
- case 'get': return runGet(input, kvState);
86
- case 'set': return runSet(input, kvState);
88
+ case 'get': return runGet(input, kvState, workingDir, daemonHomeDir);
89
+ case 'set': return runSet(input, kvState, workingDir, daemonHomeDir, swapManager);
87
90
  case 'list': return runList(input, kvState);
88
91
  case 'clear': return runClear(input, kvState);
89
92
  case 'budget': return runBudget(kvState, projectIndex);
@@ -404,19 +407,60 @@ function runMode(input, manager) {
404
407
  }
405
408
  return { success: false, error: `Unknown mode action: ${String(action)}` };
406
409
  }
407
- async function runGet(input, kvState) {
410
+ /**
411
+ * Well-known read-only keys surfaced directly by the state tool.
412
+ * These keys are injected into the `get` response alongside KVState values
413
+ * and rejected with an error in `set` to prevent mutation.
414
+ */
415
+ const WELL_KNOWN_READONLY_KEYS = new Set(['runtime.workingDir', 'daemon.homeDir']);
416
+ async function runGet(input, kvState, workingDir, daemonHomeDir) {
408
417
  const keys = input.keys ?? [];
409
418
  if (keys.length === 0) {
410
419
  return { success: false, error: 'mode "get" requires a non-empty "keys" array' };
411
420
  }
412
- const values = await kvState.get(keys);
421
+ // Separate well-known keys from regular KV keys
422
+ const kvKeys = keys.filter((k) => !WELL_KNOWN_READONLY_KEYS.has(k));
423
+ const values = kvKeys.length > 0 ? await kvState.get(kvKeys) : {};
424
+ // Inject well-known read-only keys
425
+ if (keys.includes('runtime.workingDir')) {
426
+ values['runtime.workingDir'] = workingDir ?? null;
427
+ }
428
+ if (keys.includes('daemon.homeDir')) {
429
+ values['daemon.homeDir'] = daemonHomeDir ?? null;
430
+ }
413
431
  return { success: true, output: JSON.stringify({ mode: 'get', values }) };
414
432
  }
415
- async function runSet(input, kvState) {
433
+ async function runSet(input, kvState, _workingDir, _daemonHomeDir, swapManager) {
416
434
  const values = input.values;
417
435
  if (!values || typeof values !== 'object') {
418
436
  return { success: false, error: 'mode "set" requires a "values" object' };
419
437
  }
438
+ // Handle runtime.workingDir specially — delegate to swap manager if available.
439
+ if ('runtime.workingDir' in values) {
440
+ const newDir = values['runtime.workingDir'];
441
+ if (typeof newDir !== 'string' || !newDir.trim()) {
442
+ return { success: false, error: 'runtime.workingDir must be a non-empty string.' };
443
+ }
444
+ if (!swapManager) {
445
+ return {
446
+ success: false,
447
+ error: 'Cannot write to runtime.workingDir: no swap manager available. Use POST /config to change runtime.workingDir.',
448
+ };
449
+ }
450
+ const result = await swapManager.requestSwap(newDir);
451
+ if (!result.ok) {
452
+ return { success: false, error: `Workspace swap failed (${result.code ?? 'UNKNOWN'}): ${result.reason ?? 'unknown error'}` };
453
+ }
454
+ return { success: true, output: JSON.stringify({ mode: 'set', swapped: true, newWorkingDir: newDir }) };
455
+ }
456
+ // Reject writes to other read-only well-known keys
457
+ const readonlyAttempts = Object.keys(values).filter((k) => WELL_KNOWN_READONLY_KEYS.has(k));
458
+ if (readonlyAttempts.length > 0) {
459
+ return {
460
+ success: false,
461
+ error: `Cannot write to read-only well-known key(s): ${readonlyAttempts.join(', ')}. These keys are immutable at runtime.`,
462
+ };
463
+ }
420
464
  await kvState.set(values);
421
465
  // Only report keys that KVState actually persists (exclude reserved keys).
422
466
  const written = Object.keys(values).filter((k) => !RESERVED_KEYS.has(k));
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- let version = '0.21.18';
3
+ let version = '0.21.21';
4
4
  try {
5
5
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'package.json'), 'utf-8'));
6
6
  version = pkg.version ?? version;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * daemon-home.ts
3
+ *
4
+ * Resolves and manages the daemon's identity home directory (`daemon.homeDir`).
5
+ *
6
+ * The daemon home holds immutable-after-startup identity state:
7
+ * - auth-users.json
8
+ * - auth-bootstrap.txt
9
+ * - daemon-settings.json
10
+ * - operator-tokens.json
11
+ *
12
+ * Resolution order (first match wins):
13
+ * 1. --daemon-home=<path> CLI arg (passed as daemonHomeArg)
14
+ * 2. GOODVIBES_DAEMON_HOME environment variable
15
+ * 3. ~/.goodvibes/daemon/
16
+ *
17
+ * One-time migration (run on first 0.21.19+ startup):
18
+ * - If ~/.goodvibes/daemon/ does not exist:
19
+ * - ~/.goodvibes/tui/auth-users.json → ~/.goodvibes/daemon/auth-users.json
20
+ * - ~/.goodvibes/tui/auth-bootstrap.txt → ~/.goodvibes/daemon/auth-bootstrap.txt
21
+ * - <cwd>/.goodvibes/operator-tokens.json → ~/.goodvibes/daemon/operator-tokens.json (F3 revision)
22
+ * - Old paths are left intact (never deleted) to avoid breaking older binaries.
23
+ */
24
+ import type { RuntimeEventBus } from '../runtime/events/index.js';
25
+ export interface DaemonHomeDirs {
26
+ /** Absolute path to the daemon home directory (immutable identity state). */
27
+ readonly daemonHomeDir: string;
28
+ /** True if this is the first startup at this daemon home (migration was run). */
29
+ readonly freshInstall: boolean;
30
+ }
31
+ export interface DaemonHomeOptions {
32
+ /** Value of --daemon-home CLI flag, if provided. */
33
+ readonly daemonHomeArg?: string | undefined;
34
+ /** Current working directory, used as base for operator-tokens migration. */
35
+ readonly cwd?: string;
36
+ /** Override process.env for testing. */
37
+ readonly env?: NodeJS.ProcessEnv;
38
+ }
39
+ /**
40
+ * Optional dependencies for migration event emission.
41
+ * When provided, migration outcomes are broadcast on the runtime event bus
42
+ * under the 'workspace' domain.
43
+ */
44
+ export interface DaemonHomeMigrationDeps {
45
+ readonly runtimeBus?: RuntimeEventBus;
46
+ }
47
+ /**
48
+ * Resolve the daemon home directory from CLI flag, environment variable, or default.
49
+ */
50
+ export declare function resolveDaemonHomeDir(options?: DaemonHomeOptions): string;
51
+ /**
52
+ * Run migration if the daemon home directory does not yet exist.
53
+ * Creates the directory and copies identity files from legacy paths if found.
54
+ * Old files are NOT deleted.
55
+ *
56
+ * Returns `freshInstall: true` when migration ran, `false` when the dir already existed.
57
+ */
58
+ export declare function runDaemonHomeMigration(daemonHomeDir: string, options?: DaemonHomeOptions, deps?: DaemonHomeMigrationDeps): DaemonHomeDirs;
59
+ /**
60
+ * Read a single key from daemon-settings.json, or return undefined if missing.
61
+ */
62
+ export declare function readDaemonSetting(daemonHomeDir: string, key: string): string | undefined;
63
+ /**
64
+ * Write a single key into daemon-settings.json (merge, not replace).
65
+ *
66
+ * Uses a write-to-tmp-then-rename pattern for atomicity:
67
+ * a crash between write and rename leaves a .tmp file, never a corrupt target.
68
+ */
69
+ export declare function writeDaemonSetting(daemonHomeDir: string, key: string, value: string): void;
70
+ //# sourceMappingURL=daemon-home.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"daemon-home.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/workspace/daemon-home.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAOH,OAAO,KAAK,EAAE,eAAe,EAAwB,MAAM,4BAA4B,CAAC;AAOxF,MAAM,WAAW,cAAc;IAC7B,6EAA6E;IAC7E,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,oDAAoD;IACpD,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5C,6EAA6E;IAC7E,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,wCAAwC;IACxC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CAClC;AAED;;;;GAIG;AACH,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,UAAU,CAAC,EAAE,eAAe,CAAC;CACvC;AAMD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,GAAE,iBAAsB,GAAG,MAAM,CAiB5E;AAMD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,aAAa,EAAE,MAAM,EACrB,OAAO,GAAE,iBAAsB,EAC/B,IAAI,GAAE,uBAA4B,GACjC,cAAc,CAoFhB;AAMD;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAWxF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAc1F"}
@@ -0,0 +1,221 @@
1
+ /**
2
+ * daemon-home.ts
3
+ *
4
+ * Resolves and manages the daemon's identity home directory (`daemon.homeDir`).
5
+ *
6
+ * The daemon home holds immutable-after-startup identity state:
7
+ * - auth-users.json
8
+ * - auth-bootstrap.txt
9
+ * - daemon-settings.json
10
+ * - operator-tokens.json
11
+ *
12
+ * Resolution order (first match wins):
13
+ * 1. --daemon-home=<path> CLI arg (passed as daemonHomeArg)
14
+ * 2. GOODVIBES_DAEMON_HOME environment variable
15
+ * 3. ~/.goodvibes/daemon/
16
+ *
17
+ * One-time migration (run on first 0.21.19+ startup):
18
+ * - If ~/.goodvibes/daemon/ does not exist:
19
+ * - ~/.goodvibes/tui/auth-users.json → ~/.goodvibes/daemon/auth-users.json
20
+ * - ~/.goodvibes/tui/auth-bootstrap.txt → ~/.goodvibes/daemon/auth-bootstrap.txt
21
+ * - <cwd>/.goodvibes/operator-tokens.json → ~/.goodvibes/daemon/operator-tokens.json (F3 revision)
22
+ * - Old paths are left intact (never deleted) to avoid breaking older binaries.
23
+ */
24
+ import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, renameSync, readdirSync } from 'node:fs';
25
+ import { join, isAbsolute, resolve, dirname } from 'node:path';
26
+ import { homedir } from 'node:os';
27
+ import { logger } from '../utils/logger.js';
28
+ import { createEventEnvelope } from '../runtime/events/index.js';
29
+ // ---------------------------------------------------------------------------
30
+ // Resolution
31
+ // ---------------------------------------------------------------------------
32
+ /**
33
+ * Resolve the daemon home directory from CLI flag, environment variable, or default.
34
+ */
35
+ export function resolveDaemonHomeDir(options = {}) {
36
+ const env = options.env ?? process.env;
37
+ // 1. CLI arg
38
+ if (options.daemonHomeArg) {
39
+ const p = options.daemonHomeArg.trim();
40
+ if (p)
41
+ return isAbsolute(p) ? resolve(p) : resolve(process.cwd(), p);
42
+ }
43
+ // 2. Env var
44
+ const envVal = env['GOODVIBES_DAEMON_HOME']?.trim();
45
+ if (envVal) {
46
+ return isAbsolute(envVal) ? resolve(envVal) : resolve(process.cwd(), envVal);
47
+ }
48
+ // 3. Default: ~/.goodvibes/daemon/
49
+ return join(homedir(), '.goodvibes', 'daemon');
50
+ }
51
+ // ---------------------------------------------------------------------------
52
+ // One-time migration
53
+ // ---------------------------------------------------------------------------
54
+ /**
55
+ * Run migration if the daemon home directory does not yet exist.
56
+ * Creates the directory and copies identity files from legacy paths if found.
57
+ * Old files are NOT deleted.
58
+ *
59
+ * Returns `freshInstall: true` when migration ran, `false` when the dir already existed.
60
+ */
61
+ export function runDaemonHomeMigration(daemonHomeDir, options = {}, deps = {}) {
62
+ const alreadyExists = existsSync(daemonHomeDir);
63
+ if (alreadyExists) {
64
+ return { daemonHomeDir, freshInstall: false };
65
+ }
66
+ // Create the daemon home directory tree
67
+ mkdirSync(daemonHomeDir, { recursive: true });
68
+ const userGoodVibesRoot = join(homedir(), '.goodvibes');
69
+ const cwd = options.cwd ?? process.cwd();
70
+ // Migrate auth-users.json from tui surface path
71
+ const legacyAuthUsers = join(userGoodVibesRoot, 'tui', 'auth-users.json');
72
+ if (existsSync(legacyAuthUsers)) {
73
+ safeCopy(legacyAuthUsers, join(daemonHomeDir, 'auth-users.json'));
74
+ }
75
+ // Migrate auth-bootstrap.txt from tui surface path
76
+ const legacyBootstrap = join(userGoodVibesRoot, 'tui', 'auth-bootstrap.txt');
77
+ if (existsSync(legacyBootstrap)) {
78
+ safeCopy(legacyBootstrap, join(daemonHomeDir, 'auth-bootstrap.txt'));
79
+ }
80
+ // Migrate operator-tokens.json — search multiple legacy paths (F3 revision).
81
+ // 0.21.17 used <cwd>/.goodvibes/operator-tokens.json (workspace-scoped).
82
+ // 0.21.16 and earlier used surface-scoped ~/.goodvibes/<surface>/companion-token.json.
83
+ // 0.21.19+ canonical path is <daemonHomeDir>/operator-tokens.json.
84
+ const destTokenPath = join(daemonHomeDir, 'operator-tokens.json');
85
+ if (!existsSync(destTokenPath)) {
86
+ // Priority 1: workspace-scoped path from 0.21.17
87
+ const legacyWorkspaceTokens = join(cwd, '.goodvibes', 'operator-tokens.json');
88
+ // Priority 2: surface-scoped legacy tokens from 0.21.16 and earlier
89
+ const legacySurfaceToken = join(userGoodVibesRoot, 'tui', 'companion-token.json');
90
+ // Priority 3: XDG data home if set
91
+ const xdgDataHome = options.env?.['XDG_DATA_HOME'];
92
+ const xdgToken = xdgDataHome ? join(xdgDataHome, 'goodvibes', 'operator-tokens.json') : null;
93
+ // Scan for any surface-scoped companion-token.json files under ~/.goodvibes/
94
+ const surfaceScopedTokens = [];
95
+ try {
96
+ const entries = readdirSync(userGoodVibesRoot, { withFileTypes: true });
97
+ for (const entry of entries) {
98
+ if (entry.isDirectory()) {
99
+ const candidate = join(userGoodVibesRoot, entry.name, 'companion-token.json');
100
+ if (existsSync(candidate))
101
+ surfaceScopedTokens.push(candidate);
102
+ }
103
+ }
104
+ }
105
+ catch {
106
+ // Best-effort scan
107
+ }
108
+ const tokenSources = [
109
+ legacyWorkspaceTokens,
110
+ ...(xdgToken ? [xdgToken] : []),
111
+ legacySurfaceToken,
112
+ ...surfaceScopedTokens,
113
+ ];
114
+ for (const src of tokenSources) {
115
+ if (!existsSync(src))
116
+ continue;
117
+ // Validate JSON before copying — corrupt JSON must not be migrated.
118
+ try {
119
+ JSON.parse(readFileSync(src, 'utf-8'));
120
+ }
121
+ catch (parseErr) {
122
+ const reason = parseErr instanceof Error ? parseErr.message : String(parseErr);
123
+ logger.warn('daemon-home: skipping corrupt token file during migration', {
124
+ sourcePath: src,
125
+ reason,
126
+ });
127
+ _emitMigrationEvent(deps.runtimeBus, { type: 'WORKSPACE_IDENTITY_MIGRATION_FAILED', sourcePath: src, reason });
128
+ safeCopy(src, destTokenPath, { skipIfInvalid: true });
129
+ continue;
130
+ }
131
+ if (safeCopy(src, destTokenPath)) {
132
+ logger.info('daemon-home: migrated operator token', { from: src, to: destTokenPath });
133
+ _emitMigrationEvent(deps.runtimeBus, { type: 'WORKSPACE_IDENTITY_MIGRATED', from: src, to: destTokenPath });
134
+ }
135
+ break; // First valid source wins
136
+ }
137
+ }
138
+ return { daemonHomeDir, freshInstall: true };
139
+ }
140
+ // ---------------------------------------------------------------------------
141
+ // Daemon settings persistence
142
+ // ---------------------------------------------------------------------------
143
+ /**
144
+ * Read a single key from daemon-settings.json, or return undefined if missing.
145
+ */
146
+ export function readDaemonSetting(daemonHomeDir, key) {
147
+ const settingsPath = join(daemonHomeDir, 'daemon-settings.json');
148
+ if (!existsSync(settingsPath))
149
+ return undefined;
150
+ try {
151
+ const raw = readFileSync(settingsPath, 'utf-8');
152
+ const parsed = JSON.parse(raw);
153
+ const val = parsed[key];
154
+ return typeof val === 'string' ? val : undefined;
155
+ }
156
+ catch {
157
+ return undefined;
158
+ }
159
+ }
160
+ /**
161
+ * Write a single key into daemon-settings.json (merge, not replace).
162
+ *
163
+ * Uses a write-to-tmp-then-rename pattern for atomicity:
164
+ * a crash between write and rename leaves a .tmp file, never a corrupt target.
165
+ */
166
+ export function writeDaemonSetting(daemonHomeDir, key, value) {
167
+ mkdirSync(daemonHomeDir, { recursive: true });
168
+ const settingsPath = join(daemonHomeDir, 'daemon-settings.json');
169
+ const tmpPath = settingsPath + '.tmp';
170
+ let existing = {};
171
+ if (existsSync(settingsPath)) {
172
+ try {
173
+ existing = JSON.parse(readFileSync(settingsPath, 'utf-8'));
174
+ }
175
+ catch {
176
+ // Overwrite corrupt file
177
+ }
178
+ }
179
+ writeFileSync(tmpPath, JSON.stringify({ ...existing, [key]: value }, null, 2), 'utf-8');
180
+ renameSync(tmpPath, settingsPath);
181
+ }
182
+ // ---------------------------------------------------------------------------
183
+ // Helpers
184
+ // ---------------------------------------------------------------------------
185
+ /**
186
+ * Emit a workspace migration event on the runtime bus.
187
+ * Never throws — bus emission must not interrupt migration.
188
+ */
189
+ function _emitMigrationEvent(bus, payload) {
190
+ if (!bus)
191
+ return;
192
+ try {
193
+ const envelope = createEventEnvelope(payload.type, payload, { sessionId: '', source: 'daemon-home-migration' });
194
+ bus.emit('workspace',
195
+ // WorkspaceEvent discriminated-union member; single widening cast is safe.
196
+ envelope);
197
+ }
198
+ catch {
199
+ // Swallow — never let event emission break migration
200
+ }
201
+ }
202
+ /**
203
+ * Copy src to dest. Returns true on success, false on failure.
204
+ * Failures are logged at warn level. Never throws.
205
+ */
206
+ function safeCopy(src, dest, opts) {
207
+ if (opts?.skipIfInvalid)
208
+ return false;
209
+ try {
210
+ copyFileSync(src, dest);
211
+ return true;
212
+ }
213
+ catch (err) {
214
+ logger.warn('daemon-home: safeCopy failed', {
215
+ src,
216
+ dest,
217
+ error: err instanceof Error ? err.message : String(err),
218
+ });
219
+ return false;
220
+ }
221
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * workspace-swap-manager.ts
3
+ *
4
+ * Manages mutable runtime.workingDir transitions.
5
+ *
6
+ * The working directory hosts all conversation-scoped state:
7
+ * sessions, memory, logs, artifacts, knowledge stores. It can be swapped
8
+ * at runtime via POST /config {key:"runtime.workingDir", value:"<path>"}.
9
+ *
10
+ * Swap policy:
11
+ * - Refused with WORKSPACE_BUSY when any session has pendingInputCount > 0.
12
+ * - Returns INVALID_PATH when the new path cannot be created.
13
+ * - On success, persists the new path to <daemonHomeDir>/daemon-settings.json.
14
+ *
15
+ * Events emitted on runtimeBus (domain: 'workspace'):
16
+ * WORKSPACE_SWAP_STARTED — before swap begins
17
+ * WORKSPACE_SWAP_REFUSED — when swap is rejected
18
+ * WORKSPACE_SWAP_COMPLETED — after all stores re-rooted
19
+ */
20
+ import type { RuntimeEventBus } from '../runtime/events/index.js';
21
+ export interface WorkspaceSwapDeps {
22
+ readonly runtimeBus: RuntimeEventBus | null;
23
+ readonly daemonHomeDir: string;
24
+ /**
25
+ * Called by the manager to check whether any session currently has pending
26
+ * input (i.e. `pendingInputCount > 0`). Returns the count of busy sessions.
27
+ */
28
+ readonly getBusySessionCount: () => number;
29
+ /**
30
+ * Called after the manager validates the new path.
31
+ * Implementations must close existing stores and re-open them at newWorkingDir.
32
+ * May throw; the manager catches and returns INVALID_PATH.
33
+ */
34
+ readonly rerootStores: (newWorkingDir: string) => Promise<void>;
35
+ }
36
+ export type WorkspaceSwapResult = {
37
+ ok: true;
38
+ previous: string;
39
+ current: string;
40
+ } | {
41
+ ok: false;
42
+ code: 'WORKSPACE_BUSY';
43
+ reason: string;
44
+ retryAfter: number;
45
+ } | {
46
+ ok: false;
47
+ code: 'INVALID_PATH';
48
+ reason: string;
49
+ };
50
+ export declare class WorkspaceSwapManager {
51
+ private readonly deps;
52
+ private currentWorkingDir;
53
+ private swapInProgress;
54
+ constructor(initialWorkingDir: string, deps: WorkspaceSwapDeps);
55
+ getCurrentWorkingDir(): string;
56
+ requestSwap(newWorkingDir: string): Promise<WorkspaceSwapResult>;
57
+ private _requestSwapInner;
58
+ private _emit;
59
+ }
60
+ //# sourceMappingURL=workspace-swap-manager.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-swap-manager.d.ts","sourceRoot":"","sources":["../../../../src/_internal/platform/workspace/workspace-swap-manager.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAMH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAOlE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,UAAU,EAAE,eAAe,GAAG,IAAI,CAAC;IAC5C,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,mBAAmB,EAAE,MAAM,MAAM,CAAC;IAC3C;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACjE;AAMD,MAAM,MAAM,mBAAmB,GAC3B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,gBAAgB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GACzE;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,cAAc,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAMxD,qBAAa,oBAAoB;IAM7B,OAAO,CAAC,QAAQ,CAAC,IAAI;IALvB,OAAO,CAAC,iBAAiB,CAAS;IAClC,OAAO,CAAC,cAAc,CAA6C;gBAGjE,iBAAiB,EAAE,MAAM,EACR,IAAI,EAAE,iBAAiB;IAK1C,oBAAoB,IAAI,MAAM;IAIxB,WAAW,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC;YAYxD,iBAAiB;IAgE/B,OAAO,CAAC,KAAK;CAmBd"}
@@ -0,0 +1,117 @@
1
+ /**
2
+ * workspace-swap-manager.ts
3
+ *
4
+ * Manages mutable runtime.workingDir transitions.
5
+ *
6
+ * The working directory hosts all conversation-scoped state:
7
+ * sessions, memory, logs, artifacts, knowledge stores. It can be swapped
8
+ * at runtime via POST /config {key:"runtime.workingDir", value:"<path>"}.
9
+ *
10
+ * Swap policy:
11
+ * - Refused with WORKSPACE_BUSY when any session has pendingInputCount > 0.
12
+ * - Returns INVALID_PATH when the new path cannot be created.
13
+ * - On success, persists the new path to <daemonHomeDir>/daemon-settings.json.
14
+ *
15
+ * Events emitted on runtimeBus (domain: 'workspace'):
16
+ * WORKSPACE_SWAP_STARTED — before swap begins
17
+ * WORKSPACE_SWAP_REFUSED — when swap is rejected
18
+ * WORKSPACE_SWAP_COMPLETED — after all stores re-rooted
19
+ */
20
+ import { mkdirSync, existsSync } from 'node:fs';
21
+ import { isAbsolute, resolve, join } from 'node:path';
22
+ import { writeDaemonSetting } from './daemon-home.js';
23
+ import { createEventEnvelope } from '../runtime/events/index.js';
24
+ // ---------------------------------------------------------------------------
25
+ // WorkspaceSwapManager
26
+ // ---------------------------------------------------------------------------
27
+ export class WorkspaceSwapManager {
28
+ deps;
29
+ currentWorkingDir;
30
+ swapInProgress = null;
31
+ constructor(initialWorkingDir, deps) {
32
+ this.deps = deps;
33
+ this.currentWorkingDir = initialWorkingDir;
34
+ }
35
+ getCurrentWorkingDir() {
36
+ return this.currentWorkingDir;
37
+ }
38
+ async requestSwap(newWorkingDir) {
39
+ if (this.swapInProgress) {
40
+ return { ok: false, code: 'WORKSPACE_BUSY', reason: 'Another workspace swap is already in progress.', retryAfter: 1 };
41
+ }
42
+ this.swapInProgress = this._requestSwapInner(newWorkingDir);
43
+ try {
44
+ return await this.swapInProgress;
45
+ }
46
+ finally {
47
+ this.swapInProgress = null;
48
+ }
49
+ }
50
+ async _requestSwapInner(newWorkingDir) {
51
+ const raw = newWorkingDir.trim();
52
+ if (!raw) {
53
+ return { ok: false, code: 'INVALID_PATH', reason: 'Working directory path must not be empty.' };
54
+ }
55
+ const resolved = isAbsolute(raw) ? resolve(raw) : resolve(this.currentWorkingDir, raw);
56
+ const from = this.currentWorkingDir;
57
+ const to = resolved;
58
+ // Check busy sessions BEFORE emitting STARTED — don't emit STARTED if we'll immediately refuse.
59
+ const busyCount = this.deps.getBusySessionCount();
60
+ if (busyCount > 0) {
61
+ const reason = `${busyCount} session(s) have pending input. Retry after active inputs complete.`;
62
+ this._emit({ type: 'WORKSPACE_SWAP_REFUSED', from, to, reason, retryAfter: 5 });
63
+ return { ok: false, code: 'WORKSPACE_BUSY', reason, retryAfter: 5 };
64
+ }
65
+ // Swap is proceeding — emit STARTED now that busy check passed.
66
+ this._emit({ type: 'WORKSPACE_SWAP_STARTED', from, to });
67
+ // Validate and create directory
68
+ try {
69
+ mkdirSync(join(resolved, '.goodvibes', 'sessions'), { recursive: true });
70
+ mkdirSync(join(resolved, '.goodvibes', 'memory'), { recursive: true });
71
+ mkdirSync(join(resolved, '.goodvibes', 'logs'), { recursive: true });
72
+ }
73
+ catch (err) {
74
+ const reason = `Cannot create workspace directory at '${resolved}': ${err instanceof Error ? err.message : String(err)}`;
75
+ return { ok: false, code: 'INVALID_PATH', reason };
76
+ }
77
+ // Re-root all stores
78
+ try {
79
+ await this.deps.rerootStores(resolved);
80
+ }
81
+ catch (err) {
82
+ const reason = `Failed to re-initialize stores at '${resolved}': ${err instanceof Error ? err.message : String(err)}`;
83
+ return { ok: false, code: 'INVALID_PATH', reason };
84
+ }
85
+ // Update internal state
86
+ this.currentWorkingDir = resolved;
87
+ // Persist to daemon settings
88
+ let persistedInDaemonSettings = false;
89
+ try {
90
+ writeDaemonSetting(this.deps.daemonHomeDir, 'runtime.workingDir', resolved);
91
+ persistedInDaemonSettings = true;
92
+ }
93
+ catch {
94
+ // Non-fatal — swap succeeded but persistence failed
95
+ }
96
+ this._emit({ type: 'WORKSPACE_SWAP_COMPLETED', from, to: resolved, persistedInDaemonSettings });
97
+ return { ok: true, previous: from, current: resolved };
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Internal helpers
101
+ // ---------------------------------------------------------------------------
102
+ _emit(payload) {
103
+ if (!this.deps.runtimeBus)
104
+ return;
105
+ try {
106
+ const envelope = createEventEnvelope(payload.type, payload, { sessionId: '', source: 'workspace-swap-manager' });
107
+ this.deps.runtimeBus.emit('workspace',
108
+ // WorkspaceEvent is the DomainEventMap['workspace'] union; createEventEnvelope
109
+ // infers the payload type as the concrete member rather than the full union.
110
+ // A single widening cast (not through unknown) is safe here.
111
+ envelope);
112
+ }
113
+ catch {
114
+ // Never throw from event emission
115
+ }
116
+ }
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-sdk",
3
- "version": "0.21.18",
3
+ "version": "0.21.21",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/mgd34msu/goodvibes-sdk.git"