mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-27 08:49:47 +00:00
Refs #889 (étape 1 ; le déplacement d’un projet gardé vers un dossier choisi reste hors périmètre). ## Comportement - **Création** : bouton « Nouvelle instance temporaire » sur l’accueil et commande de palette. CodeNomad crée un dossier vide sous son dossier de configuration (`temporary-workspaces/<date>-<id>`) et l’ouvre comme un onglet normal, nommé « Temporaire · HH:MM », avec une icône de sablier, hors projets récents. - **Fermeture** (X, Ctrl+W) : dialogue **Garder / Jeter / Annuler**, puisqu’un onglet temporaire fermé ne pourrait pas être rouvert. - **Garder** : le dossier et ses conversations deviennent un projet récent nommé, sur place. - **Jeter** : conversations et dossier supprimés. - Le clic droit sur l’onglet propose aussi « Garder comme projet ». - Au redémarrage, l’onglet revient toujours marqué temporaire. ## Mise en œuvre - Dossier sans Git : OpenCode le range dans le projet « global », mais CodeNomad filtre déjà les conversations sur le dossier exact, donc rien ne se mélange. - Le statut temporaire est l’appartenance à un registre (`temporary-workspaces.json`), pas l’emplacement ; les entrées dont le dossier n’existe plus sont oubliées. - Garde-fous de **Jeter** : - seulement les enfants directs enregistrés de la racine temporaire ; - refus si une conversation tourne ou si le dossier est ouvert dans un autre onglet ; - exécution sous le fence de suppression des worktrees ; - conversations supprimées enfants d’abord ; - workspace fermé avant l’effacement du dossier, avec réessais pour Windows ; - le dossier reste enregistré si l’effacement échoue. - Le store d’alertes gagne un dialogue de choix générique : Annuler plus des choix ordonnés, le dernier ayant le focus, et toute fermeture valant annulation. ## Validation - Serveur : `temporary-workspaces.test.ts` (4/4) ; 438/439 tests serveur `workspaces` + `server`. Le seul échec est le test de charge de `pending-requests`, qui passe seul et est sans lien. - UI : `temporary-instances.test.ts` (4/4) ; `tab-chrome.test.ts` (6/6, Electron ignoré), avec marqueur d’onglet, entrée de menu et dialogue de choix sur les vrais composants ; typecheck et build. - Natif isolé : `node scripts/test-temporary-workspaces-native.mjs <CLI>` contre OpenCode 2.0.26 sous Windows. - **Jeter** supprime conversations parente et enfant, fichiers et dossier ; une conversation d’un autre dossier survit. - **Garder** conserve le dossier et sa conversation. - Le PID du daemon isolé reste inchangé. - Le typecheck serveur n’a que les erreurs préexistantes liées à `@codenomad/remote-tunnel`, non construit localement. ## Fichiers volumineux touchés `App.tsx` (~990 lignes), `stores/instances.ts` (~2 340), `workspaces/manager.ts` (~1 280), `folder-selection-view.tsx` (~1 070), `stores/preferences.tsx` (~1 480).
99 lines
6.1 KiB
JavaScript
99 lines
6.1 KiB
JavaScript
// Explicit isolated CLI and private database: never the shared service.
|
|
// Usage: node scripts/test-temporary-workspaces-native.mjs <absolute OpenCode CLI executable>
|
|
import assert from "node:assert/strict"
|
|
import { spawn } from "node:child_process"
|
|
import { randomUUID } from "node:crypto"
|
|
import { mkdtemp, mkdir, stat, writeFile } from "node:fs/promises"
|
|
import os from "node:os"
|
|
import path from "node:path"
|
|
import { setTimeout as delay } from "node:timers/promises"
|
|
import pino from "pino"
|
|
import { OpenCode, isSessionNotFoundError } from "@opencode/client"
|
|
import { tsImport } from "tsx/esm/api"
|
|
import { stopFixtureChild } from "./native-fixture-guards.mjs"
|
|
|
|
const cli = process.argv[2]
|
|
assert(cli && path.isAbsolute(cli), "Pass an absolute isolated CLI executable path")
|
|
const parent = path.join(os.tmpdir(), "opencode")
|
|
await mkdir(parent, { recursive: true })
|
|
const root = await mkdtemp(path.join(parent, "codenomad-temporary-native-"))
|
|
const other = path.join(root, "other-project")
|
|
await mkdir(other)
|
|
const original = { ...process.env }
|
|
const env = Object.fromEntries(Object.entries(original).filter(([key]) => !/^(OPENCODE_|XDG_|CODENOMAD_)/i.test(key)))
|
|
for (const key of ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key)
|
|
const password = randomUUID()
|
|
Object.assign(env, { HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root,
|
|
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_DB: path.join(root, "fixture.db"),
|
|
OPENCODE_CONFIG_PROJECT_DISABLE: "1", OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
|
|
OPENCODE_SERVER_PASSWORD: password })
|
|
await mkdir(env.OPENCODE_CONFIG_DIR)
|
|
let output = "", spawnError, manager
|
|
const child = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { cwd: root, env, windowsHide: true })
|
|
const stopped = new Promise(resolve => child.once("close", resolve))
|
|
child.on("error", error => { spawnError = error })
|
|
child.stdout.on("data", data => { output += data })
|
|
child.stderr.on("data", data => { output += data })
|
|
try {
|
|
const deadline = Date.now() + 30_000
|
|
while (!/http:\/\/127\.0\.0\.1:\d+/.test(output)) {
|
|
if (spawnError) throw spawnError
|
|
if (child.exitCode !== null || Date.now() > deadline) throw new Error(output)
|
|
await delay(25)
|
|
}
|
|
const url = output.match(/http:\/\/127\.0\.0\.1:\d+/)[0]
|
|
const headers = { authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` }
|
|
const endpoint = { url, auth: { type: "basic", username: "opencode", password } }
|
|
const native = OpenCode.make({ baseUrl: url, headers })
|
|
const info = await native.server.info()
|
|
const { rememberRuntime } = await tsImport("../packages/server/src/opencode/compatibility/runtime.ts", import.meta.url)
|
|
rememberRuntime(endpoint, { version: info.version, pid: info.pid, discovery: "info" })
|
|
const { WorkspaceManager } = await tsImport("../packages/server/src/workspaces/manager.ts", import.meta.url)
|
|
const { EventBus } = await tsImport("../packages/server/src/events/bus.ts", import.meta.url)
|
|
const { WorktreeDeletionFence } = await tsImport("../packages/server/src/workspaces/worktree-session-evacuation.ts", import.meta.url)
|
|
const { TemporaryFolderRegistry, TemporaryWorkspaces } = await tsImport("../packages/server/src/workspaces/temporary-workspaces.ts", import.meta.url)
|
|
const registry = await TemporaryFolderRegistry.open(path.join(root, "temporary-workspaces"), path.join(root, "temporary-workspaces.json"))
|
|
manager = new WorkspaceManager({ rootDir: root, temporaryFolders: registry, settings: { getOwner: () => ({ environmentVariables: {} }) },
|
|
binaryResolver: { resolveDefault: () => ({ path: cli, label: "isolated" }) }, eventBus: new EventBus(), logger: pino({ level: "silent" }),
|
|
hostServiceLifecycleFactory: () => ({ discover: async () => endpoint, ensure: async () => endpoint }),
|
|
})
|
|
const temporary = new TemporaryWorkspaces({ registry, workspaceManager: manager, deletionFence: new WorktreeDeletionFence() })
|
|
|
|
// Discard: conversations (with a child), files and the folder disappear; others stay.
|
|
const discarded = await temporary.createFolder()
|
|
await writeFile(path.join(discarded, "script.py"), "print('scratch')\n")
|
|
const { workspace } = await manager.create(discarded)
|
|
assert.equal(workspace.temporary, true)
|
|
assert.equal((await manager.getWorktrees(workspace.id, "fresh")).isGitRepo, false)
|
|
const location = manager.getServiceLocation(workspace.id)
|
|
const root1 = await native.session.create({ location })
|
|
const child1 = await native.session.create({ location, parentID: root1.id })
|
|
const foreign = await native.session.create({ location: { directory: other } })
|
|
await temporary.discard(workspace.id)
|
|
await assert.rejects(stat(discarded), { code: "ENOENT" })
|
|
for (const id of [root1.id, child1.id]) {
|
|
await assert.rejects(native.session.get({ sessionID: id }), (error) => isSessionNotFoundError(error))
|
|
}
|
|
assert.equal((await native.session.get({ sessionID: foreign.id })).id, foreign.id)
|
|
assert.equal(registry.has(discarded), false)
|
|
assert.equal(manager.get(workspace.id), undefined)
|
|
|
|
// Keep: the folder and its conversation stay; the workspace is no longer temporary.
|
|
const kept = await temporary.createFolder()
|
|
const created = await manager.create(kept)
|
|
const keptSession = await native.session.create({ location: manager.getServiceLocation(created.workspace.id) })
|
|
await temporary.keep(created.workspace.id)
|
|
assert.equal(manager.get(created.workspace.id).temporary, undefined)
|
|
assert.equal(registry.has(kept), false)
|
|
assert.ok((await stat(kept)).isDirectory())
|
|
assert.equal((await native.session.get({ sessionID: keptSession.id })).id, keptSession.id)
|
|
await assert.rejects(temporary.discard(created.workspace.id), /not temporary/)
|
|
assert.equal((await native.server.info()).pid, info.pid)
|
|
console.log(`PASS OpenCode ${info.version}: discard removed parent/child conversations, files and folder while a foreign conversation survived; keep retained folder and conversation`)
|
|
} finally {
|
|
await manager?.shutdown()
|
|
try { await stopFixtureChild(child, stopped) } finally {
|
|
await writeFile(path.join(root, "daemon.log"), output)
|
|
console.log(`Isolated fixture: ${root}`)
|
|
}
|
|
}
|