deepdebug-local-agent 1.0.23 → 1.1.0

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 (168) hide show
  1. package/.claude/context.md +76 -0
  2. package/.claude/skills/README.md +25 -0
  3. package/.claude/skills/tdd-debugging/SKILL.md +53 -0
  4. package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
  5. package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
  6. package/.claude/standards/README.md +51 -0
  7. package/.claude/standards/clean-code.md +75 -0
  8. package/.claude/standards/code-review-checklist.md +61 -0
  9. package/.claude/standards/layered-architecture.md +163 -0
  10. package/.claude/standards/node.md +45 -0
  11. package/.claude/standards/security-owasp.md +114 -0
  12. package/.claude/standards/simplicity.md +42 -0
  13. package/.claude/standards/solid.md +64 -0
  14. package/.claude/standards/testing.md +59 -0
  15. package/.cursor/rules/standards.mdc +17 -0
  16. package/.github/workflows/build-byoc-image.yml +315 -0
  17. package/.github/workflows/deploy-gcp.yml +102 -0
  18. package/.github/workflows/npm-publish.yml +69 -8
  19. package/AGENTS.md +21 -0
  20. package/CHANGELOG.md +69 -0
  21. package/CLAUDE.md +13 -0
  22. package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
  23. package/Dockerfile +4 -0
  24. package/Dockerfile.full +55 -3
  25. package/bin/install.js +16 -5
  26. package/cloudbuild-full.yaml +57 -0
  27. package/docker-compose.yml +5 -3
  28. package/docs/OBSERVABILITY.md +164 -0
  29. package/docs/byoc-agent-image.md +70 -0
  30. package/docs/graphify-packaging-plan.md +133 -0
  31. package/docs/graphify-validation-run-2026-10-28.md +243 -0
  32. package/docs/releasing.md +43 -0
  33. package/docs/signed-commands.md +163 -0
  34. package/env.example +30 -0
  35. package/helm/Chart.yaml +2 -2
  36. package/helm/templates/deployment.yaml +13 -3
  37. package/helm/templates/networkpolicy.yaml +22 -0
  38. package/helm/templates/secret.yaml +1 -0
  39. package/helm/values.yaml +13 -14
  40. package/mcp-server.js +4 -1
  41. package/package.json +9 -4
  42. package/scripts/upgrade-observability-push.sh +227 -0
  43. package/src/ai-engine.js +22 -22
  44. package/src/config/trusted-signing-keys.json +3 -0
  45. package/src/detectors/build-tool-detector.js +50 -0
  46. package/src/detectors/multi-project-detector.js +22 -22
  47. package/src/detectors/service-detector.js +24 -24
  48. package/src/exec-utils.js +26 -4
  49. package/src/git/base-git-provider.js +117 -117
  50. package/src/git/bitbucket-provider.js +2 -2
  51. package/src/git/git-provider-registry.js +18 -18
  52. package/src/git/github-provider.js +13 -13
  53. package/src/git/multi-repo-pr.js +161 -0
  54. package/src/mcp-http-server.js +32 -16
  55. package/src/observability/bundle-files.js +418 -0
  56. package/src/observability/detect.js +151 -0
  57. package/src/observability/provision.js +212 -0
  58. package/src/observability/render-cli.js +59 -0
  59. package/src/observability/routes.js +88 -0
  60. package/src/patch/patch-engine.js +28 -28
  61. package/src/patch-manager.js +85 -85
  62. package/src/patch_bkp.js +14 -14
  63. package/src/ports.js +4 -4
  64. package/src/routes/workspace.route.js +47 -16
  65. package/src/runtimes/base-runtime.js +75 -75
  66. package/src/runtimes/java/gradle-runtime.js +17 -17
  67. package/src/runtimes/java/java-integrations.js +25 -25
  68. package/src/runtimes/java/maven-runtime.js +34 -34
  69. package/src/runtimes/node/node-integrations.js +1 -1
  70. package/src/runtimes/node/npm-runtime.js +14 -14
  71. package/src/runtimes/node/yarn-runtime.js +4 -4
  72. package/src/runtimes/runtime-registry.js +25 -25
  73. package/src/server-local.js +4 -4
  74. package/src/server.js +1366 -333
  75. package/src/siblings/burst-suppressor.js +89 -0
  76. package/src/siblings/capabilities.js +273 -0
  77. package/src/siblings/env-compat.cjs +58 -0
  78. package/src/siblings/incident-forwarder.js +320 -0
  79. package/src/siblings/logger.js +15 -0
  80. package/src/siblings/metrics.js +94 -0
  81. package/src/utils/agent-auth.js +184 -0
  82. package/src/utils/approval-token.js +56 -0
  83. package/src/utils/bind-host.js +29 -0
  84. package/src/utils/branch-refspec.js +98 -0
  85. package/src/utils/child-env.js +34 -0
  86. package/src/utils/environment-diagnostics.js +55 -55
  87. package/src/utils/exec-policy.js +962 -0
  88. package/src/utils/exec-utils.js +15 -15
  89. package/src/utils/file-count.js +46 -0
  90. package/src/utils/fs-utils.js +10 -10
  91. package/src/utils/multi-repo-open.js +132 -0
  92. package/src/utils/open-gate.js +183 -0
  93. package/src/utils/ranged-read.js +65 -0
  94. package/src/utils/request-guards.js +77 -0
  95. package/src/utils/request-signing.js +342 -0
  96. package/src/utils/root-recovery.js +244 -0
  97. package/src/utils/safe-exec-args.js +58 -0
  98. package/src/utils/sandbox.js +182 -0
  99. package/src/utils/search-outcome.js +56 -0
  100. package/src/utils/workspace-root.js +80 -0
  101. package/src/validators/syntax-validator.js +149 -0
  102. package/src/workspace/detect-port.js +16 -16
  103. package/src/workspace/file-reader.js +1 -1
  104. package/src/workspace/process-manager.js +46 -46
  105. package/src/workspace-manager.js +86 -24
  106. package/test/adapters/registry.test.js +9 -2
  107. package/test/agent-auth.test.js +194 -0
  108. package/test/bind-host.test.js +26 -0
  109. package/test/branch-refspec.test.js +224 -0
  110. package/test/burst-suppressor.test.js +60 -0
  111. package/test/capabilities-access.test.js +47 -0
  112. package/test/capabilities.test.js +87 -0
  113. package/test/child-env.test.js +23 -0
  114. package/test/env-compat.test.js +71 -0
  115. package/test/exec-policy.test.js +463 -0
  116. package/test/file-count.test.js +64 -0
  117. package/test/incident-forwarder.test.js +231 -0
  118. package/test/multi-repo-open.test.js +180 -0
  119. package/test/multi-repo-pr.test.js +129 -0
  120. package/test/observability-detect.test.js +199 -0
  121. package/test/open-gate.test.js +341 -0
  122. package/test/ranged-read.test.js +60 -0
  123. package/test/request-guards.test.js +91 -0
  124. package/test/request-signing.test.js +284 -0
  125. package/test/root-recovery-sidecar.test.js +171 -0
  126. package/test/root-recovery.test.js +274 -0
  127. package/test/safe-exec-args.test.js +58 -0
  128. package/test/sandbox.test.js +98 -0
  129. package/test/search-outcome.test.js +50 -0
  130. package/test/workspace-root.test.js +64 -0
  131. package/tools/graphify-validation/README.md +92 -0
  132. package/tools/graphify-validation/config/questions.json +31 -0
  133. package/tools/graphify-validation/config/repos.json +9 -0
  134. package/tools/graphify-validation/config/symbols.json +6 -0
  135. package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
  136. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
  137. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
  138. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
  139. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
  140. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
  141. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
  142. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
  143. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
  144. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
  145. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
  146. package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
  147. package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
  148. package/tools/graphify-validation/lib/graph_metrics.py +96 -0
  149. package/tools/graphify-validation/lib/gv_common.py +130 -0
  150. package/tools/graphify-validation/lib/licenses.py +47 -0
  151. package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
  152. package/tools/graphify-validation/lib/recall.py +115 -0
  153. package/tools/graphify-validation/lib/report.py +226 -0
  154. package/tools/graphify-validation/lib/run_all.py +237 -0
  155. package/tools/graphify-validation/lib/run_graphify.py +144 -0
  156. package/tools/graphify-validation/lib/rusage_helper.py +17 -0
  157. package/tools/graphify-validation/lib/sample_edges.py +246 -0
  158. package/tools/graphify-validation/lib/spring_fixture.py +81 -0
  159. package/tools/graphify-validation/requirements-py311.lock +508 -0
  160. package/tools/graphify-validation/requirements-tools.in +1 -0
  161. package/tools/graphify-validation/requirements-tools.lock +371 -0
  162. package/tools/graphify-validation/requirements.in +1 -0
  163. package/tools/graphify-validation/requirements.lock +502 -0
  164. package/tools/graphify-validation/run.sh +43 -0
  165. package/tools/graphify-validation/tests/test_harness.py +151 -0
  166. package/cloudbuild-agent-qa.yaml +0 -43
  167. package/cloudbuild-agent-staging.yaml +0 -40
  168. package/cloudbuild.yaml.deprecated +0 -42
