@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.
- package/dist/_internal/contracts/generated/runtime-event-domains.d.ts +1 -1
- package/dist/_internal/contracts/generated/runtime-event-domains.d.ts.map +1 -1
- package/dist/_internal/contracts/generated/runtime-event-domains.js +1 -0
- package/dist/_internal/daemon/index.d.ts +1 -1
- package/dist/_internal/daemon/index.d.ts.map +1 -1
- package/dist/_internal/daemon/runtime-session-routes.d.ts +12 -1
- package/dist/_internal/daemon/runtime-session-routes.d.ts.map +1 -1
- package/dist/_internal/daemon/runtime-session-routes.js +23 -5
- package/dist/_internal/daemon/system-route-types.d.ts +19 -0
- package/dist/_internal/daemon/system-route-types.d.ts.map +1 -1
- package/dist/_internal/daemon/system-routes.d.ts.map +1 -1
- package/dist/_internal/daemon/system-routes.js +18 -0
- package/dist/_internal/platform/companion/companion-chat-rate-limiter.d.ts +47 -3
- package/dist/_internal/platform/companion/companion-chat-rate-limiter.d.ts.map +1 -1
- package/dist/_internal/platform/companion/companion-chat-rate-limiter.js +49 -6
- package/dist/_internal/platform/companion/companion-chat-routes.d.ts +13 -0
- package/dist/_internal/platform/companion/companion-chat-routes.d.ts.map +1 -1
- package/dist/_internal/platform/companion/companion-chat-routes.js +38 -2
- package/dist/_internal/platform/config/schema-domain-runtime.d.ts.map +1 -1
- package/dist/_internal/platform/config/schema-domain-runtime.js +8 -0
- package/dist/_internal/platform/config/schema-types.d.ts +2 -2
- package/dist/_internal/platform/config/schema-types.d.ts.map +1 -1
- package/dist/_internal/platform/control-plane/method-catalog-events.d.ts.map +1 -1
- package/dist/_internal/platform/control-plane/method-catalog-events.js +1 -0
- package/dist/_internal/platform/control-plane/session-broker.d.ts +12 -0
- package/dist/_internal/platform/control-plane/session-broker.d.ts.map +1 -1
- package/dist/_internal/platform/control-plane/session-broker.js +22 -0
- package/dist/_internal/platform/daemon/cli.js +51 -4
- package/dist/_internal/platform/daemon/facade-composition.d.ts +5 -0
- package/dist/_internal/platform/daemon/facade-composition.d.ts.map +1 -1
- package/dist/_internal/platform/daemon/facade-composition.js +1 -0
- package/dist/_internal/platform/daemon/facade.d.ts.map +1 -1
- package/dist/_internal/platform/daemon/facade.js +1 -0
- package/dist/_internal/platform/daemon/http/router-route-contexts.d.ts +1 -0
- package/dist/_internal/platform/daemon/http/router-route-contexts.d.ts.map +1 -1
- package/dist/_internal/platform/daemon/http/router-route-contexts.js +5 -0
- package/dist/_internal/platform/daemon/http/router.d.ts +5 -0
- package/dist/_internal/platform/daemon/http/router.d.ts.map +1 -1
- package/dist/_internal/platform/daemon/http/router.js +1 -0
- package/dist/_internal/platform/daemon/http/system-route-types.d.ts +1 -1
- package/dist/_internal/platform/daemon/http/system-route-types.d.ts.map +1 -1
- package/dist/_internal/platform/daemon/types.d.ts +21 -0
- package/dist/_internal/platform/daemon/types.d.ts.map +1 -1
- package/dist/_internal/platform/pairing/companion-token.d.ts +2 -0
- package/dist/_internal/platform/pairing/companion-token.d.ts.map +1 -1
- package/dist/_internal/platform/pairing/companion-token.js +19 -9
- package/dist/_internal/platform/runtime/events/domain-map.d.ts +4 -2
- package/dist/_internal/platform/runtime/events/domain-map.d.ts.map +1 -1
- package/dist/_internal/platform/runtime/events/domain-map.js +1 -0
- package/dist/_internal/platform/runtime/events/index.d.ts +4 -1
- package/dist/_internal/platform/runtime/events/index.d.ts.map +1 -1
- package/dist/_internal/platform/runtime/events/index.js +0 -15
- package/dist/_internal/platform/runtime/events/workspace.d.ts +56 -0
- package/dist/_internal/platform/runtime/events/workspace.d.ts.map +1 -0
- package/dist/_internal/platform/runtime/events/workspace.js +2 -0
- package/dist/_internal/platform/runtime/health/effect-handlers.d.ts.map +1 -1
- package/dist/_internal/platform/runtime/health/effect-handlers.js +12 -1
- package/dist/_internal/platform/runtime/services.d.ts +19 -0
- package/dist/_internal/platform/runtime/services.d.ts.map +1 -1
- package/dist/_internal/platform/runtime/services.js +31 -0
- package/dist/_internal/platform/state/memory-store.d.ts +11 -0
- package/dist/_internal/platform/state/memory-store.d.ts.map +1 -1
- package/dist/_internal/platform/state/memory-store.js +22 -0
- package/dist/_internal/platform/state/project-index.d.ts +13 -3
- package/dist/_internal/platform/state/project-index.d.ts.map +1 -1
- package/dist/_internal/platform/state/project-index.js +31 -0
- package/dist/_internal/platform/tools/state/index.d.ts +23 -0
- package/dist/_internal/platform/tools/state/index.d.ts.map +1 -1
- package/dist/_internal/platform/tools/state/index.js +49 -5
- package/dist/_internal/platform/version.js +1 -1
- package/dist/_internal/platform/workspace/daemon-home.d.ts +70 -0
- package/dist/_internal/platform/workspace/daemon-home.d.ts.map +1 -0
- package/dist/_internal/platform/workspace/daemon-home.js +221 -0
- package/dist/_internal/platform/workspace/workspace-swap-manager.d.ts +60 -0
- package/dist/_internal/platform/workspace/workspace-swap-manager.d.ts.map +1 -0
- package/dist/_internal/platform/workspace/workspace-swap-manager.js +117 -0
- 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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|