From ebfd2511c877f6b36f02951bd49cee412b93d29f Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Tue, 25 Aug 2026 16:02:52 +0200 Subject: [PATCH] feat(data-bridge): add getEnvState command with atomic injection Add a no-arg `dataBridge.getEnvState` command returning `{ injected, environment }` so consumers can fetch the full injected environment (including arbitrary keys they do not know by name) and detect when injection has completed. Make injection atomic (`setAll`): the whole map is persisted once and only then is `injected` set, so a polling consumer never observes a ready flag with a partially written map. Restored non-empty persisted state is treated as already injected to avoid stalls on restart. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WXYeqEViPumEU4DE2TMxW9 --- README.md | 7 ++-- package.json | 4 +++ src/service/commands.ts | 10 ++++++ src/service/data.ts | 18 ++++++++-- src/service/storage.ts | 29 +++++++++++++++ src/test/extension.test.ts | 73 +++++++++++++++++++++++++++++++++----- 6 files changed, 128 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 851ed9e..362b556 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,11 @@ It will store those data in memory and offer them to other extensions via VSCode The extension exposes a set of VSCode commands to handle retrieval of data. - `dataBridge.getEnv` - - Takes a list of environment variables - - Returns a dictionary of stored environment variables + - Takes a list of environment variable names + - Returns a dictionary of the requested stored environment variables +- `dataBridge.getEnvState` + - Takes no arguments + - Returns `{ injected: boolean, environment: Record }`, where `environment` is the full stored map and `injected` is `true` once at least one `POST /data` injection has been applied. Consumers that do not know the variable names in advance poll this until `injected` is `true`, then read every key. Injection is applied atomically, so a `true` `injected` flag always accompanies a complete map. ### Data Storage diff --git a/package.json b/package.json index 7006224..48f60e8 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,10 @@ "command": "dataBridge.getEnv", "title": "Retrieve a list of environment variables" }, + { + "command": "dataBridge.getEnvState", + "title": "Retrieve the full injected environment and readiness flag" + }, { "command": "dataBridge.showLogs", "title": "Data Bridge: Show Logs" diff --git a/src/service/commands.ts b/src/service/commands.ts index 81d9d33..76a329d 100644 --- a/src/service/commands.ts +++ b/src/service/commands.ts @@ -21,6 +21,16 @@ export default class CommandRegistry { return this.dataService.getEnvVars(request); }); + // Register command to return the full injected environment plus a readiness + // flag (no arguments, so it bypasses the schema-validating helper). + const getEnvStateDisposable = vscode.commands.registerCommand( + `${CommandRegistry.COMMAND_PREFIX}.getEnvState`, + () => { + return this.dataService.getEnvState(); + }, + ); + this.context.subscriptions.push(getEnvStateDisposable); + // Register command to show logs (no validation needed) const showLogsDisposable = vscode.commands.registerCommand( `${CommandRegistry.COMMAND_PREFIX}.showLogs`, diff --git a/src/service/data.ts b/src/service/data.ts index 7fde5e8..533c6fe 100644 --- a/src/service/data.ts +++ b/src/service/data.ts @@ -12,9 +12,9 @@ class DataService { public async inject(request: DataInjectRequest): Promise { const keys = Object.keys(request.environment).length; logger.debug(`Injecting ${keys} environment variable(s)`); - for (const [key, value] of Object.entries(request.environment)) { - await this.storage.setEnv(key, value); - } + // Apply the whole payload atomically so consumers polling getEnvState never + // observe a "ready" state with a partially written environment map. + await this.storage.setAll(request.environment); } public getEnvVars(request: getEnvCommandRequest): Record { @@ -25,6 +25,18 @@ class DataService { .filter(([_, value]) => value !== undefined), ); } + + /** + * Returns the full injected environment together with an `injected` readiness + * flag. Consumers that do not know the variable names in advance poll this until + * `injected` is true, then read every key from `environment`. + */ + public getEnvState(): { injected: boolean; environment: Record } { + return { + injected: this.storage.isInjected(), + environment: this.storage.getAll(), + }; + } } export default DataService; diff --git a/src/service/storage.ts b/src/service/storage.ts index f2ff382..c231ae5 100644 --- a/src/service/storage.ts +++ b/src/service/storage.ts @@ -8,6 +8,7 @@ interface Storage { export default class DataStorage { private readonly storage: Storage; private readonly persistence: SecretStoragePersistence; + private injected = false; private constructor(persistence: SecretStoragePersistence) { this.storage = { @@ -22,6 +23,9 @@ export default class DataStorage { const storage = new DataStorage(persistence); const persisted = await persistence.loadAll(); storage.storage.environment = persisted; + // Treat restored non-empty state as already injected so that a pod/extension + // restart does not make consumers wait for a fresh injection that will not come. + storage.injected = Object.keys(persisted).length > 0; logger.info(`Loaded ${Object.keys(persisted).length} persisted env var(s)`); return storage; } @@ -30,6 +34,14 @@ export default class DataStorage { return this.storage.environment[key]; } + public getAll(): Record { + return { ...this.storage.environment }; + } + + public isInjected(): boolean { + return this.injected; + } + public async setEnv(key: string, value: string): Promise { this.storage.environment[key] = value; logger.debug(`Environment variable set: ${key}`); @@ -37,4 +49,21 @@ export default class DataStorage { await this.persistence.saveAll(this.storage.environment); } } + + /** + * Atomically applies a whole environment map: updates the in-memory store, + * persists once, and only then marks the storage as injected. Marking + * `injected` after the single persist call ensures a concurrent consumer + * never observes `injected === true` with a partially written map. + */ + public async setAll(env: Record): Promise { + for (const [key, value] of Object.entries(env)) { + this.storage.environment[key] = value; + } + logger.debug(`Environment variables set: ${Object.keys(env).length}`); + if (this.persistence) { + await this.persistence.saveAll(this.storage.environment); + } + this.injected = true; + } } diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 8bcfbcd..a7b4d4f 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -1,15 +1,72 @@ import * as assert from "assert"; -// You can import and use all API from the 'vscode' module -// as well as import your extension to test it import * as vscode from "vscode"; -// import * as myExtension from '../../extension'; +import DataService from "../service/data"; +import DataStorage from "../service/storage"; +import SecretStoragePersistence from "../service/persistence"; -suite("Extension Test Suite", () => { - vscode.window.showInformationMessage("Start all tests."); +// Minimal in-memory SecretStorage so the storage layer can be exercised without +// a real extension context. +class InMemorySecretStorage implements vscode.SecretStorage { + private data = new Map(); + private emitter = new vscode.EventEmitter(); + public readonly onDidChange = this.emitter.event; - test("Sample test", () => { - assert.strictEqual(-1, [1, 2, 3].indexOf(5)); - assert.strictEqual(-1, [1, 2, 3].indexOf(0)); + async get(key: string): Promise { + return this.data.get(key); + } + async store(key: string, value: string): Promise { + this.data.set(key, value); + this.emitter.fire({ key }); + } + async delete(key: string): Promise { + this.data.delete(key); + this.emitter.fire({ key }); + } + async keys(): Promise { + return [...this.data.keys()]; + } +} + +async function newService(): Promise { + const persistence = new SecretStoragePersistence(new InMemorySecretStorage()); + const storage = await DataStorage.withPersistence(persistence); + return new DataService(storage); +} + +suite("Data Bridge - arbitrary env injection", () => { + test("getEnvState reports not-injected before any injection", async () => { + const service = await newService(); + const state = service.getEnvState(); + assert.strictEqual(state.injected, false); + assert.deepStrictEqual(state.environment, {}); + }); + + test("inject stores arbitrary keys and getEnvState returns them all as ready", async () => { + const service = await newService(); + await service.inject({ + environment: { + THEIA: "true", + ARTEMIS_TOKEN: "token-123", + MY_VAR: "hello", + }, + }); + + const state = service.getEnvState(); + assert.strictEqual(state.injected, true); + assert.deepStrictEqual(state.environment, { + THEIA: "true", + ARTEMIS_TOKEN: "token-123", + MY_VAR: "hello", + }); + }); + + test("getEnv still returns only the requested keys", async () => { + const service = await newService(); + await service.inject({ environment: { A: "1", B: "2", C: "3" } }); + assert.deepStrictEqual(service.getEnvVars(["A", "C", "MISSING"]), { + A: "1", + C: "3", + }); }); });