@@ -7,13 +7,13 @@ import path from "path";
7
7
  /**
8
8
  * ProcessManager
9
9
  *
10
- * Gerencia ciclo de vida de processos (start, stop, restart)
11
- * Monitora CPU, Memory, Uptime
12
- * Captura logs em tempo real
10
+ * Manages the process lifecycle (start, stop, restart)
11
+ * Monitors CPU, Memory, Uptime
12
+ * Captures logs in real time
13
13
  *
14
- * UNIFIED VERSION: Aceita AMBOS os formatos:
15
- * 1. command/args direto (novo)
16
- * 2. language/framework/buildTool (legado)
14
+ * UNIFIED VERSION: Accepts BOTH formats:
15
+ * 1. command/args directly (new)
16
+ * 2. language/framework/buildTool (legacy)
17
17
  */
18
18
  export class ProcessManager extends EventEmitter {
19
19
  constructor() {
@@ -22,7 +22,7 @@ export class ProcessManager extends EventEmitter {
22
22
  }
23
23
 
24
24
  /**
25
- * Verifica se um serviço está rodando
25
+ * Checks whether a service is running
26
26
  */
27
27
  isRunning(serviceId) {
28
28
  const processData = this.processes.get(serviceId);
@@ -30,11 +30,11 @@ export class ProcessManager extends EventEmitter {
30
30
  }
31
31
 
32
32
  /**
33
- * Inicia um serviço
33
+ * Starts a service
34
34
  *
35
- * ACEITA DOIS FORMATOS:
36
- * 1. { command, args, cwd, port, env } - Novo formato direto
37
- * 2. { language, framework, buildTool, cwd, port, profile } - Formato legado
35
+ * ACCEPTS TWO FORMATS:
36
+ * 1. { command, args, cwd, port, env } - New direct format
37
+ * 2. { language, framework, buildTool, cwd, port, profile } - Legacy format
38
38
  */
39
39
  async start(serviceId, config) {
40
40
  const {
@@ -45,7 +45,7 @@ export class ProcessManager extends EventEmitter {
45
45
  language,
46
46
  framework,
47
47
  buildTool,
48
- // Comum
48
+ // Common
49
49
  cwd,
50
50
  port,
51
51
  profile,
@@ -53,23 +53,23 @@ export class ProcessManager extends EventEmitter {
53
53
  envVars = {}
54
54
  } = config;
55
55
 
56
- // Se já estiver rodando, parar primeiro
56
+ // If it is already running, stop it first
57
57
  if (this.processes.has(serviceId)) {
58
58
  console.log(`⚠️ Service ${serviceId} already running, stopping first...`);
59
59
  await this.stop(serviceId);
60
60
  await new Promise(resolve => setTimeout(resolve, 1000));
61
61
  }
62
62
 
63
- // Determinar comando a usar
63
+ // Determine which command to use
64
64
  let cmd, cmdArgs;
65
65
 
66
66
  if (command && args) {
67
- // NOVO FORMATO: command/args passados diretamente
67
+ // NEW FORMAT: command/args passed directly
68
68
  cmd = command;
69
69
  cmdArgs = args;
70
70
  console.log(`🚀 Starting ${serviceId} (direct): ${cmd} ${cmdArgs.join(' ')}`);
71
71
  } else if (language) {
72
- // FORMATO LEGADO: derivar de language/framework/buildTool
72
+ // LEGACY FORMAT: derive from language/framework/buildTool
73
73
  const startCommand = await this.getStartCommand(language, framework, buildTool, cwd, profile || 'dev');
74
74
  cmd = startCommand.cmd;
75
75
  cmdArgs = startCommand.args;
@@ -167,7 +167,7 @@ export class ProcessManager extends EventEmitter {
167
167
 
168
168
  this.emit("log", { serviceId, message: log, type: "stdout" });
169
169
 
170
- // Detectar quando serviço está pronto
170
+ // Detect when the service is ready
171
171
  if (this.isServiceReady(log, language || 'java')) {
172
172
  if (processData.status !== "running") {
173
173
  processData.status = "running";
@@ -178,7 +178,7 @@ export class ProcessManager extends EventEmitter {
178
178
  }
179
179
  });
180
180
 
181
- // Capturar stderr (Spring Boot também loga aqui)
181
+ // Capture stderr (Spring Boot also logs here)
182
182
  child.stderr.on("data", (data) => {
183
183
  const text = data.toString();
184
184
  const lines = text.split('\n').filter(l => l.trim());
@@ -195,7 +195,7 @@ export class ProcessManager extends EventEmitter {
195
195
 
196
196
  this.emit("log", { serviceId, message: log, type: "stderr" });
197
197
 
198
- // Spring Boot pode logar "Started" no stderr também
198
+ // Spring Boot may log "Started" on stderr too
199
199
  if (this.isServiceReady(log, language || 'java')) {
200
200
  if (processData.status !== "running") {
201
201
  processData.status = "running";
@@ -206,21 +206,21 @@ export class ProcessManager extends EventEmitter {
206
206
  }
207
207
  });
208
208
 
209
- // Capturar erro de inicialização
209
+ // Capture startup errors
210
210
  child.on("error", (err) => {
211
211
  console.error(`❌ Failed to start ${serviceId}:`, err.message);
212
212
  processData.status = "failed";
213
213
  this.emit("error", { serviceId, error: err.message });
214
214
  });
215
215
 
216
- // Capturar encerramento
216
+ // Capture termination
217
217
  child.on("close", (code, signal) => {
218
218
  console.log(`⏹️ Service ${serviceId} exited with code ${code}, signal ${signal}`);
219
219
  this.processes.delete(serviceId);
220
220
  this.emit("stopped", { serviceId, code, signal });
221
221
  });
222
222
 
223
- // Iniciar monitoramento de recursos
223
+ // Start resource monitoring
224
224
  this.startResourceMonitoring(serviceId);
225
225
 
226
226
  return {
@@ -234,7 +234,7 @@ export class ProcessManager extends EventEmitter {
234
234
  }
235
235
 
236
236
  /**
237
- * Para um serviço
237
+ * Stops a service
238
238
  */
239
239
  async stop(serviceId) {
240
240
  const processData = this.processes.get(serviceId);
@@ -272,14 +272,14 @@ export class ProcessManager extends EventEmitter {
272
272
  child.once('exit', onExit);
273
273
  child.once('close', onExit);
274
274
 
275
- // Tentar graceful shutdown
275
+ // Try a graceful shutdown
276
276
  try {
277
277
  child.kill('SIGTERM');
278
278
  } catch (err) {
279
279
  console.log(`⚠️ SIGTERM failed for ${serviceId}: ${err.message}`);
280
280
  }
281
281
 
282
- // Force kill após 5 segundos
282
+ // Force kill after 5 seconds
283
283
  setTimeout(() => {
284
284
  if (!resolved && this.processes.has(serviceId)) {
285
285
  console.log(`🔨 Force killing ${serviceId} (SIGKILL)`);
@@ -291,7 +291,7 @@ export class ProcessManager extends EventEmitter {
291
291
  }
292
292
  }, 5000);
293
293
 
294
- // Timeout geral após 10 segundos
294
+ // Overall timeout after 10 seconds
295
295
  setTimeout(() => {
296
296
  if (!resolved) {
297
297
  resolved = true;
@@ -309,7 +309,7 @@ export class ProcessManager extends EventEmitter {
309
309
  }
310
310
 
311
311
  /**
312
- * Retorna status de um serviço
312
+ * Returns the status of a service
313
313
  */
314
314
  getStatus(serviceId) {
315
315
  const processData = this.processes.get(serviceId);
@@ -333,7 +333,7 @@ export class ProcessManager extends EventEmitter {
333
333
  }
334
334
 
335
335
  /**
336
- * Retorna logs de um serviço
336
+ * Returns the logs of a service
337
337
  */
338
338
  getLogs(serviceId, limit = 100) {
339
339
  const processData = this.processes.get(serviceId);
@@ -344,7 +344,7 @@ export class ProcessManager extends EventEmitter {
344
344
  }
345
345
 
346
346
  /**
347
- * Lista todos os serviços
347
+ * Lists all services
348
348
  */
349
349
  listServices() {
350
350
  const services = [];
@@ -355,20 +355,20 @@ export class ProcessManager extends EventEmitter {
355
355
  }
356
356
 
357
357
  /**
358
- * Alias para compatibilidade
358
+ * Alias for compatibility
359
359
  */
360
360
  listAll() {
361
361
  return this.listServices();
362
362
  }
363
363
 
364
364
  /**
365
- * Determina comando de start baseado no framework (FORMATO LEGADO)
366
- * Usado quando server.js não passa command/args diretamente
365
+ * Determines the start command based on the framework (LEGACY FORMAT)
366
+ * Used when server.js does not pass command/args directly
367
367
  */
368
368
  async getStartCommand(language, framework, buildTool, cwd, profile = 'dev') {
369
369
  console.log(`🔧 getStartCommand: language=${language}, framework=${framework}, buildTool=${buildTool}`);
370
370
 
371
- // Java (qualquer framework ou sem framework)
371
+ // Java (any framework or no framework)
372
372
  if (language === "java") {
373
373
  return await this.getJavaCommand(buildTool, cwd, profile);
374
374
  }
@@ -393,14 +393,14 @@ export class ProcessManager extends EventEmitter {
393
393
  return await this.getGoCommand(cwd);
394
394
  }
395
395
 
396
- // Fallback genérico - tenta npm start
396
+ // Generic fallback - try npm start
397
397
  console.warn(`⚠️ Unknown language: ${language}, trying npm start`);
398
398
  return { cmd: "npm", args: ["start"] };
399
399
  }
400
400
 
401
401
  /**
402
- * Comando Java/Spring Boot
403
- */
402
+ * Java/Spring Boot command
403
+ async getJavaCommand(buildTool, cwd, profile = null) {
404
404
  async getJavaCommand(buildTool, cwd, profile = null) {
405
405
  const targetDir = path.join(cwd, "target");
406
406
  const buildDir = path.join(cwd, "build", "libs");
@@ -433,7 +433,7 @@ export class ProcessManager extends EventEmitter {
433
433
  return javaArgs;
434
434
  };
435
435
 
436
- // Maven: Procurar JAR em target/
436
+ // Maven: Look for a JAR in target/
437
437
  if (buildTool === "maven") {
438
438
  console.log(`📁 Target directory: ${targetDir} (exists: ${fs.existsSync(targetDir)})`);
439
439
 
@@ -470,11 +470,11 @@ export class ProcessManager extends EventEmitter {
470
470
  }
471
471
  }
472
472
 
473
- // Sem JAR - pedir para compilar
473
+ // No JAR - ask to compile
474
474
  throw new Error(`No JAR found. Please compile first: mvn clean install -DskipTests`);
475
475
  }
476
476
 
477
- // Gradle: Procurar JAR em build/libs/
477
+ // Gradle: Look for a JAR in build/libs/
478
478
  if (buildTool === "gradle") {
479
479
  console.log(`📁 Build directory: ${buildDir} (exists: ${fs.existsSync(buildDir)})`);
480
480
 
@@ -509,8 +509,8 @@ export class ProcessManager extends EventEmitter {
509
509
  }
510
510
 
511
511
  /**
512
- * Comando Node.js
513
- */
512
+ * Node.js command
513
+ async getNodeCommand(framework, cwd) {
514
514
  async getNodeCommand(framework, cwd) {
515
515
  const nodeModules = path.join(cwd, "node_modules");
516
516
  const hasNodeModules = fs.existsSync(nodeModules);
@@ -530,7 +530,7 @@ export class ProcessManager extends EventEmitter {
530
530
  }
531
531
 
532
532
  /**
533
- * Comando Python
533
+ * Python command
534
534
  */
535
535
  async getPythonCommand(framework, cwd) {
536
536
  if (framework === "django") {
@@ -546,21 +546,21 @@ export class ProcessManager extends EventEmitter {
546
546
  }
547
547
 
548
548
  /**
549
- * Comando .NET
549
+ * .NET command
550
550
  */
551
551
  async getDotNetCommand(cwd) {
552
552
  return { cmd: "dotnet", args: ["run"] };
553
553
  }
554
554
 
555
555
  /**
556
- * Comando Go
556
+ * Go command
557
557
  */
558
558
  async getGoCommand(cwd) {
559
559
  return { cmd: "go", args: ["run", "."] };
560
560
  }
561
561
 
562
562
  /**
563
- * Detecta quando serviço está pronto
563
+ * Detects when the service is ready
564
564
  */
565
565
  isServiceReady(log, language) {
566
566
  if (!log) return false;
@@ -591,7 +591,7 @@ export class ProcessManager extends EventEmitter {
591
591
  }
592
592
 
593
593
  /**
594
- * Monitora recursos (CPU e Memory)
594
+ * Monitors resources (CPU and Memory)
595
595
  */
596
596
  startResourceMonitoring(serviceId) {
597
597
  const interval = setInterval(async () => {
@@ -1,30 +1,36 @@
1
1
  import path from "path";
2
+ import fs from "fs";
2
3
  import { exists, listRecursive, readFile, stat } from "./fs-utils.js";
4
+ import { WORKSPACE_SIDECAR, readWorkspaceSidecar } from "./utils/open-gate.js";
3
5
  import { detectProject } from "./detectors.js";
4
6
  import { detectPort } from "./ports.js";
5
7
 
6
8
  /**
7
- * WorkspaceManager — Gere múltiplos workspaces simultâneos.
9
+ * WorkspaceManager — Manages multiple simultaneous workspaces.
8
10
  *
9
- * Substitui a variável global WORKSPACE_ROOT.
10
- * Cada workspace é identificado por um ID e tem o seu próprio root path.
11
+ * Replaces the global WORKSPACE_ROOT variable.
12
+ * Each workspace is identified by an ID and has its own root path.
11
13
  */
12
14
  export class WorkspaceManager {
13
15
  constructor() {
14
16
  /** @type {Map<string, WorkspaceState>} */
15
17
  this.workspaces = new Map();
16
18
 
17
- /** Backward compat: workspace "default" para REST legacy */
19
+ /** Backward compat: "default" workspace for legacy REST */
18
20
  this.defaultWorkspaceId = null;
19
21
  }
20
22
 
21
23
  /**
22
- * Abre um workspace. Se já existe com mesmo ID, actualiza o root.
23
- * @param {string} workspaceId - Identificador único
24
- * @param {string} rootPath - Caminho absoluto
24
+ * Opens a workspace. If one already exists with the same ID, updates the root.
25
+ * @param {string} workspaceId - Unique identifier
26
+ * @param {string} rootPath - Absolute path
27
+ * @param {{tenantId?: string|null}} [meta] - Optional. tenantId = tenant this
28
+ * clone was hydrated for (TENANT GATE for ROOT-RECOVERY). When absent,
29
+ * a previously registered tenantId for the same workspaceId is kept;
30
+ * otherwise stays null (= unknown, recovery fails closed).
25
31
  * @returns {Promise<WorkspaceState>}
26
32
  */
27
- async open(workspaceId, rootPath) {
33
+ async open(workspaceId, rootPath, meta = {}) {
28
34
  const abs = path.resolve(rootPath);
29
35
  if (!(await exists(abs))) {
30
36
  throw new Error(`Workspace path not found: ${abs}`);
@@ -33,9 +39,24 @@ export class WorkspaceManager {
33
39
  const projectInfo = await detectProject(abs);
34
40
  const port = await detectPort(abs);
35
41
 
42
+ // A previous registration under the same id only lends its tenant when
43
+ // it is for the SAME directory: ids like "default" are reused by
44
+ // different clones, and carrying tenant A over to tenant B's dir would
45
+ // mislabel it (registry AND sidecar, which ROOT-RECOVERY trusts).
46
+ const prevAny = this.workspaces.get(workspaceId);
47
+ const prev = prevAny && prevAny.root === abs ? prevAny : null;
48
+ // SIDECAR (QA 2026-09-22): after a restart the Map is empty, but the
49
+ // dir may still carry its registration on disk - keep that tenant
50
+ // instead of downgrading it to "unknown" (fail-closed elsewhere).
51
+ const sidecar = (!(meta && meta.tenantId) && !(prev && prev.tenantId))
52
+ ? readWorkspaceSidecar(abs) : null;
53
+ const tenantId = (meta && meta.tenantId) || (prev && prev.tenantId)
54
+ || (sidecar && sidecar.id === workspaceId ? sidecar.tenantId : null) || null;
55
+
36
56
  const state = {
37
57
  id: workspaceId,
38
58
  root: abs,
59
+ tenantId,
39
60
  projectInfo,
40
61
  port,
41
62
  openedAt: new Date().toISOString(),
@@ -44,8 +65,9 @@ export class WorkspaceManager {
44
65
  };
45
66
 
46
67
  this.workspaces.set(workspaceId, state);
68
+ this.writeSidecar(state);
47
69
 
48
- // Se é o primeiro workspace, torna-se o default
70
+ // If it is the first workspace, it becomes the default
49
71
  if (!this.defaultWorkspaceId) {
50
72
  this.defaultWorkspaceId = workspaceId;
51
73
  }
@@ -55,16 +77,55 @@ export class WorkspaceManager {
55
77
  }
56
78
 
57
79
  /**
58
- * Fecha um workspace.
80
+ * Persist the registration next to the clone so the OPEN GATE can still
81
+ * recognise this dir after the in-memory Map is gone (cold start / new
82
+ * revision / second instance). Best-effort: a write failure is logged and
83
+ * never breaks open(). NEVER stores tokens.
84
+ */
85
+ writeSidecar(state) {
86
+ const file = path.join(state.root, WORKSPACE_SIDECAR);
87
+ try {
88
+ // Atomic (temp + rename) and owner-only: the sidecar's tenantId is
89
+ // read by ROOT-RECOVERY, so it must never be observed half-written.
90
+ const tmp = `${file}.${process.pid}.tmp`;
91
+ fs.writeFileSync(tmp, JSON.stringify({
92
+ id: state.id,
93
+ tenantId: state.tenantId || null,
94
+ root: state.root,
95
+ openedAt: state.openedAt
96
+ }, null, 2) + "\n", { mode: 0o600 });
97
+ fs.renameSync(tmp, file);
98
+ // Single-repo clones ARE git repos and the PR path runs `git add -A`:
99
+ // keep the sidecar out of git via .git/info/exclude (never touches
100
+ // the repo's own .gitignore). Multi-repo parents have no .git.
101
+ const infoDir = path.join(state.root, ".git", "info");
102
+ if (fs.existsSync(infoDir)) {
103
+ const excludeFile = path.join(infoDir, "exclude");
104
+ let current = "";
105
+ try { current = fs.readFileSync(excludeFile, "utf8"); } catch { current = ""; }
106
+ if (!current.split(/\r?\n/).includes(WORKSPACE_SIDECAR)) {
107
+ fs.appendFileSync(excludeFile, (current && !current.endsWith("\n") ? "\n" : "") + WORKSPACE_SIDECAR + "\n");
108
+ }
109
+ }
110
+ } catch (err) {
111
+ console.warn(`[workspace] sidecar write failed (non-fatal) at ${file}: ${err.message}`);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Closes a workspace.
59
117
  */
60
118
  close(workspaceId) {
61
119
  const ws = this.workspaces.get(workspaceId);
62
120
  if (!ws) return false;
63
121
 
64
122
  this.workspaces.delete(workspaceId);
123
+ // A closed workspace is no longer registered anywhere: drop the sidecar
124
+ // so the OPEN GATE treats the dir exactly as before (orphan rules).
125
+ try { fs.rmSync(path.join(ws.root, WORKSPACE_SIDECAR), { force: true }); } catch { /* best-effort */ }
65
126
 
66
127
  if (this.defaultWorkspaceId === workspaceId) {
67
- // Eleger novo default
128
+ // Elect a new default
68
129
  const first = this.workspaces.keys().next().value;
69
130
  this.defaultWorkspaceId = first || null;
70
131
  }
@@ -74,7 +135,7 @@ export class WorkspaceManager {
74
135
  }
75
136
 
76
137
  /**
77
- * Obtém estado de um workspace. Actualiza lastAccessedAt.
138
+ * Gets the state of a workspace. Updates lastAccessedAt.
78
139
  * @param {string} workspaceId
79
140
  * @returns {WorkspaceState|null}
80
141
  */
@@ -87,7 +148,7 @@ export class WorkspaceManager {
87
148
  }
88
149
 
89
150
  /**
90
- * Obtém o root path de um workspace. Throw se não existe.
151
+ * Gets the root path of a workspace. Throws if it does not exist.
91
152
  * @param {string} workspaceId
92
153
  * @returns {string}
93
154
  */
@@ -98,8 +159,8 @@ export class WorkspaceManager {
98
159
  }
99
160
 
100
161
  /**
101
- * Resolve workspace ID — se null, usa o default.
102
- * Backward compat para REST endpoints que não enviam workspaceId.
162
+ * Resolves a workspace ID — if null, uses the default.
163
+ * Backward compat for REST endpoints that do not send a workspaceId.
103
164
  */
104
165
  resolveId(workspaceId) {
105
166
  if (workspaceId) return workspaceId;
@@ -108,7 +169,7 @@ export class WorkspaceManager {
108
169
  }
109
170
 
110
171
  /**
111
- * Resolve root path — aceita workspaceId ou usa default.
172
+ * Resolves the root path — accepts a workspaceId or uses the default.
112
173
  */
113
174
  resolveRoot(workspaceId) {
114
175
  const id = this.resolveId(workspaceId);
@@ -116,29 +177,29 @@ export class WorkspaceManager {
116
177
  }
117
178
 
118
179
  /**
119
- * Lista todos os workspaces abertos.
180
+ * Lists all open workspaces.
120
181
  */
121
182
  list() {
122
183
  return Array.from(this.workspaces.values());
123
184
  }
124
185
 
125
186
  /**
126
- * Verifica se workspace está aberto.
187
+ * Checks whether a workspace is open.
127
188
  */
128
189
  isOpen(workspaceId) {
129
190
  return this.workspaces.has(workspaceId);
130
191
  }
131
192
 
132
193
  /**
133
- * Total de workspaces abertos.
194
+ * Total number of open workspaces.
134
195
  */
135
196
  get count() {
136
197
  return this.workspaces.size;
137
198
  }
138
199
 
139
200
  /**
140
- * Backward compat: obtém WORKSPACE_ROOT (default workspace).
141
- * Usado pelos endpoints REST legacy.
201
+ * Backward compat: gets WORKSPACE_ROOT (default workspace).
202
+ * Used by the legacy REST endpoints.
142
203
  */
143
204
  get defaultRoot() {
144
205
  if (!this.defaultWorkspaceId) return null;
@@ -147,12 +208,12 @@ export class WorkspaceManager {
147
208
  }
148
209
 
149
210
  /**
150
- * Backward compat: define WORKSPACE_ROOT (default workspace).
151
- * Chamado pelo POST /workspace/open legacy.
211
+ * Backward compat: sets WORKSPACE_ROOT (default workspace).
212
+ * Called by the legacy POST /workspace/open.
152
213
  */
153
214
  async setDefault(rootPath) {
154
215
  const id = `default-${Date.now()}`;
155
- // Se já tem default, reutiliza o ID
216
+ // If a default already exists, reuse its ID
156
217
  const existingId = this.defaultWorkspaceId || id;
157
218
  await this.open(existingId, rootPath);
158
219
  this.defaultWorkspaceId = existingId;
@@ -164,6 +225,7 @@ export class WorkspaceManager {
164
225
  * @typedef {Object} WorkspaceState
165
226
  * @property {string} id
166
227
  * @property {string} root
228
+ * @property {string|null} tenantId
167
229
  * @property {Object} projectInfo
168
230
  * @property {number|null} port
169
231
  * @property {string} openedAt
@@ -77,11 +77,18 @@ describe('adapter registry', () => {
77
77
  });
78
78
 
79
79
  test('stopAll respects timeout — slow stop does not block forever', async () => {
80
- const slow = { stop: () => new Promise(() => { /* never resolve */ }) };
80
+ // stop() resolves AFTER stopAll's timeout (via a real timer), proving stopAll
81
+ // returns early — then we drain the outstanding promise before the test
82
+ // settles. A never-resolving promise leaves "resolution still pending" at test
83
+ // end (node:test failure) which cancelled the sibling subtests.
84
+ let slowPromise;
85
+ const slow = { stop: () => (slowPromise = new Promise((resolve) => setTimeout(resolve, 400))) };
81
86
  const before = Date.now();
82
- await stopAll([slow], 200);
87
+ await stopAll([slow], 100);
83
88
  const elapsed = Date.now() - before;
84
89
  assert.ok(elapsed < 1000, `stopAll should return within timeout; took ${elapsed}ms`);
90
+ assert.ok(elapsed < 400, `stopAll should return on its timeout, not wait for slow stop; took ${elapsed}ms`);
91
+ await slowPromise; // drain: nothing left pending when the test settles
85
92
  });
86
93
 
87
94
  test('stopAll swallows per-adapter stop() errors (never throws)', async () => {