Skip to content

Commit 97e4c16

Browse files
committed
Add workspace configuration store (roots + ignore list) backend service (closes #125)
Introduce WorkspaceConfigService backed by ~/.launchpad/config.yml as the single source of truth for which directories Launchpad scans, replacing the hardcoded dev-root list and fixed scan depth in the TUI discovery walk. The service persists workspace roots (replace the built-in defaults when set), additionalRoots (appended to defaults only when roots is unset), an ignore list, scan depth (clamped 1-5, default 3), and a detectGitOnly gate (default true). It mirrors the ProjectRegistry persistence pattern: copy-on- write via a synchronized mutate, defaults on an absent file, warn-and-fall- back on a parse failure (surfaced via lastLoadError), and parent-dir creation on first write. ~ and ~/<subpath> expand to absolute paths on read; the null-vs-empty distinction for roots is preserved so an explicit empty list means "no roots at all". ProjectDiscovery now consumes roots, depth, the ignore list (descendant-aware pruning), and the git-only gate from the service. LiveProjectSearch stays broad over $HOME but applies the same ignore and git-only gates so on-demand search never resurfaces a project that eager discovery hides. Native reflection hints cover the two new YAML records. CLI aliases are intentionally out of scope for this change.
1 parent 357ddac commit 97e4c16

7 files changed

Lines changed: 779 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
- **Provider-agnostic TUI symbols**: Renamed `AppState.ollamaStatus` to `llmProviderStatus`; renamed matching helpers in `WelcomeView` (`ollamaReady/Dot/Label/Detail` -> `llmProviderReady/Dot/Label/Detail`), `Footer` (`ollamaState` -> `llmProviderState`), and `SettingsView`. The system-check card now derives the service row label from the resolved provider's `displayName()` instead of hardcoding "Ollama". Error hints in `buildLlmErrorMessage` are driven by the resolved provider name (falling back to "the local model server") rather than hardcoding "Ollama". The `/settings` command palette description now reads "Configure LLM provider and remote standards repo" (closes #27).
1111

1212
### Added
13+
- **Workspace configuration store**: A `WorkspaceConfigService` backed by `~/.launchpad/config.yml` is now the single source of truth for which directories Launchpad scans. It persists workspace `roots` (replace the built-in defaults when set), `additionalRoots` (appended to defaults only when `roots` is unset), an `ignored` list, scan `depth` (clamped 1-5, default 3), and a `detectGitOnly` gate (default true). `~` and `~/<subpath>` expand to absolute paths on read; an absent file means "use defaults" and a parse failure warns and falls back to defaults instead of erroring. TUI project discovery and live search now read roots/depth/ignored from the store and only surface build-roots that are also git repositories when `detectGitOnly` is on (closes #125).
1314
- **Gemini CLI compatibility projection**: An opt-in `gemini` `AgentProjection` points Gemini CLI at the canonical `AGENTS.md` instead of emitting a redundant context file. It writes a minimal `.gemini/settings.json` setting `context.fileName` to `AGENTS.md`, so Gemini reads the single source-of-truth contract with no duplicated content to drift out of sync. An existing hand-authored `.gemini/settings.json` is never clobbered. Adds a `CONFIG` file kind for vendor files that point at canonical output. Enable it via `projections: ["claude", "gemini"]` in the standards manifest or by ticking Gemini CLI in the TUI projection picker; no engine change was required (closes #145).
1415
- **Anthropic (Claude) cloud preparation provider**: An opt-in, paid synthesis backend on the `PreparationProvider` SPI (`provider=anthropic`), shipped via the Spring AI Anthropic starter. It is explicit-selection only - `autoDetectable()` is `false`, so auto-detection never routes preparation to a paid API even when a key is present; local AI stays the default. Authentication is vendor-specific (`launchpad.ai.anthropic.api-key`, bound from `LAUNCHPAD_ANTHROPIC_API_KEY`, falling back to the shared key), with its own cloud base URL and `anthropic-version`. A missing key or an unreachable endpoint degrades cleanly to deterministic-only output instead of crashing. Adding it was a single new bean plus its config - no router or health-checker changes (toward #144).
1516
- **Project readiness evaluator**: `ReadinessEvaluator.evaluate(Path)` returns a read-only `ReadinessResult` (status, structured reason lines, recommended action, last-prepared timestamp) derived from a project's prepared artifacts (`AGENTS.md` and the `.launchpad/*.json` sidecars) and its provenance stamp - the deterministic verdict the readiness dashboard renders. `ProvenanceHeader` gains a `parse(...)` inverse of `render()` (closes #122).

src/main/java/com/acltabontabon/launchpad/LaunchpadRuntimeHints.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
9494
// Per-project relationship metadata at <projectRoot>/.launchpad/project.yml,
9595
// overlaid into RegisteredProject on every registry read.
9696
"com.acltabontabon.launchpad.config.ProjectMetadataFile",
97+
// User-global workspace config at ~/.launchpad/config.yml (roots, ignore
98+
// list, depth, git-only gate). Nested records need explicit entries -
99+
// without these the discovery walk silently falls back to defaults under native.
100+
"com.acltabontabon.launchpad.config.WorkspaceConfigService$Document",
101+
"com.acltabontabon.launchpad.config.WorkspaceConfigService$Workspace",
97102
// Per-project scan persisted to <projectRoot>/.launchpad/scan.json by ScanStore.
98103
// Read back by MCP tools and any tooling that resumes from cache.
99104
"com.acltabontabon.launchpad.scanner.ProjectContext",
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
package com.acltabontabon.launchpad.config;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import com.fasterxml.jackson.annotation.JsonInclude;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import com.fasterxml.jackson.databind.SerializationFeature;
7+
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
8+
import java.io.IOException;
9+
import java.io.UncheckedIOException;
10+
import java.nio.file.Files;
11+
import java.nio.file.Path;
12+
import java.util.ArrayList;
13+
import java.util.LinkedHashSet;
14+
import java.util.List;
15+
import java.util.function.Function;
16+
import java.util.concurrent.atomic.AtomicReference;
17+
import org.slf4j.Logger;
18+
import org.slf4j.LoggerFactory;
19+
import org.springframework.stereotype.Component;
20+
21+
/**
22+
* User-global workspace configuration, persisted to {@code ~/.launchpad/config.yml}.
23+
* The single source of truth for which directories Launchpad scans for projects:
24+
* the dev roots, an ignore list, the scan depth, and a git-only detection gate.
25+
* Consumed today by the TUI discovery walk ({@code ProjectDiscovery}) and live
26+
* search; the UI (#137/#129), CLI (#124), and MCP layers read and write the same
27+
* service.
28+
* <p>
29+
* Schema:
30+
* <pre>
31+
* workspace:
32+
* roots: # when present, REPLACES the built-in defaults (even when empty)
33+
* - ~/Workspace
34+
* - ~/clients
35+
* additionalRoots: # appended to defaults only when roots is NOT set
36+
* - /work/projects
37+
* ignored:
38+
* - ~/dev/legacy-vendor-lib
39+
* depth: 3 # clamped 1-5, default 3
40+
* detectGitOnly: true
41+
* </pre>
42+
* {@code ~} and {@code ~/<subpath>} expand to absolute paths on read. An absent
43+
* file means "use defaults" (no error); a parse failure warns and falls back to
44+
* defaults, surfaced via {@link #lastLoadError()} so the TUI can show a warning
45+
* instead of silently behaving as if the file were empty.
46+
* <p>
47+
* Thread-safe via copy-on-write through an {@link AtomicReference}; writes are
48+
* serialized through {@link #mutate}, mirroring {@link ProjectRegistry}.
49+
*/
50+
@Component
51+
public class WorkspaceConfigService {
52+
53+
private static final Logger log = LoggerFactory.getLogger(WorkspaceConfigService.class);
54+
55+
/**
56+
* Built-in dev-root names relative to {@code $HOME}. Stored and seeded as
57+
* {@code ~}-prefixed specs so a config written from defaults stays portable.
58+
*/
59+
static final List<String> DEFAULT_ROOT_NAMES =
60+
List.of("Workspace", "workspace", "code", "Code", "dev", "Developer",
61+
"src", "Projects", "projects", "repos", "git");
62+
63+
static final int DEFAULT_DEPTH = 3;
64+
static final int MIN_DEPTH = 1;
65+
static final int MAX_DEPTH = 5;
66+
static final boolean DEFAULT_DETECT_GIT_ONLY = true;
67+
68+
/** Empty (all-defaults) state: roots/additionalRoots null = "use defaults". */
69+
private static final Workspace EMPTY = new Workspace(null, null, null, null, null);
70+
71+
@JsonIgnoreProperties(ignoreUnknown = true)
72+
record Document(Workspace workspace) {
73+
Document {
74+
workspace = workspace == null ? EMPTY : workspace;
75+
}
76+
}
77+
78+
@JsonIgnoreProperties(ignoreUnknown = true)
79+
record Workspace(
80+
List<String> roots,
81+
List<String> additionalRoots,
82+
List<String> ignored,
83+
Integer depth,
84+
Boolean detectGitOnly
85+
) {
86+
Workspace {
87+
// Deliberately do NOT coerce roots/additionalRoots: the replace-vs-append
88+
// rule depends on distinguishing "unset" (null) from "explicitly empty"
89+
// (an empty list, meaning "no roots at all"). Only ignored is normalized.
90+
ignored = ignored == null ? List.of() : List.copyOf(ignored);
91+
}
92+
93+
Workspace withRoots(List<String> r) {
94+
return new Workspace(r, additionalRoots, ignored, depth, detectGitOnly);
95+
}
96+
97+
Workspace withIgnored(List<String> i) {
98+
return new Workspace(roots, additionalRoots, i, depth, detectGitOnly);
99+
}
100+
101+
Workspace withDepth(Integer d) {
102+
return new Workspace(roots, additionalRoots, ignored, d, detectGitOnly);
103+
}
104+
105+
Workspace withDetectGitOnly(Boolean g) {
106+
return new Workspace(roots, additionalRoots, ignored, depth, g);
107+
}
108+
}
109+
110+
private final Path configFile;
111+
private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory())
112+
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
113+
.enable(SerializationFeature.INDENT_OUTPUT);
114+
private final AtomicReference<Workspace> current;
115+
private final AtomicReference<String> lastLoadError = new AtomicReference<>();
116+
117+
public WorkspaceConfigService() {
118+
this(Path.of(System.getProperty("user.home"), ".launchpad", "config.yml"));
119+
}
120+
121+
/** Construct against a custom config path (used by tests and embedders). */
122+
public WorkspaceConfigService(Path configFile) {
123+
this.configFile = configFile;
124+
this.current = new AtomicReference<>(loadFromDisk());
125+
}
126+
127+
// ---- Resolved read API (what the scanner consumes) ----------------------
128+
129+
/**
130+
* Effective scan roots as absolute, normalized paths. Applies the replace
131+
* (roots set) / append (only additionalRoots set) rule, expands {@code ~},
132+
* and de-dupes order-preserving. Path existence is NOT filtered here -
133+
* callers decide what to do with a configured-but-missing root.
134+
*/
135+
public List<Path> resolvedRoots() {
136+
var ws = current.get();
137+
List<String> effective;
138+
if (ws.roots() != null) {
139+
effective = ws.roots();
140+
} else {
141+
effective = new ArrayList<>(defaultRootSpecs());
142+
if (ws.additionalRoots() != null) {
143+
effective.addAll(ws.additionalRoots());
144+
}
145+
}
146+
return expandDistinct(effective);
147+
}
148+
149+
/** Ignored paths as absolute, normalized paths ({@code ~} expanded). */
150+
public List<Path> ignoredPaths() {
151+
return expandDistinct(current.get().ignored());
152+
}
153+
154+
/** Scan depth, clamped to {@value #MIN_DEPTH}..{@value #MAX_DEPTH} (default {@value #DEFAULT_DEPTH}). */
155+
public int depth() {
156+
var d = current.get().depth();
157+
return clampDepth(d == null ? DEFAULT_DEPTH : d);
158+
}
159+
160+
/** Whether discovery should only accept directories that are also git repositories. */
161+
public boolean detectGitOnly() {
162+
var g = current.get().detectGitOnly();
163+
return g == null ? DEFAULT_DETECT_GIT_ONLY : g;
164+
}
165+
166+
/**
167+
* Non-null reason string when the last load failed (file present but
168+
* unreadable/unparseable), otherwise null. Surfaced by the TUI so a broken
169+
* config warns instead of silently behaving as defaults.
170+
*/
171+
public String lastLoadError() {
172+
return lastLoadError.get();
173+
}
174+
175+
// ---- Raw read API (what the settings UI round-trips) --------------------
176+
177+
/** Raw {@code roots} entries as written (unexpanded), or empty when unset. */
178+
public List<String> rawRoots() {
179+
var r = current.get().roots();
180+
return r == null ? List.of() : r;
181+
}
182+
183+
/** Raw {@code additionalRoots} entries as written (unexpanded), or empty when unset. */
184+
public List<String> rawAdditionalRoots() {
185+
var a = current.get().additionalRoots();
186+
return a == null ? List.of() : a;
187+
}
188+
189+
/** Raw {@code ignored} entries as written (unexpanded). */
190+
public List<String> rawIgnored() {
191+
return current.get().ignored();
192+
}
193+
194+
/** True when {@code roots} is set explicitly (replace mode), false when defaults apply (append mode). */
195+
public boolean rootsExplicitlySet() {
196+
return current.get().roots() != null;
197+
}
198+
199+
/** Raw {@code depth} value as written, or null when unset. */
200+
public Integer rawDepth() {
201+
return current.get().depth();
202+
}
203+
204+
/** Raw {@code detectGitOnly} value as written, or null when unset. */
205+
public Boolean rawDetectGitOnly() {
206+
return current.get().detectGitOnly();
207+
}
208+
209+
// ---- Mutators (persist via mutate) --------------------------------------
210+
211+
/**
212+
* Add a root. Adding implies explicit/replace mode, so when {@code roots}
213+
* was unset it is first seeded from the built-in defaults. The raw spec
214+
* (e.g. {@code ~/clients}) is stored unexpanded for portability.
215+
*/
216+
public void addRoot(String raw) {
217+
var v = trim(raw);
218+
if (v.isBlank()) return;
219+
mutate(ws -> {
220+
var roots = ws.roots() == null
221+
? new ArrayList<>(defaultRootSpecs())
222+
: new ArrayList<>(ws.roots());
223+
if (roots.contains(v)) return ws;
224+
roots.add(v);
225+
return ws.withRoots(roots);
226+
});
227+
}
228+
229+
/** Remove a root by raw spec. Returns whether anything was removed. */
230+
public boolean removeRoot(String raw) {
231+
var v = trim(raw);
232+
if (v.isBlank()) return false;
233+
var ws = current.get();
234+
var base = ws.roots() == null ? defaultRootSpecs() : ws.roots();
235+
if (!base.contains(v)) return false;
236+
var next = new ArrayList<>(base);
237+
next.remove(v);
238+
mutate(w -> w.withRoots(next));
239+
return true;
240+
}
241+
242+
/** Add an ignored path (raw spec stored unexpanded). */
243+
public void addIgnored(String raw) {
244+
var v = trim(raw);
245+
if (v.isBlank()) return;
246+
mutate(ws -> {
247+
if (ws.ignored().contains(v)) return ws;
248+
var next = new ArrayList<>(ws.ignored());
249+
next.add(v);
250+
return ws.withIgnored(next);
251+
});
252+
}
253+
254+
/** Remove an ignored path by raw spec. Returns whether anything was removed. */
255+
public boolean removeIgnored(String raw) {
256+
var v = trim(raw);
257+
if (v.isBlank()) return false;
258+
if (!current.get().ignored().contains(v)) return false;
259+
mutate(ws -> ws.withIgnored(
260+
ws.ignored().stream().filter(x -> !x.equals(v)).toList()));
261+
return true;
262+
}
263+
264+
/** Set the scan depth (stored clamped to {@value #MIN_DEPTH}..{@value #MAX_DEPTH}). */
265+
public void setDepth(int depth) {
266+
var clamped = clampDepth(depth);
267+
mutate(ws -> ws.withDepth(clamped));
268+
}
269+
270+
/** Set the git-only detection gate. */
271+
public void setDetectGitOnly(boolean detectGitOnly) {
272+
mutate(ws -> ws.withDetectGitOnly(detectGitOnly));
273+
}
274+
275+
// ---- Internals ----------------------------------------------------------
276+
277+
private static List<String> defaultRootSpecs() {
278+
return DEFAULT_ROOT_NAMES.stream().map(n -> "~/" + n).toList();
279+
}
280+
281+
private static List<Path> expandDistinct(List<String> specs) {
282+
var out = new LinkedHashSet<Path>();
283+
for (var spec : specs) {
284+
if (spec == null || spec.isBlank()) continue;
285+
out.add(expand(spec));
286+
}
287+
return List.copyOf(out);
288+
}
289+
290+
/** Expand {@code ~} / {@code ~/<subpath>} against {@code $HOME}; absolutize and normalize. */
291+
private static Path expand(String raw) {
292+
var t = raw.trim();
293+
var home = System.getProperty("user.home");
294+
Path p;
295+
if (t.equals("~")) {
296+
p = Path.of(home);
297+
} else if (t.startsWith("~/")) {
298+
p = Path.of(home, t.substring(2));
299+
} else {
300+
p = Path.of(t);
301+
}
302+
return p.toAbsolutePath().normalize();
303+
}
304+
305+
private static int clampDepth(int d) {
306+
return Math.max(MIN_DEPTH, Math.min(MAX_DEPTH, d));
307+
}
308+
309+
private static String trim(String raw) {
310+
return raw == null ? "" : raw.trim();
311+
}
312+
313+
private void mutate(Function<Workspace, Workspace> fn) {
314+
synchronized (current) {
315+
var next = fn.apply(current.get());
316+
current.set(next);
317+
writeToDisk(next);
318+
}
319+
}
320+
321+
private Workspace loadFromDisk() {
322+
if (!Files.isRegularFile(configFile)) {
323+
lastLoadError.set(null);
324+
return EMPTY;
325+
}
326+
try {
327+
var doc = yaml.readValue(configFile.toFile(), Document.class);
328+
lastLoadError.set(null);
329+
return doc.workspace();
330+
} catch (IOException | RuntimeException e) {
331+
var summary = e.getClass().getSimpleName() + ": " + e.getMessage();
332+
lastLoadError.set("Failed to read " + configFile + " - " + summary);
333+
log.warn("Failed to read workspace config at {} - using defaults. "
334+
+ "If running under GraalVM native, this is usually a missing reflection hint.",
335+
configFile, e);
336+
return EMPTY;
337+
}
338+
}
339+
340+
private void writeToDisk(Workspace ws) {
341+
try {
342+
Files.createDirectories(configFile.getParent());
343+
yaml.writeValue(configFile.toFile(), new Document(ws));
344+
} catch (IOException e) {
345+
throw new UncheckedIOException("Failed to write " + configFile, e);
346+
}
347+
}
348+
}

0 commit comments

Comments
 (0